commit 96d94efd6aa841c695929e7fd76a64cb8a7c2e33 Author: Samuel Enocsson Date: Fri May 22 10:57:52 2026 +0200 docs: add package-updater design spec Initial design spec for package-updater service: - Go HTTP service triggered by Gitea Actions webhook - Discovers Compose-managed containers via Docker labels - Two-gate security: bearer token + opt-in label - Stateless, single-worker, no rollback in v1 - Loki structured logs + Prometheus metrics - Reusable composite Gitea action Decisions reached via brainstorming session 2026-05-22. diff --git a/docs/superpowers/specs/2026-05-22-package-updater-design.md b/docs/superpowers/specs/2026-05-22-package-updater-design.md new file mode 100644 index 0000000..184dca3 --- /dev/null +++ b/docs/superpowers/specs/2026-05-22-package-updater-design.md @@ -0,0 +1,489 @@ +# package-updater — Design Spec + +**Date:** 2026-05-22 +**Status:** Approved (brainstorm complete, ready for implementation planning) +**Author:** Samuel Enocsson + +--- + +## 1. Purpose + +A small HTTP service that receives webhook-style notifications from Gitea Actions (or any CI) and triggers a pull + restart of the corresponding Docker Compose service on the host. It fills the gap between Watchtower (polling, no CI integration) and full GitOps (Argo CD / Flux) for a self-hosted, single-host environment. + +**Concrete trigger:** Gitea workflow builds and pushes `registry.example.com/myapp:v1.2.3` to the container registry, then calls `POST /update` with the image name. The service finds the matching Compose-managed container(s) on the host, pulls the new image, and restarts the relevant service(s). + +Watchtower remains in place for third-party images that the user does not build themselves. + +## 2. Non-Goals (v1) + +- Multi-host orchestration (single Docker host only) +- Real rollback (relies on Compose's "keep old container if new fails to start") +- Persistent state, audit history, or web UI (logs live in Loki) +- Per-repo / per-image API keys (single shared bearer token) +- Wildcards/regex in image matching +- Notifications outside of Gitea (workflow failure is visible there already) +- Rate limiting +- TLS termination (handled by Nginx Proxy Manager in front of the service) + +## 3. Architecture + +``` +┌──────────────────┐ POST /update ┌─────────────────────┐ +│ Gitea Actions │ ─────────────────────▶│ package-updater │ +│ (composite │ Bearer │ (Go service, │ +│ action) │ {image, tag} │ container) │ +└──────────────────┘ └──────────┬──────────┘ + │ + docker.sock │ (mounted) + ▼ + ┌─────────────────────┐ + │ Docker daemon │ + │ + running containers│ + │ + Compose stacks │ + └─────────────────────┘ +``` + +### 3.1 High-level flow + +1. Gitea workflow builds + pushes `registry.example.com/myapp:v1.2.3`. +2. Workflow calls `POST /update` with `{image, tag}` + bearer token. +3. Service validates token (constant-time compare). +4. Service lists Docker containers (running + stopped), filters by: + - Image name match (tag-agnostic) + - Opt-in label `se.enocsson.auto-update=true` +5. For each match, reads Compose's built-in labels to find `working_dir`, `config_files`, `service`, `project`. +6. Path safety check: refuse jobs whose `working_dir` is not inside `STACKS_ROOT`. +7. Deduplicates `(project, service, config_files)` and enqueues one job per unique tuple. +8. Single FIFO worker executes jobs synchronously: `docker compose -f -p pull ` then `up -d `. +9. Response aggregates per-job results. + +### 3.2 Design principles + +- **Stateless** — no database, no config file beyond environment variables. The Docker daemon is the source of truth. +- **Idempotent** — re-running the same update is safe. Zero matches returns `200` with an empty result list. +- **Fail-safe** — per-job failures do not stop other jobs. Compose's normal behaviour preserves the old container if the new one fails to start. +- **Defense in depth** — two independent gates (bearer token AND opt-in label) must both pass before any container is touched. + +## 4. API Contract + +### 4.1 `POST /update` + +**Headers:** +``` +Authorization: Bearer +Content-Type: application/json +``` + +**Request body:** +```json +{ + "image": "registry.example.com/myapp", + "tag": "v1.2.3" +} +``` + +- `image` — **required**. Image reference without tag. Matched against container image (tag-agnostic). +- `tag` — **optional**. For logging/audit only. The actual tag pulled is governed by the `image:` line in the Compose file. + +**Response codes:** + +| Status | Meaning | +|---|---| +| `200 OK` | All jobs succeeded (or zero matches) | +| `207 Multi-Status` | Mixed success/failure across jobs | +| `400 Bad Request` | Missing or invalid `image` field | +| `401 Unauthorized` | Missing or invalid token | +| `500 Internal Server Error` | Docker daemon unreachable, or all jobs failed | + +**Success response body:** +```json +{ + "request_id": "0d8c4b9e-7a1f-4d2c-8b1a-3c5e6f7a8b9d", + "image": "registry.example.com/myapp", + "tag": "v1.2.3", + "matched": 2, + "results": [ + { + "project": "myapp-prod", + "service": "web", + "compose_file": "/home/shcizo/self-hosted/myapp-prod/docker-compose.yml", + "status": "updated", + "duration_ms": 4231 + }, + { + "project": "myapp-staging", + "service": "web", + "compose_file": "/home/shcizo/self-hosted/myapp-staging/docker-compose.yml", + "status": "failed", + "error": "pull: manifest unknown", + "duration_ms": 812 + } + ] +} +``` + +**`status` values:** `updated`, `failed`, `refused` (path outside `STACKS_ROOT`), `timeout`. + +### 4.2 `GET /healthz` + +Returns `200 OK` if the process is alive AND `cli.Ping(ctx)` against the Docker socket succeeds. Used by container healthcheck and external uptime monitoring. + +### 4.3 `GET /metrics` + +Prometheus exposition format. No auth (internal network only). + +### 4.4 `GET /version` + +Returns build info (version, commit, build time). + +## 5. Discovery & Matching + +### 5.1 Container lookup + +```go +containers, err := cli.ContainerList(ctx, container.ListOptions{All: true}) +``` + +`All: true` so we also catch crashed/stopped containers we may want to restart. + +### 5.2 Image matching + +Tag-agnostic exact match after normalisation: + +``` +input: "registry.example.com/myapp" +container: "registry.example.com/myapp:v1.2.2@sha256:abc..." + → strip tag and digest → "registry.example.com/myapp" + → MATCH +``` + +Case-sensitive. No wildcards or regex (YAGNI). + +### 5.3 Opt-in filter + +Container must have label `se.enocsson.auto-update=true`. Anything else (`false`, missing, other value) is silently excluded. + +### 5.4 Compose label extraction + +| Label | Used for | +|---|---| +| `com.docker.compose.project` | `-p ` flag | +| `com.docker.compose.project.working_dir` | Working directory for `docker compose` exec | +| `com.docker.compose.project.config_files` | `-f ` flag(s) (comma-separated list) | +| `com.docker.compose.service` | Which service to pull/restart | + +If any of these labels are missing (container not started by Compose), the container is excluded with a warning log. This should not happen in practice — the whole tool is Compose-centric — but we don't crash. + +### 5.5 Path safety check + +Before running any `docker compose` command, verify that `working_dir` is inside `STACKS_ROOT` (default: `/home/shcizo/self-hosted`). If not, the job is marked `refused` and logged at warning level. This is a defense-in-depth measure against forged or unexpected Docker labels. + +### 5.6 Deduplication + +Multiple containers may belong to the same `(project, service, config_files)` tuple (e.g. Compose `deploy.replicas > 1`). Collapse to a single job using: + +``` +dedupKey = project + "|" + service + "|" + sorted(config_files) +``` + +### 5.7 Concurrency + +A single global FIFO queue with one worker. The HTTP handler blocks until all jobs for the request are complete, so the caller receives the result synchronously. The single-worker model gives per-stack mutual exclusion implicitly — no race conditions between concurrent updates against the same stack. + +## 6. Self-Update Handling + +When the service receives an update whose image matches its own running container, it must finish writing the HTTP response before exec'ing `docker compose up -d` against itself (otherwise the response is lost when the container is replaced). + +**Implementation:** detect self-update at job dispatch time. For self-update jobs, the worker writes the HTTP response, flushes, closes the connection, then waits ~1 second before invoking the `up -d` command. All other jobs execute normally. + +**Acceptable residual risk:** if a buggy version is pushed that fails to start, the running version dies (Compose can't restart what won't start) and the user must SSH in and run `docker compose up -d` manually with a previous tag. This is the same risk any self-updating system carries; mitigation is a deploy discipline issue, not a code one. + +## 7. Security Model + +### 7.1 Authentication + +- Single bearer token read from env var `UPDATER_API_KEY` at startup. Service refuses to start if it is missing or empty (fail-fast). +- Token compared with `crypto/subtle.ConstantTimeCompare` to defeat timing attacks. +- Token is never logged. + +### 7.2 Authorisation + +A container is eligible for update only if it has both: +- An image name matching the request, AND +- The opt-in label `se.enocsson.auto-update=true`. + +Both gates are independent. Compromising either alone does not allow an attacker to trigger an update. + +### 7.3 Network exposure + +The service binds `0.0.0.0:8080` inside the container and is reached via Nginx Proxy Manager which terminates TLS and forwards on the internal Docker network. The service itself does not handle TLS. + +### 7.4 Socket access + +The service mounts `/var/run/docker.sock` and therefore has effective root on the host. This is the same trust model as Watchtower. The opt-in label is the second gate that limits blast radius from API-layer abuse. + +## 8. Deployment + +### 8.1 Service's own Compose stack + +Path: `/home/shcizo/self-hosted/package-updater/docker-compose.yml` + +```yaml +services: + package-updater: + image: registry.example.com/package-updater:latest + container_name: package-updater + restart: unless-stopped + environment: + - UPDATER_API_KEY=${UPDATER_API_KEY} + - STACKS_ROOT=/home/shcizo/self-hosted + - LOG_LEVEL=info + - PORT=8080 + - UPDATE_TIMEOUT=5m + volumes: + - /var/run/docker.sock:/var/run/docker.sock + - /home/shcizo/self-hosted:/home/shcizo/self-hosted:ro + labels: + - "se.enocsson.auto-update=true" + networks: + - proxy + healthcheck: + test: ["CMD", "wget", "-q", "-O-", "http://localhost:8080/healthz"] + interval: 30s + timeout: 5s + retries: 3 + +networks: + proxy: + external: true +``` + +`.env` next to it (git-ignored): +``` +UPDATER_API_KEY= +``` + +NPM is configured with a proxy host pointing at `package-updater:8080` with TLS. + +### 8.2 Dockerfile + +Multi-stage, statically-linked Go binary: + +```dockerfile +FROM golang:1.23-alpine AS build +WORKDIR /src +COPY go.mod go.sum ./ +RUN go mod download +COPY . . +RUN CGO_ENABLED=0 go build -ldflags="-s -w" -o /out/package-updater ./cmd/server + +FROM alpine:3.20 +RUN apk add --no-cache docker-cli docker-cli-compose ca-certificates wget +COPY --from=build /out/package-updater /usr/local/bin/ +ENTRYPOINT ["/usr/local/bin/package-updater"] +``` + +`docker-cli` and `docker-cli-compose` are installed because we shell out to `docker compose` for pull/up. Going through the CLI handles dependent services and networks correctly out of the box; calling the Docker API directly would require us to replicate that logic. + +Expected image size: ~50 MB. + +### 8.3 Stacks root convention + +All Compose stacks eligible for auto-update must live under `STACKS_ROOT` (default `/home/shcizo/self-hosted`). This is required so that the path stored in `com.docker.compose.project.working_dir` (a host path) resolves to the same path inside the service container. The mount uses identical source and target paths to avoid any path translation logic. + +Read-only mount is sufficient — the service only reads Compose files; it never writes them. + +## 9. Gitea Action + +A reusable composite action lives in a separate repository, e.g. `gitea.example.com/samuel/action-deploy-update`. + +`action.yml`: +```yaml +name: "Deploy via package-updater" +description: "Notifies package-updater to pull & restart a 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 +``` + +Consumer workflow (`.gitea/workflows/deploy.yml`): +```yaml +jobs: + deploy: + runs-on: ubuntu-latest + needs: [build-and-push] + steps: + - uses: gitea.example.com/samuel/action-deploy-update@v1 + with: + endpoint: https://updater.example.com/update + image: registry.example.com/${{ gitea.repository }} + tag: ${{ gitea.sha }} + token: ${{ secrets.UPDATER_TOKEN }} +``` + +`UPDATER_TOKEN` is set as an organisation-level secret so all repos share it. + +## 10. Error Handling + +| Phase | Possible failure | Behaviour | +|---|---|---| +| Auth | Missing/invalid token | `401`, warning log with client IP | +| Validation | Missing/invalid `image` | `400`, no action | +| Docker daemon | Socket unreachable | `500`, error log | +| Discovery | Zero matches (after opt-in filter) | `200` with `matched: 0`, info log | +| Path safety | `working_dir` outside `STACKS_ROOT` | Skip job with `status: refused`, warning log. Other jobs continue. | +| `docker compose pull` | Manifest unknown, registry auth fail, network timeout | Per-job `status: failed`. No rollback (old container still running). | +| `docker compose up -d` | Validation error, port conflict, etc. | Per-job `status: failed`. Compose retains old container if new fails. | +| Per-job timeout | Job exceeds `UPDATE_TIMEOUT` (default 5m) | Cancel and mark `status: timeout`. | + +**Aggregate HTTP status:** +- All jobs `updated` → `200` +- Mixed → `207` +- All jobs `failed`/`refused`/`timeout` → `500` +- `matched: 0` → `200` (idempotent: "nothing to do" is not an error) + +No rollback in v1. If you push a buggy image, Compose keeps the old container if the new one fails to start. Real rollback (re-pin previous tag) would require tracking previous tags per service, which we don't. + +## 11. Observability + +### 11.1 Logs (Loki) + +Structured JSON to stdout. Promtail/Alloy already scrapes Docker container logs and forwards to Loki. Example records: + +```json +{"time":"2026-05-22T14:32:11Z","level":"info","event":"update_request","request_id":"0d8c4b9e","image":"registry.example.com/myapp","tag":"v1.2.3","client_ip":"10.0.0.5"} +{"time":"2026-05-22T14:32:11Z","level":"info","event":"matched","request_id":"0d8c4b9e","image":"registry.example.com/myapp","matched":2} +{"time":"2026-05-22T14:32:11Z","level":"info","event":"job_start","request_id":"0d8c4b9e","project":"myapp-prod","service":"web"} +{"time":"2026-05-22T14:32:15Z","level":"info","event":"job_complete","request_id":"0d8c4b9e","project":"myapp-prod","service":"web","status":"updated","duration_ms":4231} +{"time":"2026-05-22T14:32:15Z","level":"error","event":"job_complete","request_id":"0d8c4b9e","project":"myapp-staging","service":"web","status":"failed","error":"pull: manifest unknown","duration_ms":812} +{"time":"2026-05-22T14:32:15Z","level":"info","event":"update_response","request_id":"0d8c4b9e","status":207,"matched":2,"updated":1,"failed":1} +``` + +**Rules:** +- Never log the API key +- Always log client IP for audit +- Every request gets a `request_id` (UUID) that propagates through all related log lines +- High-cardinality fields (`image`, `project`, `service`) stay at top level for LogQL filtering + +### 11.2 Metrics (Prometheus) + +Exposed at `/metrics`, no auth (internal network). + +| Metric | Type | Labels | Purpose | +|---|---|---|---| +| `package_updater_build_info` | Gauge=1 | `version`, `commit` | Dashboard version display | +| `package_updater_http_requests_total` | Counter | `endpoint`, `status_code` | Traffic + error rate | +| `package_updater_update_jobs_total` | Counter | `project`, `service`, `status` | Deploy counts per stack, success/fail | +| `package_updater_update_duration_seconds` | Histogram | `project`, `service` | Pull+up duration; detect regressions | +| `package_updater_queue_depth` | Gauge | — | Sustained backlog indicator | +| `package_updater_last_update_timestamp` | Gauge | `project`, `service` | Wall-clock of last successful update; alert on stale stacks | +| `package_updater_docker_ping_up` | Gauge=0\|1 | — | Distinguishes "service up" from "socket broken" | + +Dependency: `github.com/prometheus/client_golang`. + +**Cardinality note:** `last_update_timestamp` grows with the number of stacks. Fine for <50 stacks. If it ever grows to hundreds, drop the per-service labels or move that metric to a separate exporter. + +## 12. Configuration + +All configuration is via environment variables. + +| Variable | Required | Default | Description | +|---|---|---|---| +| `UPDATER_API_KEY` | **yes** | — | Bearer token. Service refuses to start if missing. | +| `STACKS_ROOT` | no | `/home/shcizo/self-hosted` | Required parent directory for any Compose stack to be eligible. | +| `PORT` | no | `8080` | HTTP listen port. | +| `LOG_LEVEL` | no | `info` | `debug`/`info`/`warn`/`error`. | +| `UPDATE_TIMEOUT` | no | `5m` | Per-job timeout. Go duration string. | +| `OPT_IN_LABEL` | no | `se.enocsson.auto-update` | Label name to check (allows renaming without rebuild). Value must equal `"true"`. | + +## 13. Repository Layout (planned) + +``` +package-updater/ +├── cmd/ +│ └── server/ +│ └── main.go # entrypoint +├── internal/ +│ ├── api/ # HTTP handlers, middleware (auth, request_id, logging) +│ ├── discovery/ # Docker container lookup + Compose label parsing +│ ├── updater/ # Job queue + worker + docker compose exec +│ ├── selfupdate/ # Self-update detection + deferred exec +│ ├── config/ # Env var loading + validation +│ └── metrics/ # Prometheus collectors +├── docs/ +│ └── superpowers/ +│ └── specs/ +│ └── 2026-05-22-package-updater-design.md +├── Dockerfile +├── docker-compose.example.yml # template users can copy +├── go.mod +├── go.sum +└── README.md +``` + +The layout follows the standard Go project structure: `cmd/` for entry points, `internal/` for non-exported packages organised by responsibility. Each `internal/` package owns one concept and exposes a small interface; this keeps files focused and individually testable. + +## 14. Testing Strategy + +- **Unit tests** for: image-name normalisation, Compose label parsing, dedup logic, path safety check, request validation. +- **Integration tests** for the discovery layer using `github.com/testcontainers/testcontainers-go` to spin up a real Docker-in-Docker environment with a known Compose stack, verify discovery + matching end-to-end. +- **HTTP handler tests** with `httptest` covering auth (200/401), validation (400), happy path (200), partial failure (207), zero match (200). +- **Self-update test** verifies response is fully written and connection closed before the deferred exec fires (use a fake exec'er injected via interface). +- **No end-to-end test** that actually exercises `docker compose pull` against a registry — that becomes a manual verification step on first deploy. + +## 15. Open Questions / Future Considerations + +These are deliberately out of scope for v1 but noted as plausible additions: + +- Per-repo / per-image API keys (`keys.json` with image-pattern scopes) +- Real rollback via tag pinning (requires state) +- Notification plug-ins (Discord, Slack, email) +- Dry-run mode (`?dry_run=true`) +- Rate limiting (token bucket per client IP or per token) +- Multi-worker concurrency with per-stack mutex +- Audit history UI + +## 16. Acceptance Criteria for v1 + +- [ ] `POST /update` with a known image triggers `docker compose pull` + `up -d` for all matching opt-in services on the host. +- [ ] Containers without the opt-in label are never touched, even with a valid token. +- [ ] Requests without a valid bearer token receive `401` and trigger no Docker action. +- [ ] Zero matches returns `200` with `matched: 0`. +- [ ] Partial failure across multiple matches returns `207` with per-job results. +- [ ] Service updates itself successfully (HTTP response is fully delivered before the container is replaced). +- [ ] Logs in Loki are filterable by `event`, `level`, `project`, `service`, `status`, `request_id`. +- [ ] Prometheus successfully scrapes `/metrics`; all listed metrics are present. +- [ ] Healthcheck returns `200` when Docker socket is reachable, fails otherwise. +- [ ] Gitea composite action, used from a real workflow, triggers an end-to-end deploy and surfaces failure to the workflow when something goes wrong.