From df54bbaacfe989a75491e3b4a4d3b8b34e161dcb Mon Sep 17 00:00:00 2001 From: Samuel Enocsson Date: Fri, 22 May 2026 14:27:28 +0200 Subject: [PATCH 01/10] docs: add CLAUDE.md with project context for Claude Code MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Captures design intent, gotchas, and references that aren't obvious from code alone — single-worker queue rationale, defense-in-depth security model, the not-yet-wired selfupdate package, and pointers to the design spec and implementation plan in docs/superpowers/. --- CLAUDE.md | 78 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 78 insertions(+) create mode 100644 CLAUDE.md diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..7414ea9 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,78 @@ +# package-updater — Claude context + +Webhook-driven Docker Compose updater. Go service, single binary, deployed as +a container with `/var/run/docker.sock` mounted. + +See `README.md` for user-facing docs. This file is for working ON the code. + +## Commands + +```bash +go test ./... # all tests, fast (no docker required) +go test -run TestFoo ./internal/api # single test +go build ./cmd/server # produces ./server +docker build -t package-updater:dev . # multi-stage, builds golang:1.26-alpine + +# Local smoke test (builds from Dockerfile, runs against /var/run/docker.sock): +cp env.sample .env && $EDITOR .env # fill UPDATER_API_KEY +docker compose -f docker-compose.local.yml up --build +curl -sH "Authorization: Bearer $UPDATER_API_KEY" \ + -d '{"image":"foo"}' http://localhost:8080/update | jq +``` + +## Architecture (one-line per package) + +- `cmd/server` — wiring only: config → docker client → discovery → queue → http +- `internal/api` — HTTP handlers, bearer-token auth, request-id + access-log middleware +- `internal/config` — env-var loading; fails fast if `UPDATER_API_KEY` missing +- `internal/discovery`— given an image, return Compose `Job`s to run (label parsing, path check, dedup) +- `internal/updater` — FIFO queue + single worker + `docker compose` subprocess executor +- `internal/selfupdate` — flush-then-exec wrapper for updating ourselves (NOT wired in live, see gotcha) +- `internal/metrics` — Prometheus collectors +- `internal/logging` — slog JSON, request-id context propagation + +## Design intent (do not break without discussion) + +- **Single FIFO worker by design** (`internal/updater/queue.go`). Spec §5.7. Never + parallelise the queue — two `docker compose` calls against the same stack race. +- **Defense in depth: token AND opt-in label AND STACKS_ROOT prefix** must all + hold before a container is touched. Weakening any of these breaks the security + model — discuss before changing. +- **Auth on `/update` only**. `/healthz`, `/metrics`, `/version` are intentionally + unauthenticated (internal network, scraper/healthcheck need them). See + `cmd/server/main.go` `routeAuth`. +- **`metrics *Metrics` parameters may be nil**; constructors and call sites check. + Tests rely on this — don't drop the nil-check. +- **Stateless**: no DB, no config file, no on-disk audit log. Docker daemon is + the source of truth. + +## Gotchas + +- **`internal/selfupdate.Wrapped` is implemented and unit-tested but NOT wired + into the live queue.** Spec §15 calls this out as a v1 gap. If you "fix" this, + read the package doc — flush-before-exec ordering is subtle. +- **Image matching is case-sensitive and tag/digest-agnostic.** The tricky case + is `localhost:5000/foo:v1` where the first colon is a port, not a tag. See + `internal/discovery/matching.go` `NormaliseImage`. +- **Path safety uses `filepath.Rel` + ".." prefix check**, NOT `strings.HasPrefix`. + Prevents the `/foo-evil` vs `/foo` confusion. See `internal/discovery/pathcheck.go`. +- **`STACKS_ROOT` defaults to `/home/shcizo/self-hosted`** in prod but `env.sample` + suggests `/tmp` for smoke tests. The default in `config.go` is the prod value + — set explicitly in test/dev envs. +- **Go 1.26.3** (`go.mod`). Dockerfile pins `golang:1.26-alpine`. Bumping one + without the other has caused a fix commit already. +- **`` C# convention from global CLAUDE.md does not apply here** — this + is Go. Use idiomatic GoDoc (`// FuncName does X.`). + +## References + +- Design spec: `docs/superpowers/specs/2026-05-22-package-updater-design.md` (489 lines, authoritative) +- Implementation plan: `docs/superpowers/plans/2026-05-22-package-updater-implementation.md` +- Consumer-side CI integration: `gitea-action/` + +## Conventions + +- Conventional Commits (`feat:`, `fix:`, `docs:`, `ci:`, `build:`, `chore:`). +- One package = one responsibility; interfaces defined at consumer site + (`api.Finder`, `api.Submitter`, `api.Pinger`) for testability. +- Table-driven tests with `stretchr/testify`. No mocks beyond hand-written fakes. From 8f999c69bde0cf0e6a358b1bb15d99c9abbec054 Mon Sep 17 00:00:00 2001 From: Samuel Enocsson Date: Sat, 4 Jul 2026 20:05:02 +0200 Subject: [PATCH 02/10] docs: add docker swarm support implementation plan --- .gitignore | 1 + .../plans/2026-07-04-docker-swarm-support.md | 859 ++++++++++++++++++ 2 files changed, 860 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-04-docker-swarm-support.md diff --git a/.gitignore b/.gitignore index eccac0c..3c370af 100644 --- a/.gitignore +++ b/.gitignore @@ -24,3 +24,4 @@ coverage.html # Serena MCP workspace .serena/ +.superpowers/ diff --git a/docs/superpowers/plans/2026-07-04-docker-swarm-support.md b/docs/superpowers/plans/2026-07-04-docker-swarm-support.md new file mode 100644 index 0000000..cd8950e --- /dev/null +++ b/docs/superpowers/plans/2026-07-04-docker-swarm-support.md @@ -0,0 +1,859 @@ +# Docker Swarm Support Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add a `MODE=swarm` operating mode so package-updater can update Docker Swarm services (`docker service update --image`) as an alternative to the existing Compose-only flow (`docker compose pull && up -d`), selected once per deployment via config — not mixed within one running instance. + +**Architecture:** Introduce parallel `SwarmDiscovery` (internal/discovery) and `SwarmExecutor` (internal/updater) implementations alongside the existing `Discovery`/`ComposeExecutor`. Both new types satisfy the exact same `api.Finder` / `updater.Executor` interfaces already in use, so `internal/api` and the FIFO `Queue` need no structural changes — only `cmd/server/main.go` branches on `cfg.Mode` to decide which pair to construct. The Compose-specific security gate (`STACKS_ROOT` path-check) does not apply to Swarm services (no local compose file exists); it is replaced by the same opt-in label mechanism, checked against the Swarm service's own labels instead of container labels. + +**Tech Stack:** Go 1.26.3, `github.com/docker/docker` SDK v28.5.2+incompatible (`client`, `api/types`, `api/types/swarm`), `stretchr/testify`. + +## Global Constraints + +- Go 1.26.3 (go.mod) — do not bump without also bumping the Dockerfile `golang:1.26-alpine` base image. +- Single FIFO worker in `internal/updater/queue.go` must remain single — never parallelize job execution, including for Swarm jobs. +- Defense in depth for Compose mode (token + opt-in label + STACKS_ROOT prefix) must keep holding unchanged. Swarm mode's gate is opt-in label only (no filesystem path exists to check) — this is a deliberate, narrower model for Swarm and must not be backported to weaken Compose mode. +- Auth remains on `/update` only; `/healthz`, `/metrics`, `/version` stay unauthenticated. +- `metrics *Metrics` parameters may be nil; every new call site must follow the existing `if m.metrics != nil { ... }` pattern — never assume non-nil. +- Stateless: no DB, no config file, no on-disk audit log required for Swarm mode either. +- Conventional Commits (`feat:`, `fix:`, `docs:`, `test:`) for every commit in this plan. +- Table-driven / one-assertion-focus tests with `testify/require`, matching existing style in `internal/discovery/discovery_test.go`. + +--- + +### Task 1: Add `Mode` to config + +**Files:** +- Modify: `internal/config/config.go` +- Test: `internal/config/config_test.go` + +**Interfaces:** +- Produces: `Config.Mode string` — always one of `"compose"` or `"swarm"` after `Load()` returns successfully. Later tasks (`cmd/server/main.go`) branch on this exact string. + +- [ ] **Step 1: Write the failing tests** + +Add to `internal/config/config_test.go` (create the file if it doesn't already cover this — check first; if it exists, add these functions alongside the existing ones): + +```go +func TestLoad_DefaultsToComposeMode(t *testing.T) { + t.Setenv("UPDATER_API_KEY", "secret") + cfg, err := config.Load() + require.NoError(t, err) + require.Equal(t, "compose", cfg.Mode) +} + +func TestLoad_AcceptsSwarmMode(t *testing.T) { + t.Setenv("UPDATER_API_KEY", "secret") + t.Setenv("MODE", "swarm") + cfg, err := config.Load() + require.NoError(t, err) + require.Equal(t, "swarm", cfg.Mode) +} + +func TestLoad_RejectsInvalidMode(t *testing.T) { + t.Setenv("UPDATER_API_KEY", "secret") + t.Setenv("MODE", "kubernetes") + _, err := config.Load() + require.Error(t, err) + require.Contains(t, err.Error(), "MODE") +} +``` + +Make sure the file imports `"testing"`, `"github.com/shcizo/package-updater/internal/config"`, and `"github.com/stretchr/testify/require"` (match whatever the existing `config_test.go` already imports). + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `go test ./internal/config/... -run 'TestLoad_.*Mode' -v` +Expected: FAIL — `cfg.Mode` does not compile (`Config` has no field `Mode`). + +- [ ] **Step 3: Implement** + +In `internal/config/config.go`, add the field and validation: + +```go +// Config holds all runtime configuration for the service. +type Config struct { + APIKey string + StacksRoot string + Port string + LogLevel string + UpdateTimeout time.Duration + OptInLabel string + Mode string +} +``` + +In `Load()`, after the existing field assignments (right after `OptInLabel`), add: + +```go + Mode: getenvDefault("MODE", "compose"), +``` + +so the literal becomes: + +```go + cfg := &Config{ + APIKey: os.Getenv("UPDATER_API_KEY"), + StacksRoot: getenvDefault("STACKS_ROOT", "/home/shcizo/self-hosted"), + Port: getenvDefault("PORT", "8080"), + LogLevel: getenvDefault("LOG_LEVEL", "info"), + OptInLabel: getenvDefault("OPT_IN_LABEL", "se.shcizo.auto-update"), + Mode: getenvDefault("MODE", "compose"), + } +``` + +Then, after the `APIKey` check and before the `UPDATE_TIMEOUT` parsing, add mode validation: + +```go + if cfg.Mode != "compose" && cfg.Mode != "swarm" { + return nil, fmt.Errorf("MODE %q is invalid: must be %q or %q", cfg.Mode, "compose", "swarm") + } +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `go test ./internal/config/... -v` +Expected: PASS — all config tests including the three new ones, and no regressions in `TestLoad_RequiresAPIKey`, `_AppliesDefaults`, `_OverridesViaEnv`, `_InvalidTimeoutErrors`. + +- [ ] **Step 5: Commit** + +```bash +git add internal/config/config.go internal/config/config_test.go +git commit -m "feat(config): add MODE env var to select compose or swarm operation" +``` + +--- + +### Task 2: Generalize `discovery.Job` for non-Compose jobs + +**Files:** +- Modify: `internal/discovery/discovery.go` +- Test: `internal/discovery/discovery_test.go` + +**Interfaces:** +- Produces: `discovery.Job` gains two fields, `Image string` (the full image reference the update request asked for — needed later by `SwarmExecutor` to know what to update *to*, since Swarm has no local compose file to `pull` against) and `ServiceID string` (empty for Compose jobs; the Swarm service ID for Swarm jobs, needed later by `SwarmExecutor` to target the right service). Both default to `""` for existing Compose jobs, so no existing caller breaks. +- Consumes: nothing new — this task only widens the struct and populates `Image` from the existing `FindJobs(ctx, image string)` parameter. + +- [ ] **Step 1: Write the failing test** + +Add to `internal/discovery/discovery_test.go`: + +```go +func TestFindJobs_PopulatesImageField(t *testing.T) { + fake := &fakeDockerClient{containers: []types.Container{ + mkContainer("registry.example.com/myapp:v1", mkComposeLabels( + "myapp-prod", "web", + "/home/shcizo/self-hosted/myapp-prod", + "/home/shcizo/self-hosted/myapp-prod/docker-compose.yml", + true, + )), + }} + d := discovery.New(fake, "/home/shcizo/self-hosted", "se.shcizo.auto-update") + jobs, err := d.FindJobs(context.Background(), "registry.example.com/myapp") + require.NoError(t, err) + require.Len(t, jobs, 1) + require.Equal(t, "registry.example.com/myapp", jobs[0].Image) + require.Empty(t, jobs[0].ServiceID) +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `go test ./internal/discovery/... -run TestFindJobs_PopulatesImageField -v` +Expected: FAIL — `jobs[0].Image` does not compile (`Job` has no field `Image`). + +- [ ] **Step 3: Implement** + +In `internal/discovery/discovery.go`, widen the struct: + +```go +// Job describes a single (project, service, config_files) update to execute. +type Job struct { + Project string + Service string + WorkingDir string + ConfigFiles []string + // Image is the full image reference the update request asked for. + // Compose jobs ignore it (compose.yml already pins the reference to + // pull); Swarm jobs need it to know what to set on the service spec. + Image string + // ServiceID is the Swarm service ID. Empty for Compose jobs. + ServiceID string + // Refused is true when the WorkingDir falls outside STACKS_ROOT. + // The job is returned so the caller can surface a per-job "refused" + // result, but it MUST NOT be executed. + Refused bool + RefusedReason string +} +``` + +In `FindJobs`, populate `Image` on the constructed job (leave `ServiceID` as its zero value): + +```go + jobs = append(jobs, Job{ + Project: cl.Project, + Service: cl.Service, + WorkingDir: cl.WorkingDir, + ConfigFiles: cl.ConfigFiles, + Image: image, + Refused: refused, + RefusedReason: reason, + }) +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `go test ./internal/discovery/... -v` +Expected: PASS — all discovery tests, including the new one and all pre-existing `TestFindJobs_*` cases (they don't assert on `Image`/`ServiceID` so they're unaffected). + +- [ ] **Step 5: Commit** + +```bash +git add internal/discovery/discovery.go internal/discovery/discovery_test.go +git commit -m "feat(discovery): add Image and ServiceID fields to Job for swarm support" +``` + +--- + +### Task 3: `SwarmDiscovery` — find matching Swarm services + +**Files:** +- Create: `internal/discovery/swarm.go` +- Test: `internal/discovery/swarm_test.go` + +**Interfaces:** +- Consumes: `discovery.Job{Project, Service, Image, ServiceID, Refused, RefusedReason}` (Task 2), `discovery.NormaliseImage(string) string` and `discovery.ImagesMatch(a, b string) bool` (existing, `internal/discovery/matching.go`), `discovery.HasOptIn(labels map[string]string, key string) bool` (existing, `internal/discovery/labels.go`). +- Produces: `discovery.SwarmDockerClient` interface, `discovery.NewSwarm(cli SwarmDockerClient, optInLabel string) *SwarmDiscovery`, method `(*SwarmDiscovery) FindJobs(ctx context.Context, image string) ([]Job, error)` — same signature as `*Discovery.FindJobs`, so both satisfy `api.Finder` (`internal/api/handlers.go`) without any change there. + +- [ ] **Step 1: Verify the Docker SDK method signature before writing code** + +Run: `go doc github.com/docker/docker/client.Client.ServiceList` + +Expected output shape (confirm it matches before proceeding — if the SDK signature differs, adjust the interface in Step 3 to match what `go doc` actually reports instead of the assumed signature below): + +``` +func (cli *Client) ServiceList(ctx context.Context, options types.ServiceListOptions) ([]swarm.Service, error) +``` + +Also run: `go doc github.com/docker/docker/api/types/swarm.Service` and `go doc github.com/docker/docker/api/types/swarm.ServiceSpec` to confirm `Service.ID`, `Service.Spec.Name` (via embedded `Annotations`), `Service.Spec.Labels` (via embedded `Annotations`), and `Service.Spec.TaskTemplate.ContainerSpec.Image` are reachable as described. Adjust field access in later steps if the embedding differs. + +- [ ] **Step 2: Write the failing tests** + +Create `internal/discovery/swarm_test.go`: + +```go +package discovery_test + +import ( + "context" + "errors" + "testing" + + "github.com/docker/docker/api/types" + "github.com/docker/docker/api/types/swarm" + "github.com/shcizo/package-updater/internal/discovery" + "github.com/stretchr/testify/require" +) + +type fakeSwarmDockerClient struct { + services []swarm.Service + err error +} + +func (f *fakeSwarmDockerClient) ServiceList(_ context.Context, _ types.ServiceListOptions) ([]swarm.Service, error) { + return f.services, f.err +} + +func mkService(id, name, image string, labels map[string]string) swarm.Service { + return swarm.Service{ + ID: id, + Spec: swarm.ServiceSpec{ + Annotations: swarm.Annotations{Name: name, Labels: labels}, + TaskTemplate: swarm.TaskSpec{ + ContainerSpec: &swarm.ContainerSpec{Image: image}, + }, + }, + } +} + +func TestSwarmFindJobs_MatchAndOptIn(t *testing.T) { + fake := &fakeSwarmDockerClient{services: []swarm.Service{ + mkService("svc-myapp", "myapp_web", "registry.example.com/myapp:v1", map[string]string{ + "se.shcizo.auto-update": "true", + }), + mkService("svc-other", "other_web", "registry.example.com/other:v1", map[string]string{ + "se.shcizo.auto-update": "true", + }), + }} + d := discovery.NewSwarm(fake, "se.shcizo.auto-update") + jobs, err := d.FindJobs(context.Background(), "registry.example.com/myapp") + require.NoError(t, err) + require.Len(t, jobs, 1) + require.Equal(t, "myapp_web", jobs[0].Service) + require.Equal(t, "svc-myapp", jobs[0].ServiceID) + require.Equal(t, "registry.example.com/myapp", jobs[0].Image) + require.False(t, jobs[0].Refused) + require.Empty(t, jobs[0].WorkingDir) + require.Empty(t, jobs[0].ConfigFiles) +} + +func TestSwarmFindJobs_SkipsWithoutOptIn(t *testing.T) { + fake := &fakeSwarmDockerClient{services: []swarm.Service{ + mkService("svc-myapp", "myapp_web", "registry.example.com/myapp:v1", nil), + }} + d := discovery.NewSwarm(fake, "se.shcizo.auto-update") + jobs, err := d.FindJobs(context.Background(), "registry.example.com/myapp") + require.NoError(t, err) + require.Empty(t, jobs) +} + +func TestSwarmFindJobs_DockerError(t *testing.T) { + fake := &fakeSwarmDockerClient{err: errors.New("connection refused")} + d := discovery.NewSwarm(fake, "se.shcizo.auto-update") + _, err := d.FindJobs(context.Background(), "registry.example.com/myapp") + require.Error(t, err) +} + +func TestSwarmFindJobs_NoContainerSpecIsSkipped(t *testing.T) { + fake := &fakeSwarmDockerClient{services: []swarm.Service{ + { + ID: "svc-weird", + Spec: swarm.ServiceSpec{ + Annotations: swarm.Annotations{Name: "weird", Labels: map[string]string{"se.shcizo.auto-update": "true"}}, + TaskTemplate: swarm.TaskSpec{ContainerSpec: nil}, + }, + }, + }} + d := discovery.NewSwarm(fake, "se.shcizo.auto-update") + jobs, err := d.FindJobs(context.Background(), "registry.example.com/myapp") + require.NoError(t, err) + require.Empty(t, jobs) +} +``` + +- [ ] **Step 3: Run tests to verify they fail** + +Run: `go test ./internal/discovery/... -run TestSwarmFindJobs -v` +Expected: FAIL to compile — `discovery.NewSwarm` and `discovery.SwarmDockerClient` don't exist yet. + +- [ ] **Step 4: Implement** + +Create `internal/discovery/swarm.go`: + +```go +package discovery + +import ( + "context" + "fmt" + + "github.com/docker/docker/api/types" + "github.com/docker/docker/api/types/swarm" +) + +// SwarmDockerClient is the subset of the Docker SDK SwarmDiscovery depends +// on. Defined as an interface so tests can supply a fake. +type SwarmDockerClient interface { + ServiceList(ctx context.Context, opts types.ServiceListOptions) ([]swarm.Service, error) +} + +// SwarmDiscovery orchestrates "given an image, which Swarm services should +// we update?". Unlike Discovery, it has no filesystem path to check — the +// opt-in label on the service itself is the only gate, since Swarm services +// have no local compose file to anchor a STACKS_ROOT check against. +type SwarmDiscovery struct { + cli SwarmDockerClient + optInLabel string +} + +// NewSwarm returns a SwarmDiscovery bound to the given Docker client and +// opt-in label. +func NewSwarm(cli SwarmDockerClient, optInLabel string) *SwarmDiscovery { + return &SwarmDiscovery{cli: cli, optInLabel: optInLabel} +} + +// FindJobs lists Swarm services, filters by image match + opt-in label, and +// returns one Job per matching service. Signature matches Discovery.FindJobs +// so both satisfy api.Finder. +func (d *SwarmDiscovery) FindJobs(ctx context.Context, image string) ([]Job, error) { + services, err := d.cli.ServiceList(ctx, types.ServiceListOptions{}) + if err != nil { + return nil, fmt.Errorf("docker service list: %w", err) + } + + var jobs []Job + for _, svc := range services { + if svc.Spec.TaskTemplate.ContainerSpec == nil { + continue + } + if !ImagesMatch(image, svc.Spec.TaskTemplate.ContainerSpec.Image) { + continue + } + if !HasOptIn(svc.Spec.Labels, d.optInLabel) { + continue + } + + jobs = append(jobs, Job{ + Service: svc.Spec.Name, + ServiceID: svc.ID, + Image: image, + }) + } + + return jobs, nil +} +``` + +- [ ] **Step 5: Run tests to verify they pass** + +Run: `go test ./internal/discovery/... -v` +Expected: PASS — all discovery tests including the four new `TestSwarmFindJobs_*` cases, no regressions. + +- [ ] **Step 6: Commit** + +```bash +git add internal/discovery/swarm.go internal/discovery/swarm_test.go +git commit -m "feat(discovery): add SwarmDiscovery to find opted-in Swarm services by image" +``` + +--- + +### Task 4: `SwarmExecutor` — run `docker service update --image` + +**Files:** +- Create: `internal/updater/swarm_executor.go` +- Test: `internal/updater/swarm_executor_test.go` + +**Interfaces:** +- Consumes: `discovery.Job{Service, ServiceID, Image, Refused, RefusedReason}` (Task 2/3). +- Produces: `updater.SwarmDockerClient` interface, `updater.NewSwarmExecutor(cli SwarmDockerClient) *SwarmExecutor`, method `(*SwarmExecutor) Execute(ctx context.Context, job discovery.Job) error` — satisfies `updater.Executor` (`internal/updater/executor.go`), so `Queue` (`internal/updater/queue.go`) needs no changes. + +- [ ] **Step 1: Verify the Docker SDK method signatures before writing code** + +Run: `go doc github.com/docker/docker/client.Client.ServiceInspectWithRaw` and `go doc github.com/docker/docker/client.Client.ServiceUpdate` + +Expected output shape (confirm before proceeding — adjust the interface in Step 3 if the SDK reports something different): + +``` +func (cli *Client) ServiceInspectWithRaw(ctx context.Context, serviceID string, options types.ServiceInspectOptions) (swarm.Service, []byte, error) +func (cli *Client) ServiceUpdate(ctx context.Context, serviceID string, version swarm.Version, service swarm.ServiceSpec, options types.ServiceUpdateOptions) (types.ServiceUpdateResponse, error) +``` + +- [ ] **Step 2: Write the failing tests** + +Create `internal/updater/swarm_executor_test.go`: + +```go +package updater_test + +import ( + "context" + "errors" + "testing" + + "github.com/docker/docker/api/types" + "github.com/docker/docker/api/types/swarm" + "github.com/shcizo/package-updater/internal/discovery" + "github.com/shcizo/package-updater/internal/updater" + "github.com/stretchr/testify/require" +) + +type fakeSwarmDockerClient struct { + inspectService swarm.Service + inspectErr error + updateErr error + + updateCalledServiceID string + updateCalledVersion swarm.Version + updateCalledSpec swarm.ServiceSpec + updateCalledOpts types.ServiceUpdateOptions +} + +func (f *fakeSwarmDockerClient) ServiceInspectWithRaw(_ context.Context, _ string, _ types.ServiceInspectOptions) (swarm.Service, []byte, error) { + return f.inspectService, nil, f.inspectErr +} + +func (f *fakeSwarmDockerClient) ServiceUpdate(_ context.Context, serviceID string, version swarm.Version, spec swarm.ServiceSpec, opts types.ServiceUpdateOptions) (types.ServiceUpdateResponse, error) { + f.updateCalledServiceID = serviceID + f.updateCalledVersion = version + f.updateCalledSpec = spec + f.updateCalledOpts = opts + return types.ServiceUpdateResponse{}, f.updateErr +} + +func mkInspectService(version uint64, image string) swarm.Service { + return swarm.Service{ + ID: "svc-myapp", + Version: swarm.Version{Index: version}, + Spec: swarm.ServiceSpec{ + Annotations: swarm.Annotations{Name: "myapp_web"}, + TaskTemplate: swarm.TaskSpec{ContainerSpec: &swarm.ContainerSpec{Image: image}}, + }, + } +} + +func TestSwarmExecutor_UpdatesImageAndVersion(t *testing.T) { + fake := &fakeSwarmDockerClient{inspectService: mkInspectService(7, "registry.example.com/myapp:v1")} + e := updater.NewSwarmExecutor(fake) + + job := discovery.Job{Service: "myapp_web", ServiceID: "svc-myapp", Image: "registry.example.com/myapp:v2"} + err := e.Execute(context.Background(), job) + + require.NoError(t, err) + require.Equal(t, "svc-myapp", fake.updateCalledServiceID) + require.Equal(t, uint64(7), fake.updateCalledVersion.Index) + require.Equal(t, "registry.example.com/myapp:v2", fake.updateCalledSpec.TaskTemplate.ContainerSpec.Image) + require.True(t, fake.updateCalledOpts.QueryRegistry) +} + +func TestSwarmExecutor_InspectError(t *testing.T) { + fake := &fakeSwarmDockerClient{inspectErr: errors.New("service not found")} + e := updater.NewSwarmExecutor(fake) + err := e.Execute(context.Background(), discovery.Job{ServiceID: "svc-missing", Image: "x:v2"}) + require.Error(t, err) +} + +func TestSwarmExecutor_UpdateError(t *testing.T) { + fake := &fakeSwarmDockerClient{ + inspectService: mkInspectService(1, "registry.example.com/myapp:v1"), + updateErr: errors.New("update rejected"), + } + e := updater.NewSwarmExecutor(fake) + err := e.Execute(context.Background(), discovery.Job{ServiceID: "svc-myapp", Image: "registry.example.com/myapp:v2"}) + require.Error(t, err) +} + +func TestSwarmExecutor_RefusedJobIsNotExecuted(t *testing.T) { + fake := &fakeSwarmDockerClient{} + e := updater.NewSwarmExecutor(fake) + job := discovery.Job{ServiceID: "svc-myapp", Image: "x:v2", Refused: true, RefusedReason: "not opted in"} + err := e.Execute(context.Background(), job) + require.Error(t, err) + require.Empty(t, fake.updateCalledServiceID) +} +``` + +- [ ] **Step 3: Run tests to verify they fail** + +Run: `go test ./internal/updater/... -run TestSwarmExecutor -v` +Expected: FAIL to compile — `updater.NewSwarmExecutor` doesn't exist yet. + +- [ ] **Step 4: Implement** + +Create `internal/updater/swarm_executor.go`: + +```go +package updater + +import ( + "context" + "fmt" + + "github.com/docker/docker/api/types" + "github.com/docker/docker/api/types/swarm" + "github.com/shcizo/package-updater/internal/discovery" +) + +// SwarmDockerClient is the subset of the Docker SDK SwarmExecutor depends +// on. Defined as an interface so tests can supply a fake. +type SwarmDockerClient interface { + ServiceInspectWithRaw(ctx context.Context, serviceID string, opts types.ServiceInspectOptions) (swarm.Service, []byte, error) + ServiceUpdate(ctx context.Context, serviceID string, version swarm.Version, service swarm.ServiceSpec, opts types.ServiceUpdateOptions) (types.ServiceUpdateResponse, error) +} + +// SwarmExecutor updates a Swarm service's image via the Docker API, +// equivalent to `docker service update --image`. +type SwarmExecutor struct { + cli SwarmDockerClient +} + +// NewSwarmExecutor returns an executor that drives Swarm service updates +// through the Docker API. +func NewSwarmExecutor(cli SwarmDockerClient) *SwarmExecutor { + return &SwarmExecutor{cli: cli} +} + +// Execute inspects the service to get its current spec + version (required +// by the Docker API as an optimistic-concurrency token), sets the new image +// on the container spec, and calls ServiceUpdate. QueryRegistry is set so a +// floating tag (e.g. ":latest") resolves to a fresh digest and actually +// triggers a rolling update instead of being treated as unchanged. +func (e *SwarmExecutor) Execute(ctx context.Context, job discovery.Job) error { + if job.Refused { + return fmt.Errorf("refused: %s", job.RefusedReason) + } + + svc, _, err := e.cli.ServiceInspectWithRaw(ctx, job.ServiceID, types.ServiceInspectOptions{}) + if err != nil { + return fmt.Errorf("inspect service %s: %w", job.ServiceID, err) + } + + spec := svc.Spec + spec.TaskTemplate.ContainerSpec.Image = job.Image + + if _, err := e.cli.ServiceUpdate(ctx, job.ServiceID, svc.Version, spec, types.ServiceUpdateOptions{QueryRegistry: true}); err != nil { + return fmt.Errorf("update service %s: %w", job.ServiceID, err) + } + return nil +} +``` + +- [ ] **Step 5: Run tests to verify they pass** + +Run: `go test ./internal/updater/... -v` +Expected: PASS — all updater tests including the four new `TestSwarmExecutor_*` cases, no regressions in `TestQueue_*` or `TestWorker_*`. + +- [ ] **Step 6: Commit** + +```bash +git add internal/updater/swarm_executor.go internal/updater/swarm_executor_test.go +git commit -m "feat(updater): add SwarmExecutor to run docker service update via the Docker API" +``` + +--- + +### Task 5: Surface `ServiceID` in the API response + +**Files:** +- Modify: `internal/api/types.go` +- Modify: `internal/api/handlers.go` +- Test: `internal/api/swarm_result_test.go` + +**Interfaces:** +- Consumes: `discovery.Job.ServiceID` (Task 2), `updater.Result{Job discovery.Job, Status Status, Error string, DurationMs int64}` (existing, `internal/updater/queue.go`). +- Produces: `api.ResultDTO` gains `ServiceID string` (json `service_id`, omitempty) alongside the existing `ComposeFile` field, which stays empty for Swarm jobs (already guarded by the existing `len(res.Job.ConfigFiles) > 0` check). + +- [ ] **Step 1: Write the failing test** + +Create `internal/api/swarm_result_test.go`: + +```go +package api_test + +import ( + "context" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/docker/docker/api/types" + "github.com/shcizo/package-updater/internal/api" + "github.com/shcizo/package-updater/internal/discovery" + "github.com/shcizo/package-updater/internal/updater" + "github.com/stretchr/testify/require" +) + +type swarmResultFinder struct{ jobs []discovery.Job } + +func (f *swarmResultFinder) FindJobs(_ context.Context, _ string) ([]discovery.Job, error) { + return f.jobs, nil +} + +type swarmResultSubmitter struct{ results []updater.Result } + +func (s *swarmResultSubmitter) Submit(_ context.Context, _ []discovery.Job) []updater.Result { + return s.results +} + +type swarmResultPinger struct{} + +func (swarmResultPinger) Ping(_ context.Context) (types.Ping, error) { return types.Ping{}, nil } + +func TestUpdate_SwarmJobPopulatesServiceID(t *testing.T) { + job := discovery.Job{Service: "myapp_web", ServiceID: "svc123", Image: "registry.example.com/myapp:v2"} + finder := &swarmResultFinder{jobs: []discovery.Job{job}} + submitter := &swarmResultSubmitter{results: []updater.Result{ + {Job: job, Status: updater.StatusUpdated, DurationMs: 42}, + }} + h := api.NewHandlers(finder, submitter, swarmResultPinger{}, "v", "c", "b", nil) + + req := httptest.NewRequest(http.MethodPost, "/update", strings.NewReader(`{"image":"registry.example.com/myapp"}`)) + rec := httptest.NewRecorder() + h.Update(rec, req) + + require.Equal(t, http.StatusOK, rec.Code) + require.Contains(t, rec.Body.String(), `"service_id":"svc123"`) + require.Contains(t, rec.Body.String(), `"service":"myapp_web"`) +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `go test ./internal/api/... -run TestUpdate_SwarmJobPopulatesServiceID -v` +Expected: FAIL — response body has no `service_id` key (`ResultDTO` has no such field yet). + +- [ ] **Step 3: Implement** + +In `internal/api/types.go`, add the field to `ResultDTO`: + +```go +// ResultDTO is one row in UpdateResponse.Results. +type ResultDTO struct { + Project string `json:"project"` + Service string `json:"service"` + ComposeFile string `json:"compose_file"` + ServiceID string `json:"service_id,omitempty"` + Status string `json:"status"` + Error string `json:"error,omitempty"` + DurationMs int64 `json:"duration_ms"` +} +``` + +In `internal/api/handlers.go`, inside the `Update` handler's results loop, add `ServiceID` to the constructed `ResultDTO`: + +```go + resp.Results = append(resp.Results, ResultDTO{ + Project: res.Job.Project, + Service: res.Job.Service, + ComposeFile: composeFile, + ServiceID: res.Job.ServiceID, + Status: string(res.Status), + Error: res.Error, + DurationMs: res.DurationMs, + }) +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `go test ./internal/api/... -v` +Expected: PASS — the new test plus all existing `TestUpdate_*`, `TestHealthz_*`, `TestVersion` cases (they don't assert on absence of `service_id`, and `omitempty` keeps it out of Compose-mode responses since `ServiceID` is `""` for those). + +- [ ] **Step 5: Commit** + +```bash +git add internal/api/types.go internal/api/handlers.go internal/api/swarm_result_test.go +git commit -m "feat(api): surface ServiceID in update results for swarm jobs" +``` + +--- + +### Task 6: Wire `MODE` in `cmd/server/main.go` + +**Files:** +- Modify: `cmd/server/main.go` + +**Interfaces:** +- Consumes: `config.Config.Mode` (Task 1), `discovery.New` / `discovery.NewSwarm` (Task 3), `updater.NewComposeExecutor` / `updater.NewSwarmExecutor` (Task 4), `api.Finder` / `updater.Executor` (existing). +- Produces: nothing new for other tasks to consume — this is the final wiring step. + +There is no unit test for `main.go` in this codebase (it's pure wiring); verification is a successful build plus the full test suite passing, run in Step 3. + +- [ ] **Step 1: Modify the wiring** + +In `cmd/server/main.go`, replace: + +```go + disc := discovery.New(dockerCli, cfg.StacksRoot, cfg.OptInLabel) + exec := updater.NewComposeExecutor() +``` + +with: + +```go + var finder api.Finder + var exec updater.Executor + switch cfg.Mode { + case "swarm": + finder = discovery.NewSwarm(dockerCli, cfg.OptInLabel) + exec = updater.NewSwarmExecutor(dockerCli) + case "compose": + finder = discovery.New(dockerCli, cfg.StacksRoot, cfg.OptInLabel) + exec = updater.NewComposeExecutor() + default: + // Unreachable: config.Load already validates Mode, but guard + // here too rather than silently falling through to a nil + // finder/exec pair. + return fmt.Errorf("unknown MODE %q", cfg.Mode) + } +``` + +Then update the `NewHandlers` call, which currently reads: + +```go + handlers := api.NewHandlers(disc, &submitterAdapter{queue: queue, timeout: cfg.UpdateTimeout}, dockerCli, version, commit, buildTime, m) +``` + +to use `finder` instead of `disc`: + +```go + handlers := api.NewHandlers(finder, &submitterAdapter{queue: queue, timeout: cfg.UpdateTimeout}, dockerCli, version, commit, buildTime, m) +``` + +Note `queue := updater.NewQueue(exec, m)` (a few lines above) already references `exec` — no change needed there since `exec` is now declared by the switch instead of a direct `updater.NewComposeExecutor()` call, but it's the same variable name so the line is unchanged. Also update the startup log line to include the mode: + +```go + logger.Info("starting", + "version", version, "commit", commit, "port", cfg.Port, + "mode", cfg.Mode, "stacks_root", cfg.StacksRoot, "opt_in_label", cfg.OptInLabel, + ) +``` + +- [ ] **Step 2: Build** + +Run: `go build ./...` +Expected: builds cleanly with no errors. + +- [ ] **Step 3: Run the full test suite** + +Run: `go test ./...` +Expected: PASS across all packages — this exercises every task in this plan together for the first time. + +- [ ] **Step 4: Manual smoke check (compose mode, default)** + +Run: `go run ./cmd/server &` (with `UPDATER_API_KEY` set) and check the startup log line shows `"mode":"compose"` (or the equivalent field in whatever log format `internal/logging` renders). Stop the process afterward. + +- [ ] **Step 5: Commit** + +```bash +git add cmd/server/main.go +git commit -m "feat: wire MODE config to select compose or swarm discovery/executor" +``` + +--- + +### Task 7: Documentation + +**Files:** +- Modify: `README.md` +- Modify: `CLAUDE.md` + +**Interfaces:** None — documentation only, no code interfaces produced or consumed. + +- [ ] **Step 1: Update `README.md`** + +Read the current README first to match its existing structure (env var table, "known limitations" section, etc. — check what's there before editing rather than guessing the exact heading names). Add: +- `MODE` to the environment variable table: `MODE` — `compose` (default) or `swarm`. Selects the update mechanism for the whole deployment; not mixed per-request. +- A short "Swarm mode" subsection explaining: services must carry the opt-in label (`OPT_IN_LABEL`, default `se.shcizo.auto-update`) directly on the *service* (`docker service update --label-add se.shcizo.auto-update=true ` or set in the stack file's `deploy.labels`), not on the container/task, since Swarm mode reads `Service.Spec.Labels`. Also note the updater must run on/against a Swarm manager node (`docker service update` requires manager API access) — pointing `DOCKER_HOST` at a manager, or scheduling the updater container itself on a manager node with the socket mounted, is the operator's responsibility. +- Update/remove the existing "single host only" limitation note (if present) to say Compose mode remains single-host; Swarm mode is the multi-node path but only from a manager node's point of view. + +- [ ] **Step 2: Update `CLAUDE.md`** + +In the "Architecture" section, add a one-line entry for the new files: +``` +- `internal/discovery/swarm.go` — SwarmDiscovery: same FindJobs signature, lists `docker service ls`, gates on service-level opt-in label instead of STACKS_ROOT path-check +- `internal/updater/swarm_executor.go` — SwarmExecutor: `docker service update --image` via the Docker API instead of a `docker compose` subprocess +``` + +In "Design intent (do not break without discussion)", add: +``` +- **Compose mode and Swarm mode are selected once per deployment via `MODE`**, never + mixed at request time. Swarm mode's security gate is opt-in label only — there is + no STACKS_ROOT-equivalent path check, since Swarm services have no local compose + file. Don't add one; don't weaken Compose mode's three-factor gate to match. +``` + +In "Gotchas", add: +``` +- **Swarm mode requires manager-node API access.** `docker service update` fails + with a permission error against a worker-only node. This is an operator/deployment + concern (point `DOCKER_HOST` at a manager, or schedule the updater on a manager), + not something the code can detect or work around. +``` + +- [ ] **Step 3: Commit** + +```bash +git add README.md CLAUDE.md +git commit -m "docs: document MODE=swarm support and its opt-in label / manager-node requirements" +``` From 1002bb7b964f910a5df86be7c8271b72a90555cc Mon Sep 17 00:00:00 2001 From: Samuel Enocsson Date: Sat, 4 Jul 2026 20:06:50 +0200 Subject: [PATCH 03/10] feat(config): add MODE env var to select compose or swarm operation --- internal/config/config.go | 6 ++++++ internal/config/config_test.go | 23 +++++++++++++++++++++++ 2 files changed, 29 insertions(+) diff --git a/internal/config/config.go b/internal/config/config.go index 80807de..b143434 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -16,6 +16,7 @@ type Config struct { LogLevel string UpdateTimeout time.Duration OptInLabel string + Mode string } // Load reads configuration from environment variables, applies defaults, @@ -28,12 +29,17 @@ func Load() (*Config, error) { Port: getenvDefault("PORT", "8080"), LogLevel: getenvDefault("LOG_LEVEL", "info"), OptInLabel: getenvDefault("OPT_IN_LABEL", "se.shcizo.auto-update"), + Mode: getenvDefault("MODE", "compose"), } if cfg.APIKey == "" { return nil, errors.New("UPDATER_API_KEY is required") } + if cfg.Mode != "compose" && cfg.Mode != "swarm" { + return nil, fmt.Errorf("MODE %q is invalid: must be %q or %q", cfg.Mode, "compose", "swarm") + } + timeoutStr := getenvDefault("UPDATE_TIMEOUT", "5m") d, err := time.ParseDuration(timeoutStr) if err != nil { diff --git a/internal/config/config_test.go b/internal/config/config_test.go index bd50ab7..6e3fe88 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -57,3 +57,26 @@ func TestLoad_InvalidTimeoutErrors(t *testing.T) { require.Error(t, err) require.Contains(t, err.Error(), "UPDATE_TIMEOUT") } + +func TestLoad_DefaultsToComposeMode(t *testing.T) { + t.Setenv("UPDATER_API_KEY", "secret") + cfg, err := config.Load() + require.NoError(t, err) + require.Equal(t, "compose", cfg.Mode) +} + +func TestLoad_AcceptsSwarmMode(t *testing.T) { + t.Setenv("UPDATER_API_KEY", "secret") + t.Setenv("MODE", "swarm") + cfg, err := config.Load() + require.NoError(t, err) + require.Equal(t, "swarm", cfg.Mode) +} + +func TestLoad_RejectsInvalidMode(t *testing.T) { + t.Setenv("UPDATER_API_KEY", "secret") + t.Setenv("MODE", "kubernetes") + _, err := config.Load() + require.Error(t, err) + require.Contains(t, err.Error(), "MODE") +} From 9cf484e164f818dc971c721ba9b3aadd99dfeef3 Mon Sep 17 00:00:00 2001 From: Samuel Enocsson Date: Sat, 4 Jul 2026 20:09:39 +0200 Subject: [PATCH 04/10] feat(discovery): add Image and ServiceID fields to Job for swarm support --- internal/discovery/discovery.go | 13 ++++++++++--- internal/discovery/discovery_test.go | 17 +++++++++++++++++ 2 files changed, 27 insertions(+), 3 deletions(-) diff --git a/internal/discovery/discovery.go b/internal/discovery/discovery.go index 5f5e687..6269169 100644 --- a/internal/discovery/discovery.go +++ b/internal/discovery/discovery.go @@ -11,10 +11,16 @@ import ( // Job describes a single (project, service, config_files) update to execute. type Job struct { - Project string - Service string - WorkingDir string + Project string + Service string + WorkingDir string ConfigFiles []string + // Image is the full image reference the update request asked for. + // Compose jobs ignore it (compose.yml already pins the reference to + // pull); Swarm jobs need it to know what to set on the service spec. + Image string + // ServiceID is the Swarm service ID. Empty for Compose jobs. + ServiceID string // Refused is true when the WorkingDir falls outside STACKS_ROOT. // The job is returned so the caller can surface a per-job "refused" // result, but it MUST NOT be executed. @@ -74,6 +80,7 @@ func (d *Discovery) FindJobs(ctx context.Context, image string) ([]Job, error) { Service: cl.Service, WorkingDir: cl.WorkingDir, ConfigFiles: cl.ConfigFiles, + Image: image, Refused: refused, RefusedReason: reason, }) diff --git a/internal/discovery/discovery_test.go b/internal/discovery/discovery_test.go index 285d83a..c1f5b0a 100644 --- a/internal/discovery/discovery_test.go +++ b/internal/discovery/discovery_test.go @@ -153,3 +153,20 @@ func TestFindJobs_NonComposeContainerIsSkipped(t *testing.T) { require.NoError(t, err) require.Empty(t, jobs) } + +func TestFindJobs_PopulatesImageField(t *testing.T) { + fake := &fakeDockerClient{containers: []types.Container{ + mkContainer("registry.example.com/myapp:v1", mkComposeLabels( + "myapp-prod", "web", + "/home/shcizo/self-hosted/myapp-prod", + "/home/shcizo/self-hosted/myapp-prod/docker-compose.yml", + true, + )), + }} + d := discovery.New(fake, "/home/shcizo/self-hosted", "se.shcizo.auto-update") + jobs, err := d.FindJobs(context.Background(), "registry.example.com/myapp") + require.NoError(t, err) + require.Len(t, jobs, 1) + require.Equal(t, "registry.example.com/myapp", jobs[0].Image) + require.Empty(t, jobs[0].ServiceID) +} From a8524e5ff43b70021d12e9ba0d287655b6bc69ee Mon Sep 17 00:00:00 2001 From: Samuel Enocsson Date: Sat, 4 Jul 2026 20:12:44 +0200 Subject: [PATCH 05/10] feat(discovery): add SwarmDiscovery to find opted-in Swarm services by image Verified the Docker SDK v28.5.2+incompatible ServiceList signature via go doc before implementing: the options type is swarm.ServiceListOptions, not types.ServiceListOptions as the brief assumed. Everything else (Service.ID, Service.Spec via embedded Annotations for Name/Labels, TaskTemplate.ContainerSpec.Image) matched the brief exactly. Reuses discovery.ImagesMatch and discovery.HasOptIn rather than duplicating matching/opt-in logic. Opt-in label is read from the service spec's own labels since Swarm services have no local compose file to anchor a STACKS_ROOT path check against (unlike Compose mode). --- internal/discovery/swarm.go | 60 ++++++++++++++++++++++ internal/discovery/swarm_test.go | 86 ++++++++++++++++++++++++++++++++ 2 files changed, 146 insertions(+) create mode 100644 internal/discovery/swarm.go create mode 100644 internal/discovery/swarm_test.go diff --git a/internal/discovery/swarm.go b/internal/discovery/swarm.go new file mode 100644 index 0000000..108fad5 --- /dev/null +++ b/internal/discovery/swarm.go @@ -0,0 +1,60 @@ +package discovery + +import ( + "context" + "fmt" + + "github.com/docker/docker/api/types/swarm" +) + +// SwarmDockerClient is the subset of the Docker SDK SwarmDiscovery depends +// on. Defined as an interface so tests can supply a fake. +type SwarmDockerClient interface { + ServiceList(ctx context.Context, opts swarm.ServiceListOptions) ([]swarm.Service, error) +} + +// SwarmDiscovery orchestrates "given an image, which Swarm services should +// we update?". Unlike Discovery, it has no filesystem path to check — the +// opt-in label on the service itself is the only gate, since Swarm services +// have no local compose file to anchor a STACKS_ROOT check against. +type SwarmDiscovery struct { + cli SwarmDockerClient + optInLabel string +} + +// NewSwarm returns a SwarmDiscovery bound to the given Docker client and +// opt-in label. +func NewSwarm(cli SwarmDockerClient, optInLabel string) *SwarmDiscovery { + return &SwarmDiscovery{cli: cli, optInLabel: optInLabel} +} + +// FindJobs lists Swarm services, filters by image match + opt-in label, and +// returns one Job per matching service. Signature matches Discovery.FindJobs +// so both satisfy api.Finder. +func (d *SwarmDiscovery) FindJobs(ctx context.Context, image string) ([]Job, error) { + services, err := d.cli.ServiceList(ctx, swarm.ServiceListOptions{}) + if err != nil { + return nil, fmt.Errorf("docker service list: %w", err) + } + + var jobs []Job + for _, svc := range services { + if svc.Spec.TaskTemplate.ContainerSpec == nil { + continue + } + if !ImagesMatch(image, svc.Spec.TaskTemplate.ContainerSpec.Image) { + continue + } + if !HasOptIn(svc.Spec.Labels, d.optInLabel) { + continue + } + + jobs = append(jobs, Job{ + Service: svc.Spec.Name, + ServiceID: svc.ID, + Image: image, + }) + } + + return jobs, nil +} diff --git a/internal/discovery/swarm_test.go b/internal/discovery/swarm_test.go new file mode 100644 index 0000000..5c3f048 --- /dev/null +++ b/internal/discovery/swarm_test.go @@ -0,0 +1,86 @@ +package discovery_test + +import ( + "context" + "errors" + "testing" + + "github.com/docker/docker/api/types/swarm" + "github.com/shcizo/package-updater/internal/discovery" + "github.com/stretchr/testify/require" +) + +type fakeSwarmDockerClient struct { + services []swarm.Service + err error +} + +func (f *fakeSwarmDockerClient) ServiceList(_ context.Context, _ swarm.ServiceListOptions) ([]swarm.Service, error) { + return f.services, f.err +} + +func mkService(id, name, image string, labels map[string]string) swarm.Service { + return swarm.Service{ + ID: id, + Spec: swarm.ServiceSpec{ + Annotations: swarm.Annotations{Name: name, Labels: labels}, + TaskTemplate: swarm.TaskSpec{ + ContainerSpec: &swarm.ContainerSpec{Image: image}, + }, + }, + } +} + +func TestSwarmFindJobs_MatchAndOptIn(t *testing.T) { + fake := &fakeSwarmDockerClient{services: []swarm.Service{ + mkService("svc-myapp", "myapp_web", "registry.example.com/myapp:v1", map[string]string{ + "se.shcizo.auto-update": "true", + }), + mkService("svc-other", "other_web", "registry.example.com/other:v1", map[string]string{ + "se.shcizo.auto-update": "true", + }), + }} + d := discovery.NewSwarm(fake, "se.shcizo.auto-update") + jobs, err := d.FindJobs(context.Background(), "registry.example.com/myapp") + require.NoError(t, err) + require.Len(t, jobs, 1) + require.Equal(t, "myapp_web", jobs[0].Service) + require.Equal(t, "svc-myapp", jobs[0].ServiceID) + require.Equal(t, "registry.example.com/myapp", jobs[0].Image) + require.False(t, jobs[0].Refused) + require.Empty(t, jobs[0].WorkingDir) + require.Empty(t, jobs[0].ConfigFiles) +} + +func TestSwarmFindJobs_SkipsWithoutOptIn(t *testing.T) { + fake := &fakeSwarmDockerClient{services: []swarm.Service{ + mkService("svc-myapp", "myapp_web", "registry.example.com/myapp:v1", nil), + }} + d := discovery.NewSwarm(fake, "se.shcizo.auto-update") + jobs, err := d.FindJobs(context.Background(), "registry.example.com/myapp") + require.NoError(t, err) + require.Empty(t, jobs) +} + +func TestSwarmFindJobs_DockerError(t *testing.T) { + fake := &fakeSwarmDockerClient{err: errors.New("connection refused")} + d := discovery.NewSwarm(fake, "se.shcizo.auto-update") + _, err := d.FindJobs(context.Background(), "registry.example.com/myapp") + require.Error(t, err) +} + +func TestSwarmFindJobs_NoContainerSpecIsSkipped(t *testing.T) { + fake := &fakeSwarmDockerClient{services: []swarm.Service{ + { + ID: "svc-weird", + Spec: swarm.ServiceSpec{ + Annotations: swarm.Annotations{Name: "weird", Labels: map[string]string{"se.shcizo.auto-update": "true"}}, + TaskTemplate: swarm.TaskSpec{ContainerSpec: nil}, + }, + }, + }} + d := discovery.NewSwarm(fake, "se.shcizo.auto-update") + jobs, err := d.FindJobs(context.Background(), "registry.example.com/myapp") + require.NoError(t, err) + require.Empty(t, jobs) +} From 34865c9fd0c2cc1704dd8e582fdeb3977cabf7fd Mon Sep 17 00:00:00 2001 From: Samuel Enocsson Date: Sat, 4 Jul 2026 20:19:00 +0200 Subject: [PATCH 06/10] feat(updater): add SwarmExecutor to run docker service update via the Docker API Adds the Swarm-mode counterpart to ComposeExecutor: updates a Swarm service's image directly through the Docker SDK (ServiceInspectWithRaw + ServiceUpdate with QueryRegistry=true so floating tags resolve to a fresh digest), gated by the same Refused guard used in Compose mode. Satisfies the existing Executor interface unchanged, so Queue needs no changes. --- internal/updater/swarm_executor.go | 52 +++++++++++++ internal/updater/swarm_executor_test.go | 99 +++++++++++++++++++++++++ 2 files changed, 151 insertions(+) create mode 100644 internal/updater/swarm_executor.go create mode 100644 internal/updater/swarm_executor_test.go diff --git a/internal/updater/swarm_executor.go b/internal/updater/swarm_executor.go new file mode 100644 index 0000000..a007792 --- /dev/null +++ b/internal/updater/swarm_executor.go @@ -0,0 +1,52 @@ +package updater + +import ( + "context" + "fmt" + + "github.com/docker/docker/api/types/swarm" + "github.com/shcizo/package-updater/internal/discovery" +) + +// SwarmDockerClient is the subset of the Docker SDK SwarmExecutor depends +// on. Defined as an interface so tests can supply a fake. +type SwarmDockerClient interface { + ServiceInspectWithRaw(ctx context.Context, serviceID string, opts swarm.ServiceInspectOptions) (swarm.Service, []byte, error) + ServiceUpdate(ctx context.Context, serviceID string, version swarm.Version, service swarm.ServiceSpec, opts swarm.ServiceUpdateOptions) (swarm.ServiceUpdateResponse, error) +} + +// SwarmExecutor updates a Swarm service's image via the Docker API, +// equivalent to `docker service update --image`. +type SwarmExecutor struct { + cli SwarmDockerClient +} + +// NewSwarmExecutor returns an executor that drives Swarm service updates +// through the Docker API. +func NewSwarmExecutor(cli SwarmDockerClient) *SwarmExecutor { + return &SwarmExecutor{cli: cli} +} + +// Execute inspects the service to get its current spec and version (required +// by the Docker API as an optimistic-concurrency token), sets the new image +// on the container spec, and calls ServiceUpdate. QueryRegistry is set so a +// floating tag (e.g. ":latest") resolves to a fresh digest and actually +// triggers a rolling update instead of being treated as unchanged. +func (e *SwarmExecutor) Execute(ctx context.Context, job discovery.Job) error { + if job.Refused { + return fmt.Errorf("refused: %s", job.RefusedReason) + } + + svc, _, err := e.cli.ServiceInspectWithRaw(ctx, job.ServiceID, swarm.ServiceInspectOptions{}) + if err != nil { + return fmt.Errorf("inspect service %s: %w", job.ServiceID, err) + } + + spec := svc.Spec + spec.TaskTemplate.ContainerSpec.Image = job.Image + + if _, err := e.cli.ServiceUpdate(ctx, job.ServiceID, svc.Version, spec, swarm.ServiceUpdateOptions{QueryRegistry: true}); err != nil { + return fmt.Errorf("update service %s: %w", job.ServiceID, err) + } + return nil +} diff --git a/internal/updater/swarm_executor_test.go b/internal/updater/swarm_executor_test.go new file mode 100644 index 0000000..253d139 --- /dev/null +++ b/internal/updater/swarm_executor_test.go @@ -0,0 +1,99 @@ +package updater_test + +import ( + "context" + "errors" + "testing" + + "github.com/docker/docker/api/types/swarm" + "github.com/shcizo/package-updater/internal/discovery" + "github.com/shcizo/package-updater/internal/updater" + "github.com/stretchr/testify/require" +) + +type fakeSwarmDockerClient struct { + inspectService swarm.Service + inspectErr error + updateErr error + + inspectCalled bool + updateCalled bool + updateCalledServiceID string + updateCalledVersion swarm.Version + updateCalledSpec swarm.ServiceSpec + updateCalledOpts swarm.ServiceUpdateOptions +} + +func (f *fakeSwarmDockerClient) ServiceInspectWithRaw(_ context.Context, _ string, _ swarm.ServiceInspectOptions) (swarm.Service, []byte, error) { + f.inspectCalled = true + return f.inspectService, nil, f.inspectErr +} + +func (f *fakeSwarmDockerClient) ServiceUpdate(_ context.Context, serviceID string, version swarm.Version, spec swarm.ServiceSpec, opts swarm.ServiceUpdateOptions) (swarm.ServiceUpdateResponse, error) { + f.updateCalled = true + f.updateCalledServiceID = serviceID + f.updateCalledVersion = version + f.updateCalledSpec = spec + f.updateCalledOpts = opts + return swarm.ServiceUpdateResponse{}, f.updateErr +} + +func mkInspectService(version uint64, image string) swarm.Service { + return swarm.Service{ + ID: "svc-myapp", + Meta: swarm.Meta{Version: swarm.Version{Index: version}}, + Spec: swarm.ServiceSpec{ + Annotations: swarm.Annotations{Name: "myapp_web"}, + TaskTemplate: swarm.TaskSpec{ContainerSpec: &swarm.ContainerSpec{Image: image}}, + }, + } +} + +func TestSwarmExecutor_UpdatesImageAndVersion(t *testing.T) { + fake := &fakeSwarmDockerClient{inspectService: mkInspectService(7, "registry.example.com/myapp:v1")} + e := updater.NewSwarmExecutor(fake) + + job := discovery.Job{Service: "myapp_web", ServiceID: "svc-myapp", Image: "registry.example.com/myapp:v2"} + err := e.Execute(context.Background(), job) + + require.NoError(t, err) + require.True(t, fake.updateCalled) + require.Equal(t, "svc-myapp", fake.updateCalledServiceID) + require.Equal(t, uint64(7), fake.updateCalledVersion.Index) + require.Equal(t, "registry.example.com/myapp:v2", fake.updateCalledSpec.TaskTemplate.ContainerSpec.Image) + require.True(t, fake.updateCalledOpts.QueryRegistry) +} + +func TestSwarmExecutor_InspectError(t *testing.T) { + fake := &fakeSwarmDockerClient{inspectErr: errors.New("service not found")} + e := updater.NewSwarmExecutor(fake) + + err := e.Execute(context.Background(), discovery.Job{ServiceID: "svc-missing", Image: "x:v2"}) + + require.Error(t, err) + require.False(t, fake.updateCalled) +} + +func TestSwarmExecutor_UpdateError(t *testing.T) { + fake := &fakeSwarmDockerClient{ + inspectService: mkInspectService(1, "registry.example.com/myapp:v1"), + updateErr: errors.New("update rejected"), + } + e := updater.NewSwarmExecutor(fake) + + err := e.Execute(context.Background(), discovery.Job{ServiceID: "svc-myapp", Image: "registry.example.com/myapp:v2"}) + + require.Error(t, err) +} + +func TestSwarmExecutor_RefusedJobIsNotExecuted(t *testing.T) { + fake := &fakeSwarmDockerClient{} + e := updater.NewSwarmExecutor(fake) + job := discovery.Job{ServiceID: "svc-myapp", Image: "x:v2", Refused: true, RefusedReason: "not opted in"} + + err := e.Execute(context.Background(), job) + + require.Error(t, err) + require.False(t, fake.inspectCalled) + require.False(t, fake.updateCalled) +} From f92f8ac6b1b8f9da5436d71347d12337219997c4 Mon Sep 17 00:00:00 2001 From: Samuel Enocsson Date: Sat, 4 Jul 2026 20:22:30 +0200 Subject: [PATCH 07/10] feat(api): surface ServiceID in update results for swarm jobs --- internal/api/handlers.go | 1 + internal/api/swarm_result_test.go | 48 +++++++++++++++++++++++++++++++ internal/api/types.go | 1 + 3 files changed, 50 insertions(+) create mode 100644 internal/api/swarm_result_test.go diff --git a/internal/api/handlers.go b/internal/api/handlers.go index cf251a3..e9af556 100644 --- a/internal/api/handlers.go +++ b/internal/api/handlers.go @@ -86,6 +86,7 @@ func (h *Handlers) Update(w http.ResponseWriter, r *http.Request) { Project: res.Job.Project, Service: res.Job.Service, ComposeFile: composeFile, + ServiceID: res.Job.ServiceID, Status: string(res.Status), Error: res.Error, DurationMs: res.DurationMs, diff --git a/internal/api/swarm_result_test.go b/internal/api/swarm_result_test.go new file mode 100644 index 0000000..e6d978b --- /dev/null +++ b/internal/api/swarm_result_test.go @@ -0,0 +1,48 @@ +package api_test + +import ( + "context" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/docker/docker/api/types" + "github.com/shcizo/package-updater/internal/api" + "github.com/shcizo/package-updater/internal/discovery" + "github.com/shcizo/package-updater/internal/updater" + "github.com/stretchr/testify/require" +) + +type swarmResultFinder struct{ jobs []discovery.Job } + +func (f *swarmResultFinder) FindJobs(_ context.Context, _ string) ([]discovery.Job, error) { + return f.jobs, nil +} + +type swarmResultSubmitter struct{ results []updater.Result } + +func (s *swarmResultSubmitter) Submit(_ context.Context, _ []discovery.Job) []updater.Result { + return s.results +} + +type swarmResultPinger struct{} + +func (swarmResultPinger) Ping(_ context.Context) (types.Ping, error) { return types.Ping{}, nil } + +func TestUpdate_SwarmJobPopulatesServiceID(t *testing.T) { + job := discovery.Job{Service: "myapp_web", ServiceID: "svc123", Image: "registry.example.com/myapp:v2"} + finder := &swarmResultFinder{jobs: []discovery.Job{job}} + submitter := &swarmResultSubmitter{results: []updater.Result{ + {Job: job, Status: updater.StatusUpdated, DurationMs: 42}, + }} + h := api.NewHandlers(finder, submitter, swarmResultPinger{}, "v", "c", "b", nil) + + req := httptest.NewRequest(http.MethodPost, "/update", strings.NewReader(`{"image":"registry.example.com/myapp"}`)) + rec := httptest.NewRecorder() + h.Update(rec, req) + + require.Equal(t, http.StatusOK, rec.Code) + require.Contains(t, rec.Body.String(), `"service_id":"svc123"`) + require.Contains(t, rec.Body.String(), `"service":"myapp_web"`) +} diff --git a/internal/api/types.go b/internal/api/types.go index fc3cf51..02f1f63 100644 --- a/internal/api/types.go +++ b/internal/api/types.go @@ -20,6 +20,7 @@ type ResultDTO struct { Project string `json:"project"` Service string `json:"service"` ComposeFile string `json:"compose_file"` + ServiceID string `json:"service_id,omitempty"` Status string `json:"status"` Error string `json:"error,omitempty"` DurationMs int64 `json:"duration_ms"` From 9a3a20087333663e21ccedb67ab83a01ecaaf35c Mon Sep 17 00:00:00 2001 From: Samuel Enocsson Date: Sat, 4 Jul 2026 20:28:28 +0200 Subject: [PATCH 08/10] feat: wire MODE config to select compose or swarm discovery/executor --- cmd/server/main.go | 21 +++++++++++++++++---- 1 file changed, 17 insertions(+), 4 deletions(-) diff --git a/cmd/server/main.go b/cmd/server/main.go index bc20743..27c1288 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -42,7 +42,7 @@ func run() error { logger := logging.New(cfg.LogLevel) logger.Info("starting", "version", version, "commit", commit, "port", cfg.Port, - "stacks_root", cfg.StacksRoot, "opt_in_label", cfg.OptInLabel, + "mode", cfg.Mode, "stacks_root", cfg.StacksRoot, "opt_in_label", cfg.OptInLabel, ) dockerCli, err := client.NewClientWithOpts(client.FromEnv, client.WithAPIVersionNegotiation()) @@ -51,8 +51,21 @@ func run() error { } defer dockerCli.Close() - disc := discovery.New(dockerCli, cfg.StacksRoot, cfg.OptInLabel) - exec := updater.NewComposeExecutor() + var finder api.Finder + var exec updater.Executor + switch cfg.Mode { + case "swarm": + finder = discovery.NewSwarm(dockerCli, cfg.OptInLabel) + exec = updater.NewSwarmExecutor(dockerCli) + case "compose": + finder = discovery.New(dockerCli, cfg.StacksRoot, cfg.OptInLabel) + exec = updater.NewComposeExecutor() + default: + // Unreachable: config.Load already validates Mode, but guard + // here too rather than silently falling through to a nil + // finder/exec pair. + return fmt.Errorf("unknown MODE %q", cfg.Mode) + } reg := prometheus.NewRegistry() m := metrics.New(reg) @@ -62,7 +75,7 @@ func run() error { queue.Start(context.Background()) defer queue.Stop() - handlers := api.NewHandlers(disc, &submitterAdapter{queue: queue, timeout: cfg.UpdateTimeout}, dockerCli, version, commit, buildTime, m) + handlers := api.NewHandlers(finder, &submitterAdapter{queue: queue, timeout: cfg.UpdateTimeout}, dockerCli, version, commit, buildTime, m) mux := http.NewServeMux() mux.HandleFunc("POST /update", handlers.Update) From 9881812eca1e130accfbd83cc3ffb6891143cfd5 Mon Sep 17 00:00:00 2001 From: Samuel Enocsson Date: Sat, 4 Jul 2026 20:32:22 +0200 Subject: [PATCH 09/10] docs: document MODE=swarm support and its opt-in label / manager-node requirements --- CLAUDE.md | 10 ++++++++++ README.md | 24 +++++++++++++++++++++++- 2 files changed, 33 insertions(+), 1 deletion(-) diff --git a/CLAUDE.md b/CLAUDE.md index 7414ea9..9b0153b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -26,7 +26,9 @@ curl -sH "Authorization: Bearer $UPDATER_API_KEY" \ - `internal/api` — HTTP handlers, bearer-token auth, request-id + access-log middleware - `internal/config` — env-var loading; fails fast if `UPDATER_API_KEY` missing - `internal/discovery`— given an image, return Compose `Job`s to run (label parsing, path check, dedup) +- `internal/discovery/swarm.go` — SwarmDiscovery: same FindJobs signature, lists `docker service ls`, gates on service-level opt-in label instead of STACKS_ROOT path-check - `internal/updater` — FIFO queue + single worker + `docker compose` subprocess executor +- `internal/updater/swarm_executor.go` — SwarmExecutor: `docker service update --image` via the Docker API instead of a `docker compose` subprocess - `internal/selfupdate` — flush-then-exec wrapper for updating ourselves (NOT wired in live, see gotcha) - `internal/metrics` — Prometheus collectors - `internal/logging` — slog JSON, request-id context propagation @@ -45,6 +47,10 @@ curl -sH "Authorization: Bearer $UPDATER_API_KEY" \ Tests rely on this — don't drop the nil-check. - **Stateless**: no DB, no config file, no on-disk audit log. Docker daemon is the source of truth. +- **Compose mode and Swarm mode are selected once per deployment via `MODE`**, never + mixed at request time. Swarm mode's security gate is opt-in label only — there is + no STACKS_ROOT-equivalent path check, since Swarm services have no local compose + file. Don't add one; don't weaken Compose mode's three-factor gate to match. ## Gotchas @@ -63,6 +69,10 @@ curl -sH "Authorization: Bearer $UPDATER_API_KEY" \ without the other has caused a fix commit already. - **`` C# convention from global CLAUDE.md does not apply here** — this is Go. Use idiomatic GoDoc (`// FuncName does X.`). +- **Swarm mode requires manager-node API access.** `docker service update` fails + with a permission error against a worker-only node. This is an operator/deployment + concern (point `DOCKER_HOST` at a manager, or schedule the updater on a manager), + not something the code can detect or work around. ## References diff --git a/README.md b/README.md index b481e41..8a41057 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,25 @@ A container is eligible for update only if it has **both**: Defense in depth: a valid bearer token AND the opt-in label must both be present before any container is touched. +## Swarm mode + +Set `MODE=swarm` to update Docker Swarm services instead of Compose stacks. The +service runs `docker service update --image` via the Docker API instead of +shelling out to `docker compose`. + +Two things to get right when running in Swarm mode: + +- **The opt-in label goes on the service, not the container/task.** Swarm mode + reads `Service.Spec.Labels`, so add it with + `docker service update --label-add se.shcizo.auto-update=true ` or + set it under `deploy.labels` in the stack file — a plain `labels:` entry on + the service (container-level) is not visible to Swarm mode's discovery. +- **The updater must talk to a Swarm manager.** `docker service update` + requires manager API access, so either point `DOCKER_HOST` at a manager node + or schedule the updater container itself on a manager with the socket + mounted. This is a deployment concern the service cannot detect or work + around. + ## Quick start 1. Build and push the image (e.g. via your own CI). @@ -49,6 +68,7 @@ All via environment variables. | `LOG_LEVEL` | no | `info` | `debug` / `info` / `warn` / `error`. | | `UPDATE_TIMEOUT` | no | `5m` | Per-job timeout (Go duration). | | `OPT_IN_LABEL` | no | `se.shcizo.auto-update` | Label name to check; value must equal `"true"`. | +| `MODE` | no | `compose` | `compose` or `swarm`. Selects the update mechanism for the whole deployment; not mixed per-request. | ## Endpoints @@ -80,5 +100,7 @@ These are tracked in the spec's section 2 and section 15 as deliberate out-of-sc - **Self-update wiring**: `internal/selfupdate.Wrapped` exists and is unit-tested but is not wired into the live queue. The HTTP response flush ordering for self-replacement is a future enhancement; for now, expect to manually rerun `docker compose up -d` on the host if pushing a new image of `package-updater` itself causes a mid-response interruption. - **No rollback**: Compose's "keep old container if new fails to start" is the only safety net. -- **Single host only**. +- **Single host only in Compose mode**. Swarm mode (`MODE=swarm`) is the + multi-node path, but only from a manager node's point of view — the updater + itself still needs manager API access (see "Swarm mode" above). - **No per-repo API keys**: a single shared bearer token is used. From db84a2f6ba4b6551dbf443764f551add573d35b9 Mon Sep 17 00:00:00 2001 From: Samuel Enocsson Date: Sat, 4 Jul 2026 20:41:59 +0200 Subject: [PATCH 10/10] fix(api): plumb request tag into discovery so swarm mode deploys the requested version req.Tag was only echoed in the HTTP response, never used to match jobs. Compose mode didn't care (ComposeExecutor re-pulls the compose file's own pinned tag), but SwarmExecutor sets the service image directly from Job.Image, which was built from the untagged req.Image alone -- so a Swarm deploy silently rewrote the service to :latest instead of the requested tag. Build the full image:tag reference once in the handler and pass it into FindJobs; NormaliseImage/ImagesMatch already strip tags before matching, so this doesn't change which jobs match in either mode. --- internal/api/handlers.go | 7 ++++- internal/api/handlers_test.go | 50 ++++++++++++++++++++++++++++++++++- 2 files changed, 55 insertions(+), 2 deletions(-) diff --git a/internal/api/handlers.go b/internal/api/handlers.go index e9af556..420ff35 100644 --- a/internal/api/handlers.go +++ b/internal/api/handlers.go @@ -56,7 +56,12 @@ func (h *Handlers) Update(w http.ResponseWriter, r *http.Request) { return } - jobs, err := h.finder.FindJobs(r.Context(), req.Image) + requestedImage := req.Image + if req.Tag != "" { + requestedImage = req.Image + ":" + req.Tag + } + + jobs, err := h.finder.FindJobs(r.Context(), requestedImage) if err != nil { writeJSONError(w, http.StatusInternalServerError, "discovery failed: "+err.Error()) return diff --git a/internal/api/handlers_test.go b/internal/api/handlers_test.go index b62f830..462540a 100644 --- a/internal/api/handlers_test.go +++ b/internal/api/handlers_test.go @@ -21,9 +21,21 @@ import ( type fakeFinder struct { jobs []discovery.Job err error + + // gotImage records the image argument passed to FindJobs, for + // assertions on the exact reference the handler built. + gotImage string + // swarmShaped, when true, makes FindJobs return a Job whose Image + // field carries the received image argument, simulating + // discovery.SwarmDiscovery.FindJobs. + swarmShaped bool } -func (f *fakeFinder) FindJobs(_ context.Context, _ string) ([]discovery.Job, error) { +func (f *fakeFinder) FindJobs(_ context.Context, image string) ([]discovery.Job, error) { + f.gotImage = image + if f.swarmShaped { + return []discovery.Job{{Service: "myapp_web", ServiceID: "svc1", Image: image}}, f.err + } return f.jobs, f.err } @@ -157,6 +169,42 @@ func TestHealthz_503WhenDockerDown(t *testing.T) { require.Equal(t, http.StatusServiceUnavailable, w.Code) } +func TestUpdate_FindJobsCalledWithTaggedImage(t *testing.T) { + finder := &fakeFinder{} + h := api.NewHandlers(finder, &fakeSubmitter{}, &fakePinger{}, "v0.0.0", "abc", "now", nil) + body, _ := json.Marshal(api.UpdateRequest{Image: "registry.example.com/myapp", Tag: "v2"}) + req := httptest.NewRequest(http.MethodPost, "/update", bytes.NewReader(body)) + w := httptest.NewRecorder() + h.Update(w, req) + require.Equal(t, "registry.example.com/myapp:v2", finder.gotImage) +} + +func TestUpdate_FindJobsCalledWithBareImageWhenNoTag(t *testing.T) { + finder := &fakeFinder{} + h := api.NewHandlers(finder, &fakeSubmitter{}, &fakePinger{}, "v0.0.0", "abc", "now", nil) + body, _ := json.Marshal(api.UpdateRequest{Image: "registry.example.com/myapp"}) + req := httptest.NewRequest(http.MethodPost, "/update", bytes.NewReader(body)) + w := httptest.NewRecorder() + h.Update(w, req) + require.Equal(t, "registry.example.com/myapp", finder.gotImage) +} + +func TestUpdate_SwarmModeThreadsTagThroughDiscovery(t *testing.T) { + finder := &fakeFinder{swarmShaped: true} + h := api.NewHandlers(finder, &fakeSubmitter{}, &fakePinger{}, "v0.0.0", "abc", "now", nil) + body, _ := json.Marshal(api.UpdateRequest{Image: "registry.example.com/myapp", Tag: "v2"}) + req := httptest.NewRequest(http.MethodPost, "/update", bytes.NewReader(body)) + w := httptest.NewRecorder() + h.Update(w, req) + require.Equal(t, http.StatusOK, w.Code) + // The fake Finder's returned Job.Image (as SwarmDiscovery would build + // it) reflects the image argument it received from the handler. + // Asserting on the captured argument proves the full request -> + // discovery flow carries the tag through to what would reach the + // SwarmExecutor. + require.Equal(t, "registry.example.com/myapp:v2", finder.gotImage) +} + func TestVersion(t *testing.T) { h := api.NewHandlers(&fakeFinder{}, &fakeSubmitter{}, &fakePinger{}, "v1.2.3", "abcdef", "2026-05-22T00:00:00Z", nil) req := httptest.NewRequest(http.MethodGet, "/version", nil)