Files
package-updater/docs/superpowers/plans/2026-07-04-docker-swarm-support.md
T

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-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):

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.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:

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) 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:

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.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:

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.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:

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:

  • MODE to the environment variable table: MODEcompose (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 <service> 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
git add README.md CLAUDE.md
git commit -m "docs: document MODE=swarm support and its opt-in label / manager-node requirements"