1 Commits

Author SHA1 Message Date
shcizo b562e84b88 chore: move composite action to dedicated repo
Action lives at gitea.shcizo.se/shcizo/package-updater-action now.
2026-05-22 14:27:11 +02:00
19 changed files with 11 additions and 1520 deletions
-1
View File
@@ -24,4 +24,3 @@ coverage.html
# Serena MCP workspace
.serena/
.superpowers/
-88
View File
@@ -1,88 +0,0 @@
# 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.
+2 -24
View File
@@ -27,25 +27,6 @@ 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).
@@ -54,7 +35,7 @@ Two things to get right when running in Swarm mode:
4. Point your reverse proxy (NPM/Traefik/Caddy) at `package-updater:8080`. NPM should handle TLS.
5. `docker compose up -d`.
6. Add the opt-in label `se.shcizo.auto-update: "true"` to each service you want auto-updated.
7. Use the [Gitea composite action](gitea-action/README.md) in your repos to call `/update` after a build.
7. Use the [Gitea composite action](https://gitea.shcizo.se/shcizo/package-updater-action) in your repos to call `/update` after a build.
## Configuration
@@ -68,7 +49,6 @@ 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
@@ -100,7 +80,5 @@ 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 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).
- **Single host only**.
- **No per-repo API keys**: a single shared bearer token is used.
+4 -17
View File
@@ -42,7 +42,7 @@ func run() error {
logger := logging.New(cfg.LogLevel)
logger.Info("starting",
"version", version, "commit", commit, "port", cfg.Port,
"mode", cfg.Mode, "stacks_root", cfg.StacksRoot, "opt_in_label", cfg.OptInLabel,
"stacks_root", cfg.StacksRoot, "opt_in_label", cfg.OptInLabel,
)
dockerCli, err := client.NewClientWithOpts(client.FromEnv, client.WithAPIVersionNegotiation())
@@ -51,21 +51,8 @@ func run() error {
}
defer dockerCli.Close()
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)
}
disc := discovery.New(dockerCli, cfg.StacksRoot, cfg.OptInLabel)
exec := updater.NewComposeExecutor()
reg := prometheus.NewRegistry()
m := metrics.New(reg)
@@ -75,7 +62,7 @@ func run() error {
queue.Start(context.Background())
defer queue.Stop()
handlers := api.NewHandlers(finder, &submitterAdapter{queue: queue, timeout: cfg.UpdateTimeout}, dockerCli, version, commit, buildTime, m)
handlers := api.NewHandlers(disc, &submitterAdapter{queue: queue, timeout: cfg.UpdateTimeout}, dockerCli, version, commit, buildTime, m)
mux := http.NewServeMux()
mux.HandleFunc("POST /update", handlers.Update)
@@ -1,859 +0,0 @@
# 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"
```
-36
View File
@@ -1,36 +0,0 @@
# Deploy via package-updater (composite action)
Notifies `package-updater` to `docker compose pull` + `up -d` for the matching service(s) after a CI build.
## Usage
In a consumer repo's `.gitea/workflows/deploy.yml`:
```yaml
jobs:
deploy:
runs-on: ubuntu-latest
needs: [build-and-push]
steps:
- uses: gitea.example.com/shcizo/package-updater/gitea-action@v1
with:
endpoint: https://updater.example.com/update
image: registry.example.com/${{ gitea.repository }}
tag: ${{ gitea.sha }}
token: ${{ secrets.UPDATER_TOKEN }}
```
`UPDATER_TOKEN` should be set as an organisation-level secret in Gitea so all repos share it.
## Inputs
| Name | Required | Default | Description |
|---|---|---|---|
| `endpoint` | yes | — | Full URL to `/update` |
| `image` | yes | — | Image reference without tag |
| `tag` | no | `""` | Tag that was just pushed (logged for audit) |
| `token` | yes | — | Bearer token configured in package-updater |
## Failure modes
The step exits non-zero if `package-updater` returns HTTP 4xx or 5xx. This is intentional — the workflow surfaces the deploy failure to whoever pushed.
-37
View File
@@ -1,37 +0,0 @@
name: "Deploy via package-updater"
description: "Notifies package-updater to pull & restart a Docker Compose service"
inputs:
endpoint:
description: "Full URL to /update (e.g. https://updater.example.com/update)"
required: true
image:
description: "Image reference without tag (e.g. registry.example.com/myapp)"
required: true
tag:
description: "Tag that was just pushed (for logging)"
required: false
default: ""
token:
description: "Bearer token for package-updater"
required: true
runs:
using: "composite"
steps:
- name: Trigger update
shell: bash
env:
TOKEN: ${{ inputs.token }}
run: |
set -euo pipefail
response=$(curl -sS -w "\n%{http_code}" \
-X POST "${{ inputs.endpoint }}" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d "{\"image\":\"${{ inputs.image }}\",\"tag\":\"${{ inputs.tag }}\"}")
body=$(echo "$response" | head -n -1)
code=$(echo "$response" | tail -n 1)
echo "HTTP $code"
echo "$body" | jq .
if [ "$code" -ge 400 ]; then
exit 1
fi
+1 -7
View File
@@ -56,12 +56,7 @@ func (h *Handlers) Update(w http.ResponseWriter, r *http.Request) {
return
}
requestedImage := req.Image
if req.Tag != "" {
requestedImage = req.Image + ":" + req.Tag
}
jobs, err := h.finder.FindJobs(r.Context(), requestedImage)
jobs, err := h.finder.FindJobs(r.Context(), req.Image)
if err != nil {
writeJSONError(w, http.StatusInternalServerError, "discovery failed: "+err.Error())
return
@@ -91,7 +86,6 @@ 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,
+1 -49
View File
@@ -21,21 +21,9 @@ 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, image string) ([]discovery.Job, error) {
f.gotImage = image
if f.swarmShaped {
return []discovery.Job{{Service: "myapp_web", ServiceID: "svc1", Image: image}}, f.err
}
func (f *fakeFinder) FindJobs(_ context.Context, _ string) ([]discovery.Job, error) {
return f.jobs, f.err
}
@@ -169,42 +157,6 @@ 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)
-48
View File
@@ -1,48 +0,0 @@
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"`)
}
-1
View File
@@ -20,7 +20,6 @@ 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"`
-6
View File
@@ -16,7 +16,6 @@ type Config struct {
LogLevel string
UpdateTimeout time.Duration
OptInLabel string
Mode string
}
// Load reads configuration from environment variables, applies defaults,
@@ -29,17 +28,12 @@ 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 {
-23
View File
@@ -57,26 +57,3 @@ 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")
}
+3 -10
View File
@@ -11,16 +11,10 @@ import (
// Job describes a single (project, service, config_files) update to execute.
type Job struct {
Project string
Service string
WorkingDir string
Project string
Service string
WorkingDir string
ConfigFiles []string
// Image is the full image reference the update request asked for.
// Compose jobs ignore it (compose.yml already pins the reference to
// pull); Swarm jobs need it to know what to set on the service spec.
Image string
// ServiceID is the Swarm service ID. Empty for Compose jobs.
ServiceID string
// Refused is true when the WorkingDir falls outside STACKS_ROOT.
// The job is returned so the caller can surface a per-job "refused"
// result, but it MUST NOT be executed.
@@ -80,7 +74,6 @@ 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,
})
-17
View File
@@ -153,20 +153,3 @@ 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)
}
-60
View File
@@ -1,60 +0,0 @@
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
}
-86
View File
@@ -1,86 +0,0 @@
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)
}
-52
View File
@@ -1,52 +0,0 @@
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
}
-99
View File
@@ -1,99 +0,0 @@
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)
}