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/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. 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) 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" +``` diff --git a/internal/api/handlers.go b/internal/api/handlers.go index cf251a3..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 @@ -86,6 +91,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/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) 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"` 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") +} 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) +} 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) +} 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) +}