Compare commits
16
Commits
v0.1.0
...
a57df51532
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a57df51532 | ||
|
|
d2a8b548c9 | ||
|
|
1c833f9fbe | ||
|
|
9ea082de82 | ||
|
|
50ef1a9a07 | ||
|
|
db84a2f6ba | ||
|
|
9881812eca | ||
|
|
ac3912367f | ||
|
|
9a3a200873 | ||
|
|
f92f8ac6b1 | ||
|
|
34865c9fd0 | ||
|
|
a8524e5ff4 | ||
|
|
9cf484e164 | ||
|
|
1002bb7b96 | ||
|
|
8f999c69bd | ||
|
|
df54bbaacf |
@@ -24,3 +24,4 @@ coverage.html
|
||||
|
||||
# Serena MCP workspace
|
||||
.serena/
|
||||
.superpowers/
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
# package-updater — Claude context
|
||||
|
||||
Webhook-driven Docker Compose updater. Go service, single binary, deployed as
|
||||
a container with `/var/run/docker.sock` mounted.
|
||||
|
||||
See `README.md` for user-facing docs. This file is for working ON the code.
|
||||
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
go test ./... # all tests, fast (no docker required)
|
||||
go test -run TestFoo ./internal/api # single test
|
||||
go build ./cmd/server # produces ./server
|
||||
docker build -t package-updater:dev . # multi-stage, builds golang:1.26-alpine
|
||||
|
||||
# Local smoke test (builds from Dockerfile, runs against /var/run/docker.sock):
|
||||
cp env.sample .env && $EDITOR .env # fill UPDATER_API_KEY
|
||||
docker compose -f docker-compose.local.yml up --build
|
||||
curl -sH "Authorization: Bearer $UPDATER_API_KEY" \
|
||||
-d '{"image":"foo"}' http://localhost:8080/update | jq
|
||||
```
|
||||
|
||||
## Architecture (one-line per package)
|
||||
|
||||
- `cmd/server` — wiring only: config → docker client → discovery → queue → http
|
||||
- `internal/api` — HTTP handlers, bearer-token auth, request-id + access-log middleware
|
||||
- `internal/config` — env-var loading; fails fast if `UPDATER_API_KEY` missing
|
||||
- `internal/discovery`— given an image, return Compose `Job`s to run (label parsing, path check, dedup)
|
||||
- `internal/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
|
||||
|
||||
## Design intent (do not break without discussion)
|
||||
|
||||
- **Single FIFO worker by design** (`internal/updater/queue.go`). Spec §5.7. Never
|
||||
parallelise the queue — two `docker compose` calls against the same stack race.
|
||||
- **Defense in depth: token AND opt-in label AND STACKS_ROOT prefix** must all
|
||||
hold before a container is touched. Weakening any of these breaks the security
|
||||
model — discuss before changing.
|
||||
- **Auth on `/update` only**. `/healthz`, `/metrics`, `/version` are intentionally
|
||||
unauthenticated (internal network, scraper/healthcheck need them). See
|
||||
`cmd/server/main.go` `routeAuth`.
|
||||
- **`metrics *Metrics` parameters may be nil**; constructors and call sites check.
|
||||
Tests rely on this — don't drop the nil-check.
|
||||
- **Stateless**: no DB, no config file, no on-disk audit log. Docker daemon is
|
||||
the source of truth.
|
||||
- **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
|
||||
|
||||
- **`internal/selfupdate.Wrapped` is implemented and unit-tested but NOT wired
|
||||
into the live queue.** Spec §15 calls this out as a v1 gap. If you "fix" this,
|
||||
read the package doc — flush-before-exec ordering is subtle.
|
||||
- **Image matching is case-sensitive and tag/digest-agnostic.** The tricky case
|
||||
is `localhost:5000/foo:v1` where the first colon is a port, not a tag. See
|
||||
`internal/discovery/matching.go` `NormaliseImage`.
|
||||
- **Path safety uses `filepath.Rel` + ".." prefix check**, NOT `strings.HasPrefix`.
|
||||
Prevents the `/foo-evil` vs `/foo` confusion. See `internal/discovery/pathcheck.go`.
|
||||
- **`STACKS_ROOT` defaults to `/home/shcizo/self-hosted`** in prod but `env.sample`
|
||||
suggests `/tmp` for smoke tests. The default in `config.go` is the prod value
|
||||
— set explicitly in test/dev envs.
|
||||
- **Go 1.26.3** (`go.mod`). Dockerfile pins `golang:1.26-alpine`. Bumping one
|
||||
without the other has caused a fix commit already.
|
||||
- **`<summary>` 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
|
||||
|
||||
- Design spec: `docs/superpowers/specs/2026-05-22-package-updater-design.md` (489 lines, authoritative)
|
||||
- Implementation plan: `docs/superpowers/plans/2026-05-22-package-updater-implementation.md`
|
||||
- Consumer-side CI integration: `gitea-action/`
|
||||
|
||||
## Conventions
|
||||
|
||||
- Conventional Commits (`feat:`, `fix:`, `docs:`, `ci:`, `build:`, `chore:`).
|
||||
- One package = one responsibility; interfaces defined at consumer site
|
||||
(`api.Finder`, `api.Submitter`, `api.Pinger`) for testability.
|
||||
- Table-driven tests with `stretchr/testify`. No mocks beyond hand-written fakes.
|
||||
@@ -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 <service>` 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.
|
||||
|
||||
+17
-4
@@ -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)
|
||||
|
||||
@@ -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 <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**
|
||||
|
||||
```bash
|
||||
git add README.md CLAUDE.md
|
||||
git commit -m "docs: document MODE=swarm support and its opt-in label / manager-node requirements"
|
||||
```
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,373 @@
|
||||
# Master/Agent Architecture — Design
|
||||
|
||||
**Date:** 2026-10-12
|
||||
**Status:** Approved in brainstorming, pending written-spec review
|
||||
|
||||
## Problem
|
||||
|
||||
package-updater today is one instance per Docker daemon: a webhook (`POST /update`)
|
||||
finds opted-in containers by label and runs `docker compose` or `docker service update`.
|
||||
It is stateless and has no UI.
|
||||
|
||||
The goal now is a tool that replaces Watchtower and Shepherd across the whole fleet:
|
||||
|
||||
- One overview of every service on every machine — Swarm services and Compose stacks,
|
||||
which can coexist on the same machine.
|
||||
- Per-service choice, made in the UI, between **manual** (show that an update exists,
|
||||
a human clicks) and **auto** (apply as soon as a new version is detected).
|
||||
- Detect new versions both from CI (webhook) and by polling registries, so images we do
|
||||
not build ourselves are covered too.
|
||||
- See available and completed updates, pick any of the recently seen versions
|
||||
(which also gives rollback).
|
||||
- Live updates in the UI.
|
||||
|
||||
Building this ourselves instead of adopting Komodo or Portainer is a deliberate choice:
|
||||
it is a hobby project, and owning it means it works exactly the way we want.
|
||||
|
||||
## Environment and assumptions
|
||||
|
||||
- Several machines on one internal network, reachable from each other by IP.
|
||||
- One machine (`swarm-mgr`) is a Swarm manager; most services are Swarm stacks
|
||||
(`docker-stack.yml`). At least one Compose stack exists (`slusk`).
|
||||
- A central Postgres already runs and will be used.
|
||||
- Authentik runs and is used per application via OIDC (not via the reverse proxy).
|
||||
- A reverse proxy fronts the web UI.
|
||||
- Stack files are version-controlled. Image references are ad hoc: a mix of `repo:tag`,
|
||||
`repo:tag@digest` and bare `repo@digest`. Many digests were written by a Claude instance
|
||||
on the servers, not by an automated process.
|
||||
- Shepherd and Watchtower currently run and will be removed (see Migration).
|
||||
|
||||
## Decisions
|
||||
|
||||
### Topology: one master, one agent per machine
|
||||
|
||||
- **Master** (one instance): Postgres, web UI, OIDC login, webhook, registry polling,
|
||||
job decisions. Runs as a Swarm service on `swarm-mgr`.
|
||||
- **Agent** (one per machine): owns that machine's Docker daemon via `docker.sock`.
|
||||
Reports inventory and runs update jobs. Runs via plain `docker compose` on each host
|
||||
(not a global Swarm service), because each agent has its own token and a global
|
||||
service would hand every node the same environment.
|
||||
The agent mounts `docker.sock` and `STACKS_ROOT` **read-write** (today it is `:ro`),
|
||||
because it writes `compose.override.yaml` files.
|
||||
|
||||
### Connection: agent dials master over WebSocket, live
|
||||
|
||||
The agent opens a long-lived WebSocket to the master and keeps it alive with reconnect
|
||||
and backoff. Inventory changes are pushed live from Docker events; the master forwards
|
||||
them to browsers. Chosen over the master polling agents over HTTP because live updates
|
||||
are an explicit goal.
|
||||
|
||||
Messages are JSON in a common envelope `{type, id, payload}` with one Go type per
|
||||
message kind, shared in `internal/protocol`. JSON over WebSocket rather than gRPC:
|
||||
same tooling as the browser side, debuggable with `websocat`, no protobuf toolchain.
|
||||
|
||||
### Trust in both directions
|
||||
|
||||
Commands flow from master to agent and can restart anything on that machine, so the
|
||||
agent must authenticate the master, not only the other way round.
|
||||
|
||||
- On first start the master generates a key pair and stores it in Postgres
|
||||
(`master_identity`), so it survives container recreation and is covered by DB backups.
|
||||
- The master serves the agent endpoint on its own port with TLS, using a self-signed
|
||||
certificate for that key. No reverse proxy, no CA.
|
||||
- The agent is configured with `MASTER_URL`, `MASTER_KEY` and `AGENT_TOKEN`.
|
||||
`MASTER_KEY` is the SHA-256 fingerprint of the master's public key. The agent skips
|
||||
normal chain verification and instead accepts the connection only if the
|
||||
certificate's public key hashes to `MASTER_KEY` (same idea as SSH `known_hosts`).
|
||||
The TLS handshake proves the master holds the private key.
|
||||
- `AGENT_TOKEN` is per agent, created in the UI ("Add agent", which shows a ready
|
||||
compose snippet including token and `MASTER_KEY`). Only its hash is stored.
|
||||
Agents can be revoked individually. A leaked token lets someone impersonate an agent
|
||||
(feed false inventory), not command machines.
|
||||
- Losing the master key means every agent needs a new `MASTER_KEY`.
|
||||
|
||||
Plain `ws://` with a signed handshake was rejected: anyone able to hijack the TCP
|
||||
stream after the handshake could inject commands.
|
||||
|
||||
### State: the master remembers, agents report reality
|
||||
|
||||
Agents are the truth for what is running. Postgres holds what the master must remember:
|
||||
desired version, update mode, version history and job history. This deliberately
|
||||
retires the "stateless" property for the master; agents stay stateless.
|
||||
|
||||
### Security gate moves from labels to the UI
|
||||
|
||||
The opt-in label no longer decides what may be updated; the per-service mode in the UI
|
||||
does. Consequences:
|
||||
|
||||
- Every newly discovered service starts as `manual`. Nothing is ever `auto` by default.
|
||||
- Access to the UI is equivalent to being able to restart everything, so login is the
|
||||
most important control (see Authentication).
|
||||
- The `STACKS_ROOT` path check stays on the agent as an independent guard for Compose,
|
||||
and matters more now that the agent needs write access there (override files).
|
||||
- The master cannot reliably tell which service is its own Postgres or Authentik, so
|
||||
they are not locked automatically. Putting them on `auto` is the operator's risk and
|
||||
is documented.
|
||||
|
||||
### Changing the version of a service
|
||||
|
||||
| Kind | How the version is changed | What can revert it |
|
||||
|---|---|---|
|
||||
| Compose | Agent writes the service's image into `compose.override.yaml` next to the stack's compose file (the agent owns this file, preserving other services' entries in it), then `docker compose pull <svc>` + `up -d <svc>`. `docker compose` loads the override automatically, so manual `docker compose up -d` keeps the new version. | Deleting the override file. |
|
||||
| Swarm | `service update` via the Docker API with `repo:tag@digest` (the agent resolves the digest; without it Swarm does not re-pull when the tag is unchanged). | Re-running `docker stack deploy` with a file holding an older reference. |
|
||||
|
||||
The updater always writes `repo:tag@digest`. The stack's own files in git are never
|
||||
edited. Writing version bumps back to git (GitOps style) was considered and deferred:
|
||||
it needs repo write access, YAML rewriting and conflict handling — a different kind of
|
||||
tool. It can be added later as a step after a successful update.
|
||||
|
||||
### Drift
|
||||
|
||||
A service drifts when `actual_ref` differs from `desired_ref`. A service the updater has
|
||||
never touched has no `desired_ref` and cannot drift. Drift is **never corrected
|
||||
automatically**, not even in `auto` mode, because a manual `stack deploy` with a new tag
|
||||
may be intentional. The UI shows the drift and offers "restore desired" or
|
||||
"accept running".
|
||||
|
||||
### Service identity and moves
|
||||
|
||||
A service row has its own ID. Location is an attribute with a unique index:
|
||||
Compose = agent + project + service; Swarm = cluster ID + service name (independent of
|
||||
which manager agent reports it); plain container = agent + container name.
|
||||
|
||||
Identity without the agent was rejected: the same stack legitimately runs on several
|
||||
machines (the agent itself, node exporters), and they would be merged into one history.
|
||||
Automatic merging was rejected for the same reason.
|
||||
|
||||
When a Compose service disappears on one agent and exactly one service with the same
|
||||
project and service name appears on another, the UI suggests "Moved from A?" with a
|
||||
**Merge** action: the old row is moved to the new location with its history, mode and
|
||||
`desired_ref`, and the duplicate is removed. Merging can also be done by hand.
|
||||
|
||||
### Versions: what the system has seen
|
||||
|
||||
Every version the system sees is recorded in `image_versions`, from three sources:
|
||||
webhook (every build CI announces), polling (every new digest under a followed tag) and
|
||||
inventory (whatever is running). The service page lists the 10 most recent versions for
|
||||
the service's repo; picking one creates a manual job to exactly that reference. Picking
|
||||
an older one is a rollback.
|
||||
|
||||
`available_*` on the service remains as "the newest suggestion", which drives the
|
||||
"update available" badge and `auto` mode.
|
||||
|
||||
| Reference format | Polling | Webhook | Pick a version |
|
||||
|---|---|---|---|
|
||||
| `repo:tag` | yes | yes | yes |
|
||||
| `repo:tag@digest` | yes (follows the tag) | yes | yes |
|
||||
| `repo@digest` | no — nothing to follow | yes (own images) | yes (seen versions) |
|
||||
|
||||
Services without a tag are flagged "follows no tag" in the UI. Recommended convention
|
||||
for stack files: `repo:tag@digest`, tag = what to follow.
|
||||
|
||||
## Data model (Postgres)
|
||||
|
||||
**`master_identity`** — single row: private key, created_at.
|
||||
|
||||
**`agents`** — `id`, `name` (set when adding), `token_hash`, `hostname`,
|
||||
`agent_version`, `is_swarm_manager` (reported), `last_seen_at`, `revoked_at`, `created_at`.
|
||||
Online/offline is held in memory only.
|
||||
|
||||
**`services`**
|
||||
- `id`
|
||||
- `kind`: `compose` | `swarm` | `container` (plain containers are shown but cannot be
|
||||
updated in v1)
|
||||
- location: `agent_id`, `compose_project`, `compose_service`, `swarm_cluster_id`,
|
||||
`swarm_service`, `container_name` (populated per kind; unique index per kind)
|
||||
- `image_repo` — normalised, used to match webhooks and polling
|
||||
- `update_mode`: `manual` (default) | `auto`
|
||||
- `desired_ref`
|
||||
- `actual_ref`, `actual_digest`, `status` — last known, kept so the UI has something to
|
||||
show while an agent is offline
|
||||
- `available_ref`, `available_digest`, `available_source` (`webhook` | `poll`),
|
||||
`available_at`
|
||||
- `last_poll_error`
|
||||
- `first_seen_at`, `removed_at` — disappeared services are marked, not deleted
|
||||
|
||||
**`image_versions`** — `repo`, `tag`, `digest`, `source` (`webhook` | `poll` | `running`),
|
||||
`first_seen_at`; unique on (repo, tag, digest).
|
||||
|
||||
**`update_jobs`** — `id`, `service_id`, `from_ref`, `to_ref`,
|
||||
`trigger` (`manual` | `webhook` | `poll`), `requested_by` (OIDC subject for manual),
|
||||
`status` (`queued` | `running` | `succeeded` | `failed`), `output`, `error`,
|
||||
`created_at`, `started_at`, `finished_at`.
|
||||
|
||||
**`sessions`** — web sessions for OIDC logins.
|
||||
|
||||
## Flows
|
||||
|
||||
### Inventory
|
||||
|
||||
1. On connect the agent sends `hello` (hostname, version, Swarm manager or not, cluster
|
||||
ID) and a full `snapshot`.
|
||||
2. The master diffs the snapshot against that agent's rows: new services are inserted as
|
||||
`manual`, missing ones get `removed_at`, changed ones get new `actual_*`. Running
|
||||
references are recorded in `image_versions` with source `running`.
|
||||
3. The agent subscribes to Docker events (container start/stop/die/destroy, service
|
||||
update), debounces bursts (a Compose restart emits many events), re-inspects the
|
||||
affected item and sends `upsert` or `remove`.
|
||||
4. Swarm services are reported only by agents on manager nodes. Agents skip containers
|
||||
that belong to Swarm tasks, so a service is not listed once per node.
|
||||
5. The master pushes changes to browsers over SSE.
|
||||
|
||||
### Update
|
||||
|
||||
1. Triggers: a click in the UI, the webhook, or polling. Webhook and polling always set
|
||||
`available_*` and record the version; they create a job only if the service is `auto`.
|
||||
2. The master inserts an `update_jobs` row and sends `job.start` to the owning agent
|
||||
(for Swarm, a connected manager agent).
|
||||
3. The agent puts the job in its FIFO queue (single worker, as today) and executes it as
|
||||
described under "Changing the version of a service".
|
||||
4. The agent sends `job.progress` and `job.result` with output. On success the master
|
||||
sets `desired_ref` and clears `available_*` if that version is now running.
|
||||
|
||||
### Failure handling
|
||||
|
||||
| Situation | Behaviour |
|
||||
|---|---|
|
||||
| Agent offline when a job is created | Job fails immediately with "agent offline". It is not stored for later — a job that runs hours later surprises more than it helps. `available_*` remains, so the user can retry. |
|
||||
| Connection drops mid-job | The agent finishes the job (its queue is local) and keeps results in memory until the master acknowledges them. They are resent after reconnect along with the snapshot. |
|
||||
| Master restarts mid-job | Jobs in `running` are reconciled when the agent reconnects; jobs the agent does not know are marked `failed` ("lost"). |
|
||||
| New trigger while a job for the same service is queued or running | No new job; only `available_*` is updated. Two jobs never race on one service. |
|
||||
| Job exceeds `UPDATE_TIMEOUT` | The agent cancels it, as today. |
|
||||
| Polling fails for one image | Error stored in `last_poll_error` and shown; other images continue. |
|
||||
|
||||
### Self-protection
|
||||
|
||||
The agent reports its own container and the master knows its own service. Both are shown
|
||||
as "updated manually outside the system", and the master refuses to create jobs for
|
||||
them. An agent recreating itself would kill the job it is running.
|
||||
|
||||
## Registry polling
|
||||
|
||||
- The master polls, not the agents: credentials live in one place and identical
|
||||
references are polled once.
|
||||
- Every distinct `repo:tag` in use is polled, including `repo:tag@digest`. Bare
|
||||
`repo@digest` and locally built images without a registry digest are skipped and
|
||||
shown as such.
|
||||
- Mechanism: `HEAD /v2/<repo>/manifests/<tag>`; the returned digest is compared with
|
||||
`actual_digest`. Use `go-containerregistry` — it handles Docker Hub's token auth and
|
||||
multi-arch indexes.
|
||||
- Interval `POLL_INTERVAL`, default 6h. Believed not to count against Docker Hub pull
|
||||
limits for HEAD requests — verify during implementation.
|
||||
- Credentials: a Docker `config.json` (as written by `docker login`) mounted into the
|
||||
master. No credential UI, no encryption in Postgres.
|
||||
|
||||
## Webhook
|
||||
|
||||
- `POST /update` with `{image, tag}` and a bearer token — the same contract as today, so
|
||||
the existing Gitea action works unchanged; only its `endpoint` moves to the master.
|
||||
- `image` is matched against `image_repo` across all agents using today's normalisation
|
||||
(`NormaliseImage`). Each match gets `available_ref = repo:tag` (source `webhook`) and
|
||||
the version is recorded; `auto` services get a job.
|
||||
- The response waits for the `auto` jobs to finish (bounded by `UPDATE_TIMEOUT`), and
|
||||
returns 5xx if any failed or timed out, so CI still surfaces a broken deploy. `manual` matches are reported as "marked", not as
|
||||
errors.
|
||||
- No match returns **404**, which catches typos in CI configuration.
|
||||
- The webhook never creates services. The first deployment of a stack is done by hand.
|
||||
|
||||
## Authentication and UI
|
||||
|
||||
### Authentication
|
||||
|
||||
Three separate mechanisms that never mix:
|
||||
|
||||
| Who | How | Where |
|
||||
|---|---|---|
|
||||
| People | OIDC authorization code flow with PKCE against Authentik (`coreos/go-oidc`, `x/oauth2`) | Web UI, via reverse proxy |
|
||||
| CI | Shared bearer token | `POST /update`, via reverse proxy |
|
||||
| Agents | Per-agent token + pinned master key over TLS | Dedicated agent port, internal network |
|
||||
|
||||
- Sessions are stored in Postgres; the cookie holds a session ID (`HttpOnly`, `Secure`,
|
||||
`SameSite=Lax`).
|
||||
- The master also requires membership of `ALLOWED_GROUP` from the token's `groups`
|
||||
claim. Restricting the application in Authentik alone is not enough: one misconfigured
|
||||
binding would hand every Authentik user the ability to restart the fleet.
|
||||
- All state-changing UI requests carry a CSRF token.
|
||||
- `/healthz`, `/metrics` and `/version` stay unauthenticated, as today.
|
||||
|
||||
### UI
|
||||
|
||||
Server-rendered Go templates with htmx; live updates via htmx's SSE support. Embedded in
|
||||
the master binary with `go:embed`. No Node build step. Visual design is drafted
|
||||
separately; this spec defines what each page contains.
|
||||
|
||||
1. **Overview** — all services grouped per machine (Swarm services per cluster). Each
|
||||
shows status, running version, the manual/auto toggle, and badges for
|
||||
*update available*, *drifted* and *follows no tag*. Live.
|
||||
2. **Service** — the 10 most recent versions with an *Update* action, job history with
|
||||
logs, drift actions (restore / accept), and *Merge* for moves.
|
||||
3. **Agents** — online/offline, version. *Add agent* produces a compose snippet with
|
||||
token and `MASTER_KEY`. *Revoke*.
|
||||
4. **Jobs** — full history; running jobs stream their log live.
|
||||
|
||||
## Code layout
|
||||
|
||||
- `cmd/master`, `cmd/agent` — replace `cmd/server`.
|
||||
- `internal/protocol` — message types shared by master and agent.
|
||||
- `internal/agent/...` — today's `discovery` and `updater` (queue, Compose and Swarm
|
||||
executors) move here, plus inventory, Docker events and the connection loop.
|
||||
- `internal/master/...` — agent hub, jobs, polling, webhook, OIDC, storage.
|
||||
- `web/` — templates and static assets.
|
||||
|
||||
Existing conventions continue: interfaces defined at the consumer, hand-written fakes,
|
||||
nil-able `*Metrics`, Conventional Commits.
|
||||
|
||||
## Testing
|
||||
|
||||
- `go test ./...` must keep passing without Docker or Postgres.
|
||||
- Master logic (mode decisions, drift, merge, webhook matching, job reconciliation) is
|
||||
tested against a storage interface with a hand-written fake.
|
||||
- Postgres storage is tested by integration tests behind the `integration` build tag,
|
||||
requiring `DATABASE_URL`.
|
||||
- Master and agent are tested together in-process: an `httptest` TLS server, a real
|
||||
WebSocket client and a fake Docker. Covers the `MASTER_KEY` handshake (including a
|
||||
wrong key being rejected), reconnect with snapshot, and jobs completed while
|
||||
disconnected.
|
||||
- Table-driven tests with testify.
|
||||
|
||||
## Migration from today's deployment
|
||||
|
||||
1. Create the database and user in Postgres; register the application and group in
|
||||
Authentik.
|
||||
2. Deploy the master.
|
||||
3. **Remove Shepherd and Watchtower.** Running alongside, they would update services
|
||||
behind the master's back and every such update would show up as drift.
|
||||
4. Add one agent per machine through the UI. Inventory fills in; everything is `manual`.
|
||||
5. Point the Gitea action's `endpoint` at the master. Contract unchanged.
|
||||
6. Remove today's package-updater instance.
|
||||
7. Convert bare `repo@digest` references to `repo:tag@digest`, and set `auto` where
|
||||
wanted. Recommended: record the convention in the self-hosted repo's `CLAUDE.md`
|
||||
so Claude instances on the servers stop writing bare digests.
|
||||
|
||||
`CLAUDE.md` in this repo is updated as part of the implementation: the "stateless" and
|
||||
"token AND opt-in label AND STACKS_ROOT" design-intent entries no longer describe the
|
||||
system, and the `MODE` entry is replaced by the agent model.
|
||||
|
||||
## Implementation phases
|
||||
|
||||
Each phase ends with something that works on its own.
|
||||
|
||||
1. **Protocol, agent and master with inventory.** Pinned-key TLS, per-agent tokens,
|
||||
snapshot and live events, Postgres storage, OIDC login, Overview and Agents pages.
|
||||
The overview works; nothing can be updated yet.
|
||||
2. **Jobs and manual update.** Job protocol, agent queue and executors (Compose override
|
||||
file, Swarm with digest), job history, Service and Jobs pages, self-protection.
|
||||
3. **Webhook and auto mode.** `POST /update` on the master, matching, waiting for
|
||||
auto jobs, mode toggle.
|
||||
4. **Polling and version history.** `image_versions`, registry polling, version picker
|
||||
and rollback.
|
||||
5. **Drift and merge.** Drift detection and actions, move suggestion and merge.
|
||||
|
||||
## Out of scope (later specs)
|
||||
|
||||
- Detecting newer version *tags* for images we do not build (e.g. `gitea:1.27.3` →
|
||||
`1.27.4`, `postgres:17.10`, `loki:3.4.2`). Requires listing tags and ordering them;
|
||||
major upgrades such as Postgres 17 → 18 must never be automatic. Revisit after
|
||||
checking the other two servers' stack files.
|
||||
- Notifications.
|
||||
- Export/import of a fleet configuration to restore a setup. The `services` table
|
||||
already holds what an export needs.
|
||||
- Writing version bumps back to git.
|
||||
- Self-update of master and agents (`internal/selfupdate` is the starting point).
|
||||
- Updating plain (non-Compose, non-Swarm) containers.
|
||||
- Credential management UI for registries.
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"`)
|
||||
}
|
||||
@@ -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"`
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -15,6 +15,12 @@ type Job struct {
|
||||
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,
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user