# 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" ```