32 KiB
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-alpinebase image. - Single FIFO worker in
internal/updater/queue.gomust 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
/updateonly;/healthz,/metrics,/versionstay unauthenticated. metrics *Metricsparameters may be nil; every new call site must follow the existingif 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 ininternal/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"afterLoad()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):
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:
// 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:
Mode: getenvDefault("MODE", "compose"),
so the literal becomes:
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:
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
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.Jobgains two fields,Image string(the full image reference the update request asked for — needed later bySwarmExecutorto know what to update to, since Swarm has no local compose file topullagainst) andServiceID string(empty for Compose jobs; the Swarm service ID for Swarm jobs, needed later bySwarmExecutorto 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
Imagefrom the existingFindJobs(ctx, image string)parameter. -
Step 1: Write the failing test
Add to internal/discovery/discovery_test.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:
// 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):
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
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) stringanddiscovery.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.SwarmDockerClientinterface,discovery.NewSwarm(cli SwarmDockerClient, optInLabel string) *SwarmDiscovery, method(*SwarmDiscovery) FindJobs(ctx context.Context, image string) ([]Job, error)— same signature as*Discovery.FindJobs, so both satisfyapi.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:
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:
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
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.SwarmDockerClientinterface,updater.NewSwarmExecutor(cli SwarmDockerClient) *SwarmExecutor, method(*SwarmExecutor) Execute(ctx context.Context, job discovery.Job) error— satisfiesupdater.Executor(internal/updater/executor.go), soQueue(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:
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:
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
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.ResultDTOgainsServiceID string(jsonservice_id, omitempty) alongside the existingComposeFilefield, which stays empty for Swarm jobs (already guarded by the existinglen(res.Job.ConfigFiles) > 0check). -
Step 1: Write the failing test
Create internal/api/swarm_result_test.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:
// 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:
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
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:
disc := discovery.New(dockerCli, cfg.StacksRoot, cfg.OptInLabel)
exec := updater.NewComposeExecutor()
with:
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:
handlers := api.NewHandlers(disc, &submitterAdapter{queue: queue, timeout: cfg.UpdateTimeout}, dockerCli, version, commit, buildTime, m)
to use finder instead of disc:
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:
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
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:
-
MODEto the environment variable table:MODE—compose(default) orswarm. 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, defaultse.shcizo.auto-update) directly on the service (docker service update --label-add se.shcizo.auto-update=true <service>or set in the stack file'sdeploy.labels), not on the container/task, since Swarm mode readsService.Spec.Labels. Also note the updater must run on/against a Swarm manager node (docker service updaterequires manager API access) — pointingDOCKER_HOSTat 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
git add README.md CLAUDE.md
git commit -m "docs: document MODE=swarm support and its opt-in label / manager-node requirements"