Author SHA1 Message Date
clawbot 157bfbdd33 Stamp the tag or short commit in a plain docker build (closes #166)
check / check (push) Successful in 3m45s
.dockerignore now lets .git into the build context, without
.git/config, which can hold a remote URL with a credential. ARG VERSION
has no default: given none, the build stage takes the version from
git describe --tags --always, and fails if the context carries .git
and no version comes out. pixad now logs its name, version and
architecture as its first log line, through the existing
Logger.Identify, which nothing called.

Model: opus-5-5
2026-10-02 03:37:27 +00:00
clawbot 3a2eb1f4d2 Read the architecture at run time, not via a Buildarch ldflag (closes #164)
check / check (push) Successful in 14s
CONVENTIONS.md no longer passes the build architecture in through the
Makefile. The Buildarch variable, globals field and BUILDARCH Makefile
lines are removed from every example, and the logger's Identify example
logs "arch", runtime.GOARCH, as internal/logger already does. Wording
matches the HTTP server conventions in sneak/prompts.

Model: opus-5-5
2026-10-02 01:17:33 +02:00
8 changed files with 46 additions and 271 deletions
+5 -1
View File
@@ -1,4 +1,8 @@
.git
# .git is sent without its config. Without a VERSION build argument the
# stage that compiles runs `git describe --tags --always` on .git, which
# does not need .git/config; that file can hold a credential, such as a
# password in a remote URL or the token the CI checkout step stores there.
.git/config
.gitignore
.DS_Store
.env*
+12 -20
View File
@@ -112,15 +112,13 @@ import (
)
var (
Appname string = "CHANGEME"
Version string
Buildarch string
Appname string = "CHANGEME"
Version string
)
func main() {
globals.Appname = Appname
globals.Version = Version
globals.Buildarch = Buildarch
fx.New(
fx.Provide(
@@ -859,7 +857,7 @@ func (l *Logger) Identify() {
l.log.Info("starting",
"appname", l.params.Globals.Appname,
"version", l.params.Globals.Version,
"buildarch", l.params.Globals.Buildarch,
"arch", runtime.GOARCH,
)
}
```
@@ -979,23 +977,20 @@ import "go.uber.org/fx"
// Package-level variables (set from main)
var (
Appname string
Version string
Buildarch string
Appname string
Version string
)
// Struct for DI
type Globals struct {
Appname string
Version string
Buildarch string
Appname string
Version string
}
func New(lc fx.Lifecycle) (*Globals, error) {
n := &Globals{
Appname: Appname,
Buildarch: Buildarch,
Version: Version,
Appname: Appname,
Version: Version,
}
return n, nil
}
@@ -1006,15 +1001,13 @@ func New(lc fx.Lifecycle) (*Globals, error) {
```go
// cmd/httpd/main.go
var (
Appname string = "CHANGEME" // Default, overridden by build
Version string // Set at build time
Buildarch string // Set at build time
Appname string = "CHANGEME" // Default, overridden by build
Version string // Set at build time
)
func main() {
globals.Appname = Appname
globals.Version = Version
globals.Buildarch = Buildarch
// ...
}
```
@@ -1025,10 +1018,9 @@ Use ldflags to inject version information at build time:
```makefile
VERSION := $(shell git describe --tags --always)
BUILDARCH := $(shell go env GOARCH)
build:
go build -ldflags "-X main.Version=$(VERSION) -X main.Buildarch=$(BUILDARCH)" ./cmd/httpd
go build -ldflags "-X main.Version=$(VERSION)" ./cmd/httpd
```
---
+18 -7
View File
@@ -43,13 +43,24 @@ COPY . .
RUN make test
# VERSION is declared here, not earlier: a new value reruns only the
# build, not script/bootstrap or the tests. CGO stays enabled for
# govips; -trimpath keeps build paths out of the binary, and -s -w
# leave out the symbol table and debug information.
ARG VERSION=dev
RUN CGO_ENABLED=1 GOTOOLCHAIN=auto go build -trimpath \
-ldflags "-s -w -X main.Version=${VERSION}" \
-o /pixad ./cmd/pixad
# build, not script/bootstrap or the tests. Given none, the version is
# `git describe --tags --always` of the .git in the build context (git
# comes from script/bootstrap): the tag on a tagged commit, tag-N-gHASH
# after one, the short commit when no tag is reachable. A context that
# carries .git and still yields no version fails the build; one without
# .git, as from a source tarball, stamps an empty version. CGO stays
# enabled for govips; -trimpath keeps build paths out of the binary, and
# -s -w leave out the symbol table and debug information.
ARG VERSION
RUN version="${VERSION:-$(git describe --tags --always)}"; \
if [ -e .git ] && { [ -z "$version" ] || [ "$version" = dev ] || \
[ "$version" = unknown ]; }; then \
echo "the build context carries .git but yields no version" >&2; \
exit 1; \
fi; \
CGO_ENABLED=1 GOTOOLCHAIN=auto go build -trimpath \
-ldflags "-s -w -X main.Version=${version}" \
-o /pixad ./cmd/pixad
# Runtime stage
# alpine:3.21, 2026-02-25
+7
View File
@@ -29,6 +29,13 @@ P2: security: referer blacklist
# Completed Steps
- 2026-10-02 a plain `docker build .` stamps the tag or short commit, not
`dev` (closes #166): `.dockerignore` lets `.git` into the build context,
without `.git/config`; with no `VERSION` build argument the `Dockerfile`
takes the version from `git describe --tags --always`, and fails the build if
the context carries `.git` and no version comes out; `ARG VERSION` has no
default; pixad logs its version, with its name and architecture, as its first
log line at startup.
- 2026-09-29 the container makes `/var/lib/pixa` usable by itself (closes
#159): `deploy/docker-entrypoint.sh` creates the directory if it is missing,
gives the directory and everything in it to `pixad` when the directory or one
+4 -1
View File
@@ -56,6 +56,9 @@ func run(_ *cobra.Command, _ []string) {
middleware.New,
healthcheck.New,
),
fx.Invoke(func(*server.Server) {}),
fx.Invoke(
func(log *logger.Logger) { log.Identify() },
func(*server.Server) {},
),
).Run()
}
-80
View File
@@ -1,80 +0,0 @@
package main
import (
"errors"
"testing"
"go.uber.org/fx"
)
// errTestHook is the error returned by the test hooks that fail.
var errTestHook = errors.New("test hook failed")
// TestRunAppExitCode checks the exit code runApp returns: the one a
// shutdown request carries, 0 for a request without one (as for SIGINT or
// SIGTERM), and 1 when the app fails to start or to stop.
func TestRunAppExitCode(t *testing.T) {
t.Parallel()
cases := []struct {
name string
hook func(shutdowner fx.Shutdowner) fx.Hook
want int
}{
{
name: "shutdown requested with exit code 1",
hook: func(shutdowner fx.Shutdowner) fx.Hook {
return fx.StartHook(func() error {
return shutdowner.Shutdown(fx.ExitCode(1))
})
},
want: 1,
},
{
name: "shutdown requested without an exit code",
hook: func(shutdowner fx.Shutdowner) fx.Hook {
return fx.StartHook(func() error {
return shutdowner.Shutdown()
})
},
want: 0,
},
{
name: "start fails",
hook: func(fx.Shutdowner) fx.Hook {
return fx.StartHook(func() error { return errTestHook })
},
want: 1,
},
{
name: "stop fails",
hook: func(shutdowner fx.Shutdowner) fx.Hook {
return fx.StartStopHook(
func() error { return shutdowner.Shutdown() },
func() error { return errTestHook },
)
},
want: 1,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
app := fx.New(
fx.NopLogger,
fx.Invoke(func(lc fx.Lifecycle, shutdowner fx.Shutdowner) {
lc.Append(tc.hook(shutdowner))
}),
)
got := runApp(app)
t.Logf("runApp() = %d", got)
if got != tc.want {
t.Errorf("runApp() = %d, want %d", got, tc.want)
}
})
}
}
@@ -300,69 +300,3 @@ func TestProcessReleasesSlotOnError(t *testing.T) {
})
}
}
// TestWaitForProcessing holds a processing slot with a Process call that
// cannot finish reading its input. WaitForProcessing must report that image
// when its context ends first, wait for it otherwise, return 0 once it has
// finished, and give back the slots it took while waiting.
func TestWaitForProcessing(t *testing.T) {
t.Parallel()
proc := New(Params{MaxConcurrentProcessing: 2})
gate := make(chan struct{})
entered := make(chan struct{}, 1)
results := make(chan error, 1)
openGate := sync.OnceFunc(func() { close(gate) })
t.Cleanup(openGate)
processInBackground(proc, &gatedReader{
data: bytes.NewReader(createTestJPEG(t, 10, 10)), gate: gate,
entered: entered, counter: &readingCounter{},
}, results)
waitForEntries(t, entered, 1)
ctx, cancel := context.WithTimeout(t.Context(), 100*time.Millisecond)
defer cancel()
stillProcessing := proc.WaitForProcessing(ctx)
t.Logf("WaitForProcessing() after its context ended: %d", stillProcessing)
if stillProcessing != 1 {
t.Errorf("WaitForProcessing() after its context ended = %d, want 1",
stillProcessing)
}
waited := make(chan int, 1)
go func() { waited <- proc.WaitForProcessing(t.Context()) }()
select {
case got := <-waited:
t.Fatalf("WaitForProcessing() = %d while an image was being processed",
got)
case <-time.After(100 * time.Millisecond):
}
openGate()
err := <-results
if err != nil {
t.Errorf("Process() error = %v, want nil", err)
}
select {
case got := <-waited:
if got != 0 {
t.Errorf("WaitForProcessing() once processing finished = %d, want 0",
got)
}
case <-time.After(5 * time.Second):
t.Fatal("WaitForProcessing() did not return once processing finished")
}
if held := len(proc.processingSemaphore); held != 0 {
t.Errorf("%d slots still held after WaitForProcessing() returned", held)
}
}
-96
View File
@@ -1,96 +0,0 @@
package server
import (
"log/slog"
"net"
"testing"
"time"
"go.uber.org/fx"
"go.uber.org/fx/fxtest"
"sneak.berlin/go/pixa/internal/config"
"sneak.berlin/go/pixa/internal/globals"
"sneak.berlin/go/pixa/internal/logger"
)
// shutdownRecorder is an fx.Shutdowner that sends the options of each
// shutdown request on requests.
type shutdownRecorder struct {
requests chan []fx.ShutdownOption
}
func (r shutdownRecorder) Shutdown(opts ...fx.ShutdownOption) error {
r.requests <- opts
return nil
}
// TestSentryInitFailureFailsStartup checks that a Sentry DSN that cannot be
// used makes the server's start hook fail, so fx stops what has already
// started, instead of the process exiting from a goroutine.
func TestSentryInitFailureFailsStartup(t *testing.T) {
t.Parallel()
lc := fxtest.NewLifecycle(t)
log, err := logger.New(lc, logger.Params{Globals: &globals.Globals{}})
if err != nil {
t.Fatalf("logger.New() error = %v", err)
}
_, err = New(lc, Params{
Logger: log,
Globals: &globals.Globals{Appname: "pixad"},
Config: &config.Config{SentryDSN: "not-a-dsn"},
})
if err != nil {
t.Fatalf("New() error = %v", err)
}
err = lc.Start(t.Context())
t.Logf("Start() error = %v", err)
if err == nil {
t.Fatal("Start() error = nil, want the Sentry initialization error")
}
}
// TestListenErrorRequestsShutdownWithExitCode1 occupies the server's port
// and checks that the listen error asks fx to shut down with exit code 1.
func TestListenErrorRequestsShutdownWithExitCode1(t *testing.T) {
t.Parallel()
busy, err := (&net.ListenConfig{}).Listen(t.Context(), "tcp", ":0")
if err != nil {
t.Fatalf("Listen() error = %v", err)
}
t.Cleanup(func() { _ = busy.Close() })
addr, ok := busy.Addr().(*net.TCPAddr)
if !ok {
t.Fatalf("listener address %v is not a TCP address", busy.Addr())
}
requests := make(chan []fx.ShutdownOption, 1)
s := &Server{
log: slog.New(slog.DiscardHandler),
config: &config.Config{Port: addr.Port},
shutdowner: shutdownRecorder{requests: requests},
}
s.httpServer = s.newHTTPServer()
go s.serveUntilShutdown()
select {
case opts := <-requests:
t.Logf("shutdown options = %v", opts)
if len(opts) != 1 || opts[0] != fx.ExitCode(1) {
t.Errorf("shutdown options = %v, want [fx.ExitCode(1)]", opts)
}
case <-time.After(5 * time.Second):
t.Fatal("no shutdown was requested after the listen error")
}
}