Merge pull request 'Initial implementation: package-updater v1' (#1) from feat/initial-implementation into main

This commit was merged in pull request #1.
This commit is contained in:
2026-05-22 13:59:37 +02:00
40 changed files with 2497 additions and 7 deletions
+10
View File
@@ -0,0 +1,10 @@
.git
.gitignore
.github
docs/
README.md
*.md
.env
.env.*
gitea-action/
docker-compose.example.yml
+26
View File
@@ -0,0 +1,26 @@
# Binaries
/bin/
/server
*.exe
*.dll
*.so
*.dylib
# Test binary, output of `go test -c`
*.test
# Coverage
*.out
coverage.html
# IDE
.idea/
.vscode/
*.swp
# Local env files
.env
.env.local
# Serena MCP workspace
.serena/
+22
View File
@@ -0,0 +1,22 @@
# syntax=docker/dockerfile:1.7
FROM golang:1.26-alpine AS build
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
ARG VERSION=dev
ARG COMMIT=unknown
ARG BUILD_TIME=unknown
RUN CGO_ENABLED=0 go build \
-ldflags="-s -w \
-X main.version=${VERSION} \
-X main.commit=${COMMIT} \
-X main.buildTime=${BUILD_TIME}" \
-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/
EXPOSE 8080
ENTRYPOINT ["/usr/local/bin/package-updater"]
+84
View File
@@ -0,0 +1,84 @@
# package-updater
Webhook-driven Docker Compose service updater. Fills the gap between Watchtower (polling, no CI integration) and full GitOps (Argo CD, Flux) for a self-hosted, single-host environment.
**Trigger flow:**
1. Gitea workflow builds and pushes a new image to your registry.
2. Workflow calls `POST /update` on this service with the image name.
3. Service finds the matching Compose-managed container(s) on the host via Docker labels.
4. Runs `docker compose pull` + `up -d` for the relevant service(s).
See [design spec](docs/superpowers/specs/2026-05-22-package-updater-design.md) and [implementation plan](docs/superpowers/plans/2026-05-22-package-updater-implementation.md) for full design and rationale.
## How it finds the right stack
The service queries the Docker socket and reads the labels Compose itself attaches to every container:
- `com.docker.compose.project`
- `com.docker.compose.service`
- `com.docker.compose.project.working_dir`
- `com.docker.compose.project.config_files`
A container is eligible for update only if it has **both**:
- An image name matching the request (tag-agnostic), AND
- The opt-in label `se.shcizo.auto-update=true`.
Defense in depth: a valid bearer token AND the opt-in label must both be present before any container is touched.
## Quick start
1. Build and push the image (e.g. via your own CI).
2. Copy `docker-compose.example.yml` to `/home/shcizo/self-hosted/package-updater/docker-compose.yml`.
3. Create `.env` next to it: `UPDATER_API_KEY=$(openssl rand -hex 32)`.
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.
## Configuration
All via environment variables.
| Variable | Required | Default | Purpose |
|---|---|---|---|
| `UPDATER_API_KEY` | **yes** | — | Bearer token. Service refuses to start without it. |
| `STACKS_ROOT` | no | `/home/shcizo/self-hosted` | Required parent for any stack eligible to update. |
| `PORT` | no | `8080` | HTTP listen port. |
| `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"`. |
## Endpoints
| Endpoint | Auth | Purpose |
|---|---|---|
| `POST /update` | Bearer token | Trigger pull + restart for matching services |
| `GET /healthz` | none | Liveness + Docker socket reachability |
| `GET /version` | none | Build info |
| `GET /metrics` | none | Prometheus exposition |
`/healthz`, `/version`, and `/metrics` are intentionally unauthenticated — they're internal-network only behind the reverse proxy.
## Observability
- **Logs**: JSON to stdout, picked up by Promtail/Alloy → Loki.
- **Metrics**: Prometheus exposition on `/metrics`. Notable: `package_updater_update_jobs_total{project,service,status}`, `package_updater_last_update_timestamp{project,service}`, `package_updater_docker_ping_up`.
## Development
```bash
go test ./...
go build ./cmd/server
docker build -t package-updater:dev .
```
## Known v1 gaps
These are tracked in the spec's section 2 and section 15 as deliberate out-of-scope:
- **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**.
- **No per-repo API keys**: a single shared bearer token is used.
+131
View File
@@ -0,0 +1,131 @@
package main
import (
"context"
"fmt"
"net/http"
"os"
"os/signal"
"syscall"
"time"
"github.com/docker/docker/client"
"github.com/prometheus/client_golang/prometheus"
"github.com/shcizo/package-updater/internal/api"
"github.com/shcizo/package-updater/internal/config"
"github.com/shcizo/package-updater/internal/discovery"
"github.com/shcizo/package-updater/internal/logging"
"github.com/shcizo/package-updater/internal/metrics"
"github.com/shcizo/package-updater/internal/updater"
)
// Build info is injected at link time via -ldflags="-X main.version=..."
var (
version = "dev"
commit = "unknown"
buildTime = "unknown"
)
func main() {
if err := run(); err != nil {
fmt.Fprintln(os.Stderr, "fatal:", err)
os.Exit(1)
}
}
func run() error {
cfg, err := config.Load()
if err != nil {
return err
}
logger := logging.New(cfg.LogLevel)
logger.Info("starting",
"version", version, "commit", commit, "port", cfg.Port,
"stacks_root", cfg.StacksRoot, "opt_in_label", cfg.OptInLabel,
)
dockerCli, err := client.NewClientWithOpts(client.FromEnv, client.WithAPIVersionNegotiation())
if err != nil {
return fmt.Errorf("docker client: %w", err)
}
defer dockerCli.Close()
disc := discovery.New(dockerCli, cfg.StacksRoot, cfg.OptInLabel)
exec := updater.NewComposeExecutor()
reg := prometheus.NewRegistry()
m := metrics.New(reg)
m.BuildInfo.WithLabelValues(version, commit).Set(1)
queue := updater.NewQueue(exec, m)
queue.Start(context.Background())
defer queue.Stop()
handlers := api.NewHandlers(disc, &submitterAdapter{queue: queue, timeout: cfg.UpdateTimeout}, dockerCli, version, commit, buildTime, m)
mux := http.NewServeMux()
mux.HandleFunc("POST /update", handlers.Update)
mux.HandleFunc("GET /healthz", handlers.Healthz)
mux.HandleFunc("GET /version", handlers.Version)
mux.Handle("GET /metrics", metrics.Handler(reg))
authed := api.Auth(cfg.APIKey)
handler := api.RequestID(api.RequestLogger(logger, m)(routeAuth(mux, authed)))
srv := &http.Server{
Addr: ":" + cfg.Port,
Handler: handler,
ReadHeaderTimeout: 10 * time.Second,
}
errCh := make(chan error, 1)
go func() {
logger.Info("listening", "addr", srv.Addr)
errCh <- srv.ListenAndServe()
}()
sig := make(chan os.Signal, 1)
signal.Notify(sig, syscall.SIGINT, syscall.SIGTERM)
select {
case s := <-sig:
logger.Info("shutdown_signal", "signal", s.String())
case err := <-errCh:
if err != nil && err != http.ErrServerClosed {
return err
}
}
shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
_ = srv.Shutdown(shutdownCtx)
return nil
}
// routeAuth applies the bearer-token middleware to /update only.
// /healthz, /metrics, /version are unauthenticated by design (internal
// network only; healthcheck and Prometheus scraper need to reach them
// without secrets).
func routeAuth(next *http.ServeMux, mw func(http.Handler) http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/update" {
mw(next).ServeHTTP(w, r)
return
}
next.ServeHTTP(w, r)
})
}
// submitterAdapter wraps Queue.Submit to inject the per-request timeout
// from config, satisfying the api.Submitter interface.
type submitterAdapter struct {
queue *updater.Queue
timeout time.Duration
}
func (s *submitterAdapter) Submit(ctx context.Context, jobs []discovery.Job) []updater.Result {
ctx, cancel := context.WithTimeout(ctx, s.timeout)
defer cancel()
return s.queue.Submit(ctx, jobs)
}
+32
View File
@@ -0,0 +1,32 @@
# Example deployment of package-updater itself.
# Copy to /home/shcizo/self-hosted/package-updater/docker-compose.yml
# and create a .env file alongside containing:
# UPDATER_API_KEY=<openssl rand -hex 32>
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.shcizo.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
+40
View File
@@ -0,0 +1,40 @@
# Local development / smoke-test compose file.
# Unlike docker-compose.example.yml (which pulls a pre-built image from a registry),
# this file BUILDS the image from the local Dockerfile so you can iterate without
# pushing anywhere.
#
# Usage:
# 1. Copy .env.example to .env and fill in UPDATER_API_KEY (e.g. `openssl rand -hex 32`)
# 2. Optionally adjust STACKS_ROOT below to point at a directory you actually have
# compose stacks in (default points at this repo dir, which is fine for smoke tests)
# 3. docker compose -f docker-compose.local.yml up --build
# 4. curl -s http://localhost:8080/healthz | jq
# 5. curl -sH "Authorization: Bearer $UPDATER_API_KEY" -d '{"image":"test"}' \
# http://localhost:8080/update | jq
services:
package-updater:
build:
context: .
args:
VERSION: dev-local
COMMIT: ${COMMIT:-unknown}
BUILD_TIME: ${BUILD_TIME:-unknown}
container_name: package-updater-local
restart: unless-stopped
environment:
- UPDATER_API_KEY=${UPDATER_API_KEY}
- STACKS_ROOT=${STACKS_ROOT:-/tmp}
- LOG_LEVEL=debug
- PORT=8080
- UPDATE_TIMEOUT=5m
ports:
- "8080:8080"
volumes:
- /var/run/docker.sock:/var/run/docker.sock
- ${STACKS_ROOT:-/tmp}:${STACKS_ROOT:-/tmp}:ro
healthcheck:
test: ["CMD", "wget", "-q", "-O-", "http://localhost:8080/healthz"]
interval: 30s
timeout: 5s
retries: 3
@@ -2587,7 +2587,6 @@ import (
"net/http" "net/http"
"os" "os"
"os/signal" "os/signal"
"strconv"
"syscall" "syscall"
"time" "time"
@@ -2708,7 +2707,6 @@ type submitterAdapter struct {
func (s *submitterAdapter) Submit(ctx context.Context, jobs []discovery.Job) []updater.Result { func (s *submitterAdapter) Submit(ctx context.Context, jobs []discovery.Job) []updater.Result {
ctx, cancel := context.WithTimeout(ctx, s.timeout) ctx, cancel := context.WithTimeout(ctx, s.timeout)
defer cancel() defer cancel()
_ = strconv.Itoa // silence unused-import linter for strconv in case go.sum drops it
return s.queue.Submit(ctx, jobs) return s.queue.Submit(ctx, jobs)
} }
``` ```
@@ -50,7 +50,7 @@ Watchtower remains in place for third-party images that the user does not build
3. Service validates token (constant-time compare). 3. Service validates token (constant-time compare).
4. Service lists Docker containers (running + stopped), filters by: 4. Service lists Docker containers (running + stopped), filters by:
- Image name match (tag-agnostic) - Image name match (tag-agnostic)
- Opt-in label `se.enocsson.auto-update=true` - Opt-in label `se.shcizo.auto-update=true`
5. For each match, reads Compose's built-in labels to find `working_dir`, `config_files`, `service`, `project`. 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`. 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. 7. Deduplicates `(project, service, config_files)` and enqueues one job per unique tuple.
@@ -161,7 +161,7 @@ Case-sensitive. No wildcards or regex (YAGNI).
### 5.3 Opt-in filter ### 5.3 Opt-in filter
Container must have label `se.enocsson.auto-update=true`. Anything else (`false`, missing, other value) is silently excluded. Container must have label `se.shcizo.auto-update=true`. Anything else (`false`, missing, other value) is silently excluded.
### 5.4 Compose label extraction ### 5.4 Compose label extraction
@@ -210,7 +210,7 @@ When the service receives an update whose image matches its own running containe
A container is eligible for update only if it has both: A container is eligible for update only if it has both:
- An image name matching the request, AND - An image name matching the request, AND
- The opt-in label `se.enocsson.auto-update=true`. - The opt-in label `se.shcizo.auto-update=true`.
Both gates are independent. Compromising either alone does not allow an attacker to trigger an update. Both gates are independent. Compromising either alone does not allow an attacker to trigger an update.
@@ -244,7 +244,7 @@ services:
- /var/run/docker.sock:/var/run/docker.sock - /var/run/docker.sock:/var/run/docker.sock
- /home/shcizo/self-hosted:/home/shcizo/self-hosted:ro - /home/shcizo/self-hosted:/home/shcizo/self-hosted:ro
labels: labels:
- "se.enocsson.auto-update=true" - "se.shcizo.auto-update=true"
networks: networks:
- proxy - proxy
healthcheck: healthcheck:
@@ -426,7 +426,7 @@ All configuration is via environment variables.
| `PORT` | no | `8080` | HTTP listen port. | | `PORT` | no | `8080` | HTTP listen port. |
| `LOG_LEVEL` | no | `info` | `debug`/`info`/`warn`/`error`. | | `LOG_LEVEL` | no | `info` | `debug`/`info`/`warn`/`error`. |
| `UPDATE_TIMEOUT` | no | `5m` | Per-job timeout. Go duration string. | | `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"`. | | `OPT_IN_LABEL` | no | `se.shcizo.auto-update` | Label name to check (allows renaming without rebuild). Value must equal `"true"`. |
## 13. Repository Layout (planned) ## 13. Repository Layout (planned)
+15
View File
@@ -0,0 +1,15 @@
# Copy to .env (which is gitignored) and fill in real values:
# cp env.sample .env && $EDITOR .env
# Required: bearer token clients must send as `Authorization: Bearer <value>`.
# Generate with: openssl rand -hex 32
UPDATER_API_KEY=
# Optional: root directory the service is allowed to manage stacks under.
# Defaults to /tmp for local smoke testing. For real use, set to your stacks dir,
# e.g. /home/shcizo/self-hosted
# STACKS_ROOT=/home/shcizo/self-hosted
# Optional: build info baked into the binary via -ldflags. Not required.
# COMMIT=$(git rev-parse --short HEAD)
# BUILD_TIME=$(date -u +%Y-%m-%dT%H:%M:%SZ)
+36
View File
@@ -0,0 +1,36 @@
# 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
@@ -0,0 +1,37 @@
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
+49
View File
@@ -0,0 +1,49 @@
module github.com/shcizo/package-updater
go 1.26.3
require (
github.com/docker/docker v28.5.2+incompatible
github.com/prometheus/client_golang v1.23.2
github.com/stretchr/testify v1.11.1
)
require (
github.com/Microsoft/go-winio v0.6.2 // indirect
github.com/beorn7/perks v1.0.1 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/containerd/errdefs v1.0.0 // indirect
github.com/containerd/errdefs/pkg v0.3.0 // indirect
github.com/containerd/log v0.1.0 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/distribution/reference v0.6.0 // indirect
github.com/docker/go-connections v0.7.0 // indirect
github.com/docker/go-units v0.5.0 // indirect
github.com/felixge/httpsnoop v1.0.4 // indirect
github.com/go-logr/logr v1.4.3 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/moby/docker-image-spec v1.3.1 // indirect
github.com/moby/sys/atomicwriter v0.1.0 // indirect
github.com/moby/term v0.5.2 // indirect
github.com/morikuni/aec v1.1.0 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/opencontainers/go-digest v1.0.0 // indirect
github.com/opencontainers/image-spec v1.1.1 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/prometheus/client_model v0.6.2 // indirect
github.com/prometheus/common v0.66.1 // indirect
github.com/prometheus/procfs v0.16.1 // indirect
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.68.0 // indirect
go.opentelemetry.io/otel v1.43.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.43.0 // indirect
go.opentelemetry.io/otel/metric v1.43.0 // indirect
go.opentelemetry.io/otel/trace v1.43.0 // indirect
go.yaml.in/yaml/v2 v2.4.2 // indirect
golang.org/x/sys v0.42.0 // indirect
golang.org/x/time v0.15.0 // indirect
google.golang.org/protobuf v1.36.11 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
gotest.tools/v3 v3.5.2 // indirect
)
+128
View File
@@ -0,0 +1,128 @@
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEKWjV8V+WSxDXJ4NFATAsZjh8iIbsQIg=
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM=
github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI=
github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M=
github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE=
github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk=
github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I=
github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk=
github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
github.com/docker/docker v28.5.2+incompatible h1:DBX0Y0zAjZbSrm1uzOkdr1onVghKaftjlSWt4AFexzM=
github.com/docker/docker v28.5.2+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
github.com/docker/go-connections v0.7.0 h1:6SsRfJddP22WMrCkj19x9WKjEDTB+ahsdiGYf0mN39c=
github.com/docker/go-connections v0.7.0/go.mod h1:no1qkHdjq7kLMGUXYAduOhYPSJxxvgWBh7ogVvptn3Q=
github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=
github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 h1:HWRh5R2+9EifMyIHV7ZV+MIZqgz+PMpZ14Jynv3O2Zs=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0/go.mod h1:JfhWUomR1baixubs02l85lZYYOm7LV6om4ceouMv45c=
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0=
github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo=
github.com/moby/sys/atomicwriter v0.1.0 h1:kw5D/EqkBwsBFi0ss9v1VG3wIkVhzGvLklJ+w3A14Sw=
github.com/moby/sys/atomicwriter v0.1.0/go.mod h1:Ul8oqv2ZMNHOceF643P6FKPXeCmYtlQMvpizfsSoaWs=
github.com/moby/sys/sequential v0.6.0 h1:qrx7XFUd/5DxtqcoH1h438hF5TmOvzC/lspjy7zgvCU=
github.com/moby/sys/sequential v0.6.0/go.mod h1:uyv8EUTrca5PnDsdMGXhZe6CCe8U/UiTWd+lL+7b/Ko=
github.com/moby/term v0.5.2 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ=
github.com/moby/term v0.5.2/go.mod h1:d3djjFCrjnB+fl8NJux+EJzu0msscUP+f8it8hPkFLc=
github.com/morikuni/aec v1.1.0 h1:vBBl0pUnvi/Je71dsRrhMBtreIqNMYErSAbEeb8jrXQ=
github.com/morikuni/aec v1.1.0/go.mod h1:xDRgiq/iw5l+zkao76YTKzKttOp2cwPEne25HDkJnBw=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040=
github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o=
github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg=
github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
github.com/prometheus/common v0.66.1 h1:h5E0h5/Y8niHc5DlaLlWLArTQI7tMrsfQjHV+d9ZoGs=
github.com/prometheus/common v0.66.1/go.mod h1:gcaUsgf3KfRSwHY4dIMXLPV0K/Wg1oZ8+SbZk/HH/dA=
github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg=
github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.68.0 h1:CqXxU8VOmDefoh0+ztfGaymYbhdB/tT3zs79QaZTNGY=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.68.0/go.mod h1:BuhAPThV8PBHBvg8ZzZ/Ok3idOdhWIodywz2xEcRbJo=
go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I=
go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0 h1:88Y4s2C8oTui1LGM6bTWkw0ICGcOLCAI5l6zsD1j20k=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0/go.mod h1:Vl1/iaggsuRlrHf/hfPJPvVag77kKyvrLeD10kpMl+A=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.43.0 h1:3iZJKlCZufyRzPzlQhUIWVmfltrXuGyfjREgGP3UUjc=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.43.0/go.mod h1:/G+nUPfhq2e+qiXMGxMwumDrP5jtzU+mWN7/sjT2rak=
go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM=
go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY=
go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg=
go.opentelemetry.io/otel/sdk v1.43.0/go.mod h1:P+IkVU3iWukmiit/Yf9AWvpyRDlUeBaRg6Y+C58QHzg=
go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfCGLEo89fDkw=
go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A=
go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A=
go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0=
go.opentelemetry.io/proto/otlp v1.10.0 h1:IQRWgT5srOCYfiWnpqUYz9CVmbO8bFmKcwYxpuCSL2g=
go.opentelemetry.io/proto/otlp v1.10.0/go.mod h1:/CV4QoCR/S9yaPj8utp3lvQPoqMtxXdzn7ozvvozVqk=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI=
go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU=
golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0=
golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw=
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9 h1:VPWxll4HlMw1Vs/qXtN7BvhZqsS9cdAittCNvVENElA=
google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9/go.mod h1:7QBABkRtR8z+TEnmXTqIqwJLlzrZKVfAUm7tY3yGv0M=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 h1:m8qni9SQFH0tJc1X0vmnpw/0t+AImlSvp30sEupozUg=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
google.golang.org/grpc v1.80.0 h1:Xr6m2WmWZLETvUNvIUmeD5OAagMw3FiKmMlTdViWsHM=
google.golang.org/grpc v1.80.0/go.mod h1:ho/dLnxwi3EDJA4Zghp7k2Ec1+c2jqup0bFkw07bwF4=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q=
gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA=
+142
View File
@@ -0,0 +1,142 @@
package api
import (
"context"
"encoding/json"
"net/http"
"github.com/docker/docker/api/types"
"github.com/shcizo/package-updater/internal/discovery"
"github.com/shcizo/package-updater/internal/logging"
"github.com/shcizo/package-updater/internal/metrics"
"github.com/shcizo/package-updater/internal/updater"
)
// Finder is the discovery interface used by the update handler.
type Finder interface {
FindJobs(ctx context.Context, image string) ([]discovery.Job, error)
}
// Submitter is the queue interface used by the update handler.
type Submitter interface {
Submit(ctx context.Context, jobs []discovery.Job) []updater.Result
}
// Pinger pings the Docker daemon for the healthcheck.
type Pinger interface {
Ping(ctx context.Context) (types.Ping, error)
}
// Handlers wires the HTTP endpoints to the rest of the service.
type Handlers struct {
finder Finder
submitter Submitter
pinger Pinger
version string
commit string
buildTime string
metrics *metrics.Metrics
}
// NewHandlers constructs a Handlers value with all dependencies injected.
// m may be nil, in which case metrics recording is silently skipped.
func NewHandlers(f Finder, s Submitter, p Pinger, version, commit, buildTime string, m *metrics.Metrics) *Handlers {
return &Handlers{finder: f, submitter: s, pinger: p, version: version, commit: commit, buildTime: buildTime, metrics: m}
}
// Update implements POST /update.
func (h *Handlers) Update(w http.ResponseWriter, r *http.Request) {
var req UpdateRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeJSONError(w, http.StatusBadRequest, "invalid JSON body")
return
}
if req.Image == "" {
writeJSONError(w, http.StatusBadRequest, "image is required")
return
}
jobs, err := h.finder.FindJobs(r.Context(), req.Image)
if err != nil {
writeJSONError(w, http.StatusInternalServerError, "discovery failed: "+err.Error())
return
}
resp := UpdateResponse{
RequestID: logging.RequestIDFrom(r.Context()),
Image: req.Image,
Tag: req.Tag,
Matched: len(jobs),
Results: []ResultDTO{},
}
if len(jobs) == 0 {
writeJSON(w, http.StatusOK, resp)
return
}
results := h.submitter.Submit(r.Context(), jobs)
updated, failed := 0, 0
for _, res := range results {
composeFile := ""
if len(res.Job.ConfigFiles) > 0 {
composeFile = res.Job.ConfigFiles[0]
}
resp.Results = append(resp.Results, ResultDTO{
Project: res.Job.Project,
Service: res.Job.Service,
ComposeFile: composeFile,
Status: string(res.Status),
Error: res.Error,
DurationMs: res.DurationMs,
})
if res.Status == updater.StatusUpdated {
updated++
} else {
failed++
}
}
switch {
case failed == 0:
writeJSON(w, http.StatusOK, resp)
case updated == 0:
writeJSON(w, http.StatusInternalServerError, resp)
default:
writeJSON(w, http.StatusMultiStatus, resp)
}
}
// Healthz implements GET /healthz.
func (h *Handlers) Healthz(w http.ResponseWriter, r *http.Request) {
if _, err := h.pinger.Ping(r.Context()); err != nil {
if h.metrics != nil {
h.metrics.DockerPingUp.Set(0)
}
writeJSON(w, http.StatusServiceUnavailable, HealthResponse{Status: "unhealthy", Docker: "unreachable"})
return
}
if h.metrics != nil {
h.metrics.DockerPingUp.Set(1)
}
writeJSON(w, http.StatusOK, HealthResponse{Status: "ok", Docker: "ok"})
}
// Version implements GET /version.
func (h *Handlers) Version(w http.ResponseWriter, _ *http.Request) {
writeJSON(w, http.StatusOK, VersionResponse{
Version: h.version,
Commit: h.commit,
BuildTime: h.buildTime,
})
}
func writeJSON(w http.ResponseWriter, code int, body any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(code)
_ = json.NewEncoder(w).Encode(body)
}
func writeJSONError(w http.ResponseWriter, code int, msg string) {
writeJSON(w, code, map[string]string{"error": msg})
}
+168
View File
@@ -0,0 +1,168 @@
package api_test
import (
"bytes"
"context"
"encoding/json"
"errors"
"io"
"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 fakeFinder struct {
jobs []discovery.Job
err error
}
func (f *fakeFinder) FindJobs(_ context.Context, _ string) ([]discovery.Job, error) {
return f.jobs, f.err
}
type fakeSubmitter struct {
results []updater.Result
}
func (f *fakeSubmitter) Submit(_ context.Context, jobs []discovery.Job) []updater.Result {
if f.results != nil {
return f.results
}
out := make([]updater.Result, len(jobs))
for i, j := range jobs {
out[i] = updater.Result{Job: j, Status: updater.StatusUpdated}
}
return out
}
type fakePinger struct{ err error }
func (f *fakePinger) Ping(_ context.Context) (types.Ping, error) {
return types.Ping{}, f.err
}
func decode[T any](t *testing.T, body io.Reader) T {
t.Helper()
var v T
require.NoError(t, json.NewDecoder(body).Decode(&v))
return v
}
func TestUpdate_ValidationError(t *testing.T) {
h := api.NewHandlers(&fakeFinder{}, &fakeSubmitter{}, &fakePinger{}, "v0.0.0", "abc", "now", nil)
req := httptest.NewRequest(http.MethodPost, "/update",
strings.NewReader(`{"tag":"v1.2.3"}`))
w := httptest.NewRecorder()
h.Update(w, req)
require.Equal(t, http.StatusBadRequest, w.Code)
}
func TestUpdate_BadJSON(t *testing.T) {
h := api.NewHandlers(&fakeFinder{}, &fakeSubmitter{}, &fakePinger{}, "v0.0.0", "abc", "now", nil)
req := httptest.NewRequest(http.MethodPost, "/update", strings.NewReader(`not json`))
w := httptest.NewRecorder()
h.Update(w, req)
require.Equal(t, http.StatusBadRequest, w.Code)
}
func TestUpdate_DiscoveryFailureReturns500(t *testing.T) {
finder := &fakeFinder{err: errors.New("daemon unreachable")}
h := api.NewHandlers(finder, &fakeSubmitter{}, &fakePinger{}, "v0.0.0", "abc", "now", nil)
body, _ := json.Marshal(api.UpdateRequest{Image: "r/x"})
req := httptest.NewRequest(http.MethodPost, "/update", bytes.NewReader(body))
w := httptest.NewRecorder()
h.Update(w, req)
require.Equal(t, http.StatusInternalServerError, w.Code)
}
func TestUpdate_ZeroMatchesReturns200(t *testing.T) {
h := api.NewHandlers(&fakeFinder{}, &fakeSubmitter{}, &fakePinger{}, "v0.0.0", "abc", "now", nil)
body, _ := json.Marshal(api.UpdateRequest{Image: "r/x"})
req := httptest.NewRequest(http.MethodPost, "/update", bytes.NewReader(body))
w := httptest.NewRecorder()
h.Update(w, req)
require.Equal(t, http.StatusOK, w.Code)
resp := decode[api.UpdateResponse](t, w.Body)
require.Equal(t, 0, resp.Matched)
}
func TestUpdate_AllSucceeded200(t *testing.T) {
finder := &fakeFinder{jobs: []discovery.Job{
{Project: "p", Service: "s", WorkingDir: "/x", ConfigFiles: []string{"/x/c.yml"}},
}}
h := api.NewHandlers(finder, &fakeSubmitter{}, &fakePinger{}, "v0.0.0", "abc", "now", nil)
body, _ := json.Marshal(api.UpdateRequest{Image: "r/x", Tag: "v1"})
req := httptest.NewRequest(http.MethodPost, "/update", bytes.NewReader(body))
w := httptest.NewRecorder()
h.Update(w, req)
require.Equal(t, http.StatusOK, w.Code)
resp := decode[api.UpdateResponse](t, w.Body)
require.Equal(t, 1, resp.Matched)
require.Equal(t, "updated", resp.Results[0].Status)
require.Equal(t, "/x/c.yml", resp.Results[0].ComposeFile)
}
func TestUpdate_MixedReturns207(t *testing.T) {
jobs := []discovery.Job{
{Project: "p1", Service: "s", ConfigFiles: []string{"/x/c.yml"}},
{Project: "p2", Service: "s", ConfigFiles: []string{"/y/c.yml"}},
}
finder := &fakeFinder{jobs: jobs}
submitter := &fakeSubmitter{results: []updater.Result{
{Job: jobs[0], Status: updater.StatusUpdated},
{Job: jobs[1], Status: updater.StatusFailed, Error: "boom"},
}}
h := api.NewHandlers(finder, submitter, &fakePinger{}, "v0.0.0", "abc", "now", nil)
body, _ := json.Marshal(api.UpdateRequest{Image: "r/x"})
req := httptest.NewRequest(http.MethodPost, "/update", bytes.NewReader(body))
w := httptest.NewRecorder()
h.Update(w, req)
require.Equal(t, http.StatusMultiStatus, w.Code)
}
func TestUpdate_AllFailedReturns500(t *testing.T) {
jobs := []discovery.Job{{Project: "p", Service: "s", ConfigFiles: []string{"/x/c.yml"}}}
finder := &fakeFinder{jobs: jobs}
submitter := &fakeSubmitter{results: []updater.Result{
{Job: jobs[0], Status: updater.StatusFailed, Error: "boom"},
}}
h := api.NewHandlers(finder, submitter, &fakePinger{}, "v0.0.0", "abc", "now", nil)
body, _ := json.Marshal(api.UpdateRequest{Image: "r/x"})
req := httptest.NewRequest(http.MethodPost, "/update", bytes.NewReader(body))
w := httptest.NewRecorder()
h.Update(w, req)
require.Equal(t, http.StatusInternalServerError, w.Code)
}
func TestHealthz_OKWhenDockerUp(t *testing.T) {
h := api.NewHandlers(&fakeFinder{}, &fakeSubmitter{}, &fakePinger{}, "v0.0.0", "abc", "now", nil)
req := httptest.NewRequest(http.MethodGet, "/healthz", nil)
w := httptest.NewRecorder()
h.Healthz(w, req)
require.Equal(t, http.StatusOK, w.Code)
}
func TestHealthz_503WhenDockerDown(t *testing.T) {
h := api.NewHandlers(&fakeFinder{}, &fakeSubmitter{}, &fakePinger{err: errors.New("ping fail")}, "v0.0.0", "abc", "now", nil)
req := httptest.NewRequest(http.MethodGet, "/healthz", nil)
w := httptest.NewRecorder()
h.Healthz(w, req)
require.Equal(t, http.StatusServiceUnavailable, w.Code)
}
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)
w := httptest.NewRecorder()
h.Version(w, req)
require.Equal(t, http.StatusOK, w.Code)
resp := decode[api.VersionResponse](t, w.Body)
require.Equal(t, "v1.2.3", resp.Version)
}
+112
View File
@@ -0,0 +1,112 @@
// Package api contains HTTP handlers, middleware, and request/response DTOs.
package api
import (
"crypto/rand"
"crypto/subtle"
"fmt"
"log/slog"
"net/http"
"strconv"
"strings"
"time"
"github.com/shcizo/package-updater/internal/logging"
"github.com/shcizo/package-updater/internal/metrics"
)
// Auth returns middleware that requires a matching bearer token.
// Compares with constant-time to defeat timing attacks.
func Auth(token string) func(http.Handler) http.Handler {
tokenBytes := []byte(token)
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
h := r.Header.Get("Authorization")
const prefix = "Bearer "
if !strings.HasPrefix(h, prefix) {
writeAuthError(w)
return
}
provided := []byte(strings.TrimPrefix(h, prefix))
if len(provided) == 0 ||
subtle.ConstantTimeCompare(provided, tokenBytes) != 1 {
writeAuthError(w)
return
}
next.ServeHTTP(w, r)
})
}
}
func writeAuthError(w http.ResponseWriter) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusUnauthorized)
_, _ = w.Write([]byte(`{"error":"unauthorized"}`))
}
// newUUID generates a random UUID v4 string using crypto/rand.
func newUUID() string {
var b [16]byte
_, _ = rand.Read(b[:])
b[6] = (b[6] & 0x0f) | 0x40 // version 4
b[8] = (b[8] & 0x3f) | 0x80 // variant bits
return fmt.Sprintf("%08x-%04x-%04x-%04x-%012x",
b[0:4], b[4:6], b[6:8], b[8:10], b[10:16])
}
// RequestID middleware ensures every request has an X-Request-ID
// header (generated if absent) and stores it in the request context.
func RequestID(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
id := r.Header.Get("X-Request-ID")
if id == "" {
id = newUUID()
}
w.Header().Set("X-Request-ID", id)
ctx := logging.WithRequestID(r.Context(), id)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
// RequestLogger emits a structured access-log line per request. m may be nil,
// in which case metrics recording is silently skipped.
func RequestLogger(base *slog.Logger, m *metrics.Metrics) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
sw := &statusWriter{ResponseWriter: w, status: http.StatusOK}
next.ServeHTTP(sw, r)
logger := logging.FromContext(r.Context(), base)
logger.Info("http_request",
"method", r.Method,
"path", r.URL.Path,
"status", sw.status,
"duration_ms", time.Since(start).Milliseconds(),
"client_ip", clientIP(r),
)
if m != nil {
m.HTTPRequests.WithLabelValues(r.URL.Path, strconv.Itoa(sw.status)).Inc()
}
})
}
}
type statusWriter struct {
http.ResponseWriter
status int
}
func (s *statusWriter) WriteHeader(code int) {
s.status = code
s.ResponseWriter.WriteHeader(code)
}
func clientIP(r *http.Request) string {
if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
if comma := strings.Index(xff, ","); comma >= 0 {
return strings.TrimSpace(xff[:comma])
}
return xff
}
return r.RemoteAddr
}
+92
View File
@@ -0,0 +1,92 @@
package api_test
import (
"io"
"log/slog"
"net/http"
"net/http/httptest"
"testing"
"github.com/shcizo/package-updater/internal/api"
"github.com/shcizo/package-updater/internal/logging"
"github.com/stretchr/testify/require"
)
func newTestLogger() *slog.Logger {
return slog.New(slog.NewJSONHandler(io.Discard, nil))
}
func TestAuth_AllowsMatchingToken(t *testing.T) {
called := false
h := api.Auth("secret")(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
called = true
w.WriteHeader(http.StatusOK)
}))
req := httptest.NewRequest(http.MethodPost, "/update", nil)
req.Header.Set("Authorization", "Bearer secret")
w := httptest.NewRecorder()
h.ServeHTTP(w, req)
require.True(t, called)
require.Equal(t, http.StatusOK, w.Code)
}
func TestAuth_Rejects(t *testing.T) {
cases := []struct{ name, header string }{
{"missing", ""},
{"wrong scheme", "Token secret"},
{"wrong value", "Bearer nope"},
{"empty bearer", "Bearer "},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
h := api.Auth("secret")(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
t.Fatal("handler must not be called")
}))
req := httptest.NewRequest(http.MethodPost, "/update", nil)
if c.header != "" {
req.Header.Set("Authorization", c.header)
}
w := httptest.NewRecorder()
h.ServeHTTP(w, req)
require.Equal(t, http.StatusUnauthorized, w.Code)
})
}
}
func TestRequestID_GeneratesIfMissing(t *testing.T) {
var seenID string
h := api.RequestID(http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) {
seenID = logging.RequestIDFrom(r.Context())
}))
req := httptest.NewRequest(http.MethodPost, "/update", nil)
w := httptest.NewRecorder()
h.ServeHTTP(w, req)
require.NotEmpty(t, seenID)
require.Equal(t, seenID, w.Header().Get("X-Request-ID"))
}
func TestRequestID_UsesIncoming(t *testing.T) {
var seenID string
h := api.RequestID(http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) {
seenID = logging.RequestIDFrom(r.Context())
}))
req := httptest.NewRequest(http.MethodPost, "/update", nil)
req.Header.Set("X-Request-ID", "given-id")
w := httptest.NewRecorder()
h.ServeHTTP(w, req)
require.Equal(t, "given-id", seenID)
}
func TestRequestLogger_LogsAndDelegates(t *testing.T) {
logger := newTestLogger()
called := false
h := api.RequestLogger(logger, nil)(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
called = true
w.WriteHeader(http.StatusTeapot)
}))
req := httptest.NewRequest(http.MethodPost, "/update", nil)
w := httptest.NewRecorder()
h.ServeHTTP(w, req)
require.True(t, called)
require.Equal(t, http.StatusTeapot, w.Code)
}
+39
View File
@@ -0,0 +1,39 @@
package api
// UpdateRequest is the body of POST /update.
type UpdateRequest struct {
Image string `json:"image"`
Tag string `json:"tag,omitempty"`
}
// UpdateResponse is the body of POST /update.
type UpdateResponse struct {
RequestID string `json:"request_id"`
Image string `json:"image"`
Tag string `json:"tag,omitempty"`
Matched int `json:"matched"`
Results []ResultDTO `json:"results"`
}
// ResultDTO is one row in UpdateResponse.Results.
type ResultDTO struct {
Project string `json:"project"`
Service string `json:"service"`
ComposeFile string `json:"compose_file"`
Status string `json:"status"`
Error string `json:"error,omitempty"`
DurationMs int64 `json:"duration_ms"`
}
// HealthResponse is the body of GET /healthz.
type HealthResponse struct {
Status string `json:"status"`
Docker string `json:"docker"`
}
// VersionResponse is the body of GET /version.
type VersionResponse struct {
Version string `json:"version"`
Commit string `json:"commit"`
BuildTime string `json:"build_time"`
}
+52
View File
@@ -0,0 +1,52 @@
// Package config loads service configuration from environment variables.
package config
import (
"errors"
"fmt"
"os"
"time"
)
// Config holds all runtime configuration for the service.
type Config struct {
APIKey string
StacksRoot string
Port string
LogLevel string
UpdateTimeout time.Duration
OptInLabel string
}
// Load reads configuration from environment variables, applies defaults,
// and validates required fields. Returns an error if validation fails so
// the service can fail-fast at startup.
func Load() (*Config, error) {
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"),
}
if cfg.APIKey == "" {
return nil, errors.New("UPDATER_API_KEY is required")
}
timeoutStr := getenvDefault("UPDATE_TIMEOUT", "5m")
d, err := time.ParseDuration(timeoutStr)
if err != nil {
return nil, fmt.Errorf("UPDATE_TIMEOUT %q is not a valid duration: %w", timeoutStr, err)
}
cfg.UpdateTimeout = d
return cfg, nil
}
func getenvDefault(key, fallback string) string {
if v := os.Getenv(key); v != "" {
return v
}
return fallback
}
+59
View File
@@ -0,0 +1,59 @@
package config_test
import (
"testing"
"time"
"github.com/shcizo/package-updater/internal/config"
"github.com/stretchr/testify/require"
)
func TestLoad_RequiresAPIKey(t *testing.T) {
t.Setenv("UPDATER_API_KEY", "")
_, err := config.Load()
require.Error(t, err)
require.Contains(t, err.Error(), "UPDATER_API_KEY")
}
func TestLoad_AppliesDefaults(t *testing.T) {
t.Setenv("UPDATER_API_KEY", "secret")
t.Setenv("STACKS_ROOT", "")
t.Setenv("PORT", "")
t.Setenv("LOG_LEVEL", "")
t.Setenv("UPDATE_TIMEOUT", "")
t.Setenv("OPT_IN_LABEL", "")
cfg, err := config.Load()
require.NoError(t, err)
require.Equal(t, "secret", cfg.APIKey)
require.Equal(t, "/home/shcizo/self-hosted", cfg.StacksRoot)
require.Equal(t, "8080", cfg.Port)
require.Equal(t, "info", cfg.LogLevel)
require.Equal(t, 5*time.Minute, cfg.UpdateTimeout)
require.Equal(t, "se.shcizo.auto-update", cfg.OptInLabel)
}
func TestLoad_OverridesViaEnv(t *testing.T) {
t.Setenv("UPDATER_API_KEY", "secret")
t.Setenv("STACKS_ROOT", "/srv/stacks")
t.Setenv("PORT", "9090")
t.Setenv("LOG_LEVEL", "debug")
t.Setenv("UPDATE_TIMEOUT", "30s")
t.Setenv("OPT_IN_LABEL", "io.example.update")
cfg, err := config.Load()
require.NoError(t, err)
require.Equal(t, "/srv/stacks", cfg.StacksRoot)
require.Equal(t, "9090", cfg.Port)
require.Equal(t, "debug", cfg.LogLevel)
require.Equal(t, 30*time.Second, cfg.UpdateTimeout)
require.Equal(t, "io.example.update", cfg.OptInLabel)
}
func TestLoad_InvalidTimeoutErrors(t *testing.T) {
t.Setenv("UPDATER_API_KEY", "secret")
t.Setenv("UPDATE_TIMEOUT", "not-a-duration")
_, err := config.Load()
require.Error(t, err)
require.Contains(t, err.Error(), "UPDATE_TIMEOUT")
}
+89
View File
@@ -0,0 +1,89 @@
package discovery
import (
"context"
"fmt"
"sort"
"strings"
"github.com/docker/docker/api/types/container"
)
// Job describes a single (project, service, config_files) update to execute.
type Job struct {
Project string
Service string
WorkingDir string
ConfigFiles []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
}
// Discovery orchestrates "given an image, what jobs should we enqueue?".
type Discovery struct {
cli DockerClient
stacksRoot string
optInLabel string
}
// New returns a Discovery bound to the given Docker client and config.
func New(cli DockerClient, stacksRoot, optInLabel string) *Discovery {
return &Discovery{cli: cli, stacksRoot: stacksRoot, optInLabel: optInLabel}
}
// FindJobs lists running containers, filters by image match + opt-in label,
// extracts Compose info, applies the path safety check, and deduplicates.
func (d *Discovery) FindJobs(ctx context.Context, image string) ([]Job, error) {
all, err := d.cli.ContainerList(ctx, container.ListOptions{All: true})
if err != nil {
return nil, fmt.Errorf("docker container list: %w", err)
}
seen := make(map[string]struct{})
var jobs []Job
for _, c := range all {
if !ImagesMatch(image, c.Image) {
continue
}
if !HasOptIn(c.Labels, d.optInLabel) {
continue
}
cl, err := ParseComposeLabels(c.Labels)
if err != nil {
continue
}
refused := !IsInsideRoot(d.stacksRoot, cl.WorkingDir)
reason := ""
if refused {
reason = fmt.Sprintf("working_dir %q outside STACKS_ROOT %q", cl.WorkingDir, d.stacksRoot)
}
key := dedupKey(cl)
if _, dup := seen[key]; dup {
continue
}
seen[key] = struct{}{}
jobs = append(jobs, Job{
Project: cl.Project,
Service: cl.Service,
WorkingDir: cl.WorkingDir,
ConfigFiles: cl.ConfigFiles,
Refused: refused,
RefusedReason: reason,
})
}
return jobs, nil
}
func dedupKey(cl ComposeLabels) string {
files := append([]string(nil), cl.ConfigFiles...)
sort.Strings(files)
return cl.Project + "|" + cl.Service + "|" + strings.Join(files, ",")
}
+155
View File
@@ -0,0 +1,155 @@
package discovery_test
import (
"context"
"errors"
"testing"
"github.com/docker/docker/api/types"
"github.com/docker/docker/api/types/container"
"github.com/shcizo/package-updater/internal/discovery"
"github.com/stretchr/testify/require"
)
type fakeDockerClient struct {
containers []types.Container
err error
}
func (f *fakeDockerClient) ContainerList(_ context.Context, _ container.ListOptions) ([]types.Container, error) {
return f.containers, f.err
}
func (f *fakeDockerClient) Ping(_ context.Context) (types.Ping, error) {
return types.Ping{}, nil
}
func mkContainer(image string, labels map[string]string) types.Container {
return types.Container{Image: image, Labels: labels}
}
func mkComposeLabels(project, service, workingDir, configFile string, optIn bool) map[string]string {
m := map[string]string{
"com.docker.compose.project": project,
"com.docker.compose.service": service,
"com.docker.compose.project.working_dir": workingDir,
"com.docker.compose.project.config_files": configFile,
}
if optIn {
m["se.shcizo.auto-update"] = "true"
}
return m
}
func TestFindJobs_MatchAndOptIn(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,
)),
mkContainer("registry.example.com/other:v1", mkComposeLabels(
"other", "web",
"/home/shcizo/self-hosted/other",
"/home/shcizo/self-hosted/other/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, "myapp-prod", jobs[0].Project)
require.Equal(t, "web", jobs[0].Service)
require.Equal(t, "/home/shcizo/self-hosted/myapp-prod", jobs[0].WorkingDir)
}
func TestFindJobs_SkipsWithoutOptIn(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",
false,
)),
}}
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.Empty(t, jobs)
}
func TestFindJobs_DedupReplicas(t *testing.T) {
labels := mkComposeLabels(
"myapp", "web",
"/home/shcizo/self-hosted/myapp",
"/home/shcizo/self-hosted/myapp/docker-compose.yml",
true,
)
fake := &fakeDockerClient{containers: []types.Container{
mkContainer("registry.example.com/myapp:v1", labels),
mkContainer("registry.example.com/myapp:v1", labels),
mkContainer("registry.example.com/myapp:v1", labels),
}}
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)
}
func TestFindJobs_OutsideRootProducesRefusedJob(t *testing.T) {
fake := &fakeDockerClient{containers: []types.Container{
mkContainer("registry.example.com/myapp:v1", mkComposeLabels(
"myapp", "web",
"/opt/elsewhere/myapp",
"/opt/elsewhere/myapp/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.True(t, jobs[0].Refused)
}
func TestFindJobs_MultipleStacksSameImage(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,
)),
mkContainer("registry.example.com/myapp:v1", mkComposeLabels(
"myapp-staging", "web",
"/home/shcizo/self-hosted/myapp-staging",
"/home/shcizo/self-hosted/myapp-staging/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, 2)
}
func TestFindJobs_DockerError(t *testing.T) {
fake := &fakeDockerClient{err: errors.New("connection refused")}
d := discovery.New(fake, "/home/shcizo/self-hosted", "se.shcizo.auto-update")
_, err := d.FindJobs(context.Background(), "registry.example.com/myapp")
require.Error(t, err)
}
func TestFindJobs_NonComposeContainerIsSkipped(t *testing.T) {
fake := &fakeDockerClient{containers: []types.Container{
mkContainer("registry.example.com/myapp:v1", map[string]string{
"se.shcizo.auto-update": "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.Empty(t, jobs)
}
+15
View File
@@ -0,0 +1,15 @@
package discovery
import (
"context"
"github.com/docker/docker/api/types"
"github.com/docker/docker/api/types/container"
)
// DockerClient is the subset of the Docker SDK we depend on.
// Defined as an interface so tests can supply a fake.
type DockerClient interface {
ContainerList(ctx context.Context, opts container.ListOptions) ([]types.Container, error)
Ping(ctx context.Context) (types.Ping, error)
}
+71
View File
@@ -0,0 +1,71 @@
package discovery
import (
"fmt"
"strings"
)
// ComposeLabels captures the four Compose-managed labels we need
// to drive a `docker compose pull`/`up -d` against the right stack.
type ComposeLabels struct {
Project string
Service string
WorkingDir string
ConfigFiles []string
}
const (
labelProject = "com.docker.compose.project"
labelService = "com.docker.compose.service"
labelWorkingDir = "com.docker.compose.project.working_dir"
labelConfigFiles = "com.docker.compose.project.config_files"
)
// ParseComposeLabels extracts the Compose labels we need. Returns an
// error naming the missing label if any required field is absent —
// in practice this should only happen if the container was not
// started by Compose.
func ParseComposeLabels(labels map[string]string) (ComposeLabels, error) {
get := func(key string) (string, error) {
v, ok := labels[key]
if !ok || v == "" {
return "", fmt.Errorf("missing required label: %s", key)
}
return v, nil
}
project, err := get(labelProject)
if err != nil {
return ComposeLabels{}, err
}
service, err := get(labelService)
if err != nil {
return ComposeLabels{}, err
}
workingDir, err := get(labelWorkingDir)
if err != nil {
return ComposeLabels{}, err
}
configFilesRaw, err := get(labelConfigFiles)
if err != nil {
return ComposeLabels{}, err
}
files := strings.Split(configFilesRaw, ",")
for i, f := range files {
files[i] = strings.TrimSpace(f)
}
return ComposeLabels{
Project: project,
Service: service,
WorkingDir: workingDir,
ConfigFiles: files,
}, nil
}
// HasOptIn reports whether the labels include the opt-in marker with
// value "true" (exact, case-sensitive — anything else is excluded).
func HasOptIn(labels map[string]string, key string) bool {
return labels[key] == "true"
}
+88
View File
@@ -0,0 +1,88 @@
package discovery_test
import (
"testing"
"github.com/shcizo/package-updater/internal/discovery"
"github.com/stretchr/testify/require"
)
func TestParseComposeLabels_Success(t *testing.T) {
labels := map[string]string{
"com.docker.compose.project": "myapp-prod",
"com.docker.compose.service": "web",
"com.docker.compose.project.working_dir": "/home/shcizo/self-hosted/myapp-prod",
"com.docker.compose.project.config_files": "/home/shcizo/self-hosted/myapp-prod/docker-compose.yml",
"se.shcizo.auto-update": "true",
}
got, err := discovery.ParseComposeLabels(labels)
require.NoError(t, err)
require.Equal(t, "myapp-prod", got.Project)
require.Equal(t, "web", got.Service)
require.Equal(t, "/home/shcizo/self-hosted/myapp-prod", got.WorkingDir)
require.Equal(t, []string{"/home/shcizo/self-hosted/myapp-prod/docker-compose.yml"}, got.ConfigFiles)
}
func TestParseComposeLabels_MultipleConfigFiles(t *testing.T) {
labels := map[string]string{
"com.docker.compose.project": "myapp",
"com.docker.compose.service": "web",
"com.docker.compose.project.working_dir": "/srv/myapp",
"com.docker.compose.project.config_files": "/srv/myapp/docker-compose.yml,/srv/myapp/docker-compose.prod.yml",
}
got, err := discovery.ParseComposeLabels(labels)
require.NoError(t, err)
require.Equal(t, []string{
"/srv/myapp/docker-compose.yml",
"/srv/myapp/docker-compose.prod.yml",
}, got.ConfigFiles)
}
func TestParseComposeLabels_MissingFieldsError(t *testing.T) {
cases := []struct {
name string
missing string
labels map[string]string
}{
{"project", "com.docker.compose.project", map[string]string{
"com.docker.compose.service": "web",
"com.docker.compose.project.working_dir": "/x",
"com.docker.compose.project.config_files": "/x/y.yml",
}},
{"service", "com.docker.compose.service", map[string]string{
"com.docker.compose.project": "p",
"com.docker.compose.project.working_dir": "/x",
"com.docker.compose.project.config_files": "/x/y.yml",
}},
{"working_dir", "com.docker.compose.project.working_dir", map[string]string{
"com.docker.compose.project": "p",
"com.docker.compose.service": "web",
"com.docker.compose.project.config_files": "/x/y.yml",
}},
{"config_files", "com.docker.compose.project.config_files", map[string]string{
"com.docker.compose.project": "p",
"com.docker.compose.service": "web",
"com.docker.compose.project.working_dir": "/x",
}},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
_, err := discovery.ParseComposeLabels(c.labels)
require.Error(t, err)
require.Contains(t, err.Error(), c.missing)
})
}
}
func TestHasOptIn(t *testing.T) {
require.True(t, discovery.HasOptIn(map[string]string{
"se.shcizo.auto-update": "true",
}, "se.shcizo.auto-update"))
require.False(t, discovery.HasOptIn(map[string]string{
"se.shcizo.auto-update": "false",
}, "se.shcizo.auto-update"))
require.False(t, discovery.HasOptIn(map[string]string{
"se.shcizo.auto-update": "TRUE",
}, "se.shcizo.auto-update"))
require.False(t, discovery.HasOptIn(map[string]string{}, "se.shcizo.auto-update"))
}
+33
View File
@@ -0,0 +1,33 @@
package discovery
import "strings"
// NormaliseImage strips the tag and digest from an image reference,
// returning the bare repository name.
//
// Tricky case: "localhost:5000/foo:v1" — the first colon is a port,
// not a tag. We disambiguate by splitting on "/" first and only
// treating colons in the last segment as tag separators.
func NormaliseImage(ref string) string {
if at := strings.Index(ref, "@"); at >= 0 {
ref = ref[:at]
}
slash := strings.LastIndex(ref, "/")
if slash < 0 {
if colon := strings.Index(ref, ":"); colon >= 0 {
return ref[:colon]
}
return ref
}
prefix, last := ref[:slash], ref[slash+1:]
if colon := strings.Index(last, ":"); colon >= 0 {
last = last[:colon]
}
return prefix + "/" + last
}
// ImagesMatch reports whether two image references resolve to the same
// repository, ignoring tag and digest. Case-sensitive per spec section 5.2.
func ImagesMatch(a, b string) bool {
return NormaliseImage(a) == NormaliseImage(b)
}
+51
View File
@@ -0,0 +1,51 @@
package discovery_test
import (
"testing"
"github.com/shcizo/package-updater/internal/discovery"
"github.com/stretchr/testify/require"
)
func TestNormaliseImage(t *testing.T) {
cases := []struct {
in string
want string
}{
{"registry.example.com/myapp", "registry.example.com/myapp"},
{"registry.example.com/myapp:v1.2.3", "registry.example.com/myapp"},
{"registry.example.com/myapp:latest", "registry.example.com/myapp"},
{"registry.example.com/myapp@sha256:abc123", "registry.example.com/myapp"},
{"registry.example.com/myapp:v1.2.3@sha256:abc123", "registry.example.com/myapp"},
{"nginx", "nginx"},
{"nginx:1.25-alpine", "nginx"},
{"library/nginx:latest", "library/nginx"},
{"gcr.io/proj/svc:tag", "gcr.io/proj/svc"},
{"localhost:5000/myimg:v1", "localhost:5000/myimg"},
}
for _, c := range cases {
t.Run(c.in, func(t *testing.T) {
require.Equal(t, c.want, discovery.NormaliseImage(c.in))
})
}
}
func TestImagesMatch(t *testing.T) {
require.True(t, discovery.ImagesMatch(
"registry.example.com/myapp",
"registry.example.com/myapp:v1.2.3",
))
require.True(t, discovery.ImagesMatch(
"registry.example.com/myapp:v1.0.0",
"registry.example.com/myapp:v9.9.9",
))
require.False(t, discovery.ImagesMatch(
"registry.example.com/myapp",
"registry.example.com/otherapp",
))
// Case-sensitive per spec section 5.2
require.False(t, discovery.ImagesMatch(
"registry.example.com/MyApp",
"registry.example.com/myapp",
))
}
+23
View File
@@ -0,0 +1,23 @@
package discovery
import (
"path/filepath"
"strings"
)
// IsInsideRoot reports whether path is the same as, or nested inside,
// root. Both are cleaned before comparison so trailing slashes, "."
// segments, and ".." escapes are handled. Prefix tricks like
// "/foo" vs "/foo-evil" are NOT considered inside.
func IsInsideRoot(root, path string) bool {
r := filepath.Clean(root)
p := filepath.Clean(path)
if p == r {
return true
}
rel, err := filepath.Rel(r, p)
if err != nil {
return false
}
return !strings.HasPrefix(rel, "..")
}
+32
View File
@@ -0,0 +1,32 @@
package discovery_test
import (
"testing"
"github.com/shcizo/package-updater/internal/discovery"
"github.com/stretchr/testify/require"
)
func TestIsInsideRoot(t *testing.T) {
cases := []struct {
name string
root string
path string
want bool
}{
{"direct child", "/home/shcizo/self-hosted", "/home/shcizo/self-hosted/myapp", true},
{"nested", "/home/shcizo/self-hosted", "/home/shcizo/self-hosted/a/b/c", true},
{"root itself", "/home/shcizo/self-hosted", "/home/shcizo/self-hosted", true},
{"sibling", "/home/shcizo/self-hosted", "/home/shcizo/other", false},
{"parent", "/home/shcizo/self-hosted", "/home/shcizo", false},
{"unrelated", "/home/shcizo/self-hosted", "/etc/passwd", false},
{"prefix-trick", "/home/shcizo/self-hosted", "/home/shcizo/self-hosted-evil", false},
{"dotdot escape", "/home/shcizo/self-hosted", "/home/shcizo/self-hosted/../etc", false},
{"trailing slash root", "/home/shcizo/self-hosted/", "/home/shcizo/self-hosted/x", true},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
require.Equal(t, c.want, discovery.IsInsideRoot(c.root, c.path))
})
}
}
+63
View File
@@ -0,0 +1,63 @@
// Package logging configures the structured JSON logger used across the service.
package logging
import (
"context"
"log/slog"
"os"
)
type ctxKey int
const requestIDKey ctxKey = iota
// New returns a slog.Logger that writes JSON to stdout at the given level.
// Valid levels: "debug", "info", "warn", "error". Unknown levels default to info.
func New(level string) *slog.Logger {
var lvl slog.Level
switch level {
case "debug":
lvl = slog.LevelDebug
case "warn":
lvl = slog.LevelWarn
case "error":
lvl = slog.LevelError
default:
lvl = slog.LevelInfo
}
handler := slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
Level: lvl,
ReplaceAttr: func(_ []string, a slog.Attr) slog.Attr {
if a.Key == slog.TimeKey {
return slog.Attr{Key: "time", Value: a.Value}
}
if a.Key == slog.MessageKey {
return slog.Attr{Key: "event", Value: a.Value}
}
return a
},
})
return slog.New(handler)
}
// WithRequestID stores the request ID in the context for downstream loggers.
func WithRequestID(ctx context.Context, id string) context.Context {
return context.WithValue(ctx, requestIDKey, id)
}
// RequestIDFrom returns the request ID stored in ctx, or empty string if absent.
func RequestIDFrom(ctx context.Context) string {
if v, ok := ctx.Value(requestIDKey).(string); ok {
return v
}
return ""
}
// FromContext returns a logger pre-bound with the request_id from ctx (if any).
func FromContext(ctx context.Context, base *slog.Logger) *slog.Logger {
if id := RequestIDFrom(ctx); id != "" {
return base.With("request_id", id)
}
return base
}
+69
View File
@@ -0,0 +1,69 @@
// Package metrics defines and registers the Prometheus collectors
// exported on /metrics.
package metrics
import (
"net/http"
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promhttp"
)
// Metrics bundles all collectors. Each field is the public handle the
// rest of the service uses to record observations.
type Metrics struct {
BuildInfo *prometheus.GaugeVec
HTTPRequests *prometheus.CounterVec
UpdateJobs *prometheus.CounterVec
UpdateDuration *prometheus.HistogramVec
QueueDepth prometheus.Gauge
LastUpdateTime *prometheus.GaugeVec
DockerPingUp prometheus.Gauge
}
// New constructs Metrics and registers them with the given registry.
// Use prometheus.NewRegistry() in tests so collectors don't leak between
// runs; production code uses prometheus.DefaultRegisterer.
func New(reg prometheus.Registerer) *Metrics {
m := &Metrics{
BuildInfo: prometheus.NewGaugeVec(prometheus.GaugeOpts{
Name: "package_updater_build_info",
Help: "Always 1. Labels carry version/commit for dashboards.",
}, []string{"version", "commit"}),
HTTPRequests: prometheus.NewCounterVec(prometheus.CounterOpts{
Name: "package_updater_http_requests_total",
Help: "HTTP requests handled, labelled by endpoint and status.",
}, []string{"endpoint", "status_code"}),
UpdateJobs: prometheus.NewCounterVec(prometheus.CounterOpts{
Name: "package_updater_update_jobs_total",
Help: "Update jobs executed, labelled by project/service/status.",
}, []string{"project", "service", "status"}),
UpdateDuration: prometheus.NewHistogramVec(prometheus.HistogramOpts{
Name: "package_updater_update_duration_seconds",
Help: "Time taken to pull + up a single service.",
Buckets: []float64{0.5, 1, 2, 5, 10, 30, 60, 120, 300},
}, []string{"project", "service"}),
QueueDepth: prometheus.NewGauge(prometheus.GaugeOpts{
Name: "package_updater_queue_depth",
Help: "Current number of submissions waiting in the queue.",
}),
LastUpdateTime: prometheus.NewGaugeVec(prometheus.GaugeOpts{
Name: "package_updater_last_update_timestamp",
Help: "Unix timestamp of the most recent successful update per service.",
}, []string{"project", "service"}),
DockerPingUp: prometheus.NewGauge(prometheus.GaugeOpts{
Name: "package_updater_docker_ping_up",
Help: "1 if the Docker socket responded to ping, 0 otherwise.",
}),
}
reg.MustRegister(
m.BuildInfo, m.HTTPRequests, m.UpdateJobs, m.UpdateDuration,
m.QueueDepth, m.LastUpdateTime, m.DockerPingUp,
)
return m
}
// Handler returns the /metrics HTTP handler bound to the given registry.
func Handler(gatherer prometheus.Gatherer) http.Handler {
return promhttp.HandlerFor(gatherer, promhttp.HandlerOpts{})
}
+56
View File
@@ -0,0 +1,56 @@
// Package selfupdate handles the special case where the update target
// is the running service's own container. We must finish writing the
// HTTP response (and flush + close the connection) before exec'ing
// `docker compose up -d` against ourselves, otherwise the response is
// lost when the container is replaced.
package selfupdate
import (
"context"
"time"
"github.com/shcizo/package-updater/internal/discovery"
)
// IsSelf reports whether job targets the running service.
// Matches on Compose project name (which is also typically the
// service name for single-service stacks).
func IsSelf(job discovery.Job, selfProject string) bool {
return job.Project == selfProject
}
// innerExec is the executor abstraction we wrap.
type innerExec interface {
Execute(ctx context.Context, job discovery.Job) error
}
// Wrapped wraps an Executor with self-update-aware deferred execution.
type Wrapped struct {
inner innerExec
selfProject string
delay time.Duration
}
// Wrap returns a Wrapped that defers exec until after flush() for
// self-updates. The delay is added after flush before exec, so the
// kernel TCP buffer has time to drain.
func Wrap(inner innerExec, selfProject string, delay time.Duration) *Wrapped {
return &Wrapped{inner: inner, selfProject: selfProject, delay: delay}
}
// ExecuteWithFlush runs job, invoking flush() before exec for self-updates
// and waiting `delay` after flush. For non-self jobs, exec happens
// normally and flush is not invoked at all (the HTTP layer decides
// when to flush in that case).
func (w *Wrapped) ExecuteWithFlush(ctx context.Context, job discovery.Job, flush func()) error {
if !IsSelf(job, w.selfProject) {
return w.inner.Execute(ctx, job)
}
flush()
select {
case <-time.After(w.delay):
case <-ctx.Done():
return ctx.Err()
}
return w.inner.Execute(ctx, job)
}
+87
View File
@@ -0,0 +1,87 @@
package selfupdate_test
import (
"context"
"errors"
"sync"
"testing"
"time"
"github.com/shcizo/package-updater/internal/discovery"
"github.com/shcizo/package-updater/internal/selfupdate"
"github.com/stretchr/testify/require"
)
type recExec struct {
mu sync.Mutex
called bool
at time.Time
}
func (r *recExec) Execute(_ context.Context, _ discovery.Job) error {
r.mu.Lock()
r.called = true
r.at = time.Now()
r.mu.Unlock()
return nil
}
func TestIsSelf(t *testing.T) {
require.True(t, selfupdate.IsSelf(
discovery.Job{Project: "package-updater", Service: "package-updater"},
"package-updater",
))
require.False(t, selfupdate.IsSelf(
discovery.Job{Project: "other", Service: "web"},
"package-updater",
))
}
func TestWrap_DefersSelf(t *testing.T) {
inner := &recExec{}
wrapped := selfupdate.Wrap(inner, "package-updater", 30*time.Millisecond)
flushed := make(chan time.Time, 1)
flush := func() { flushed <- time.Now() }
job := discovery.Job{Project: "package-updater", Service: "package-updater"}
require.NoError(t, wrapped.ExecuteWithFlush(context.Background(), job, flush))
flushAt := <-flushed
inner.mu.Lock()
require.True(t, inner.called)
require.True(t, inner.at.After(flushAt))
inner.mu.Unlock()
}
func TestWrap_NormalJobIsImmediate(t *testing.T) {
inner := &recExec{}
wrapped := selfupdate.Wrap(inner, "package-updater", 30*time.Millisecond)
flushed := make(chan time.Time, 1)
flush := func() { flushed <- time.Now() }
job := discovery.Job{Project: "other", Service: "web"}
require.NoError(t, wrapped.ExecuteWithFlush(context.Background(), job, flush))
inner.mu.Lock()
require.True(t, inner.called)
inner.mu.Unlock()
select {
case <-flushed:
t.Fatal("flush should not be called for non-self jobs")
default:
}
}
func TestWrap_PropagatesError(t *testing.T) {
wrapped := selfupdate.Wrap(failExec{}, "x", 1*time.Millisecond)
err := wrapped.ExecuteWithFlush(context.Background(), discovery.Job{Project: "other"}, func() {})
require.Error(t, err)
}
type failExec struct{}
func (failExec) Execute(_ context.Context, _ discovery.Job) error {
return errors.New("boom")
}
+54
View File
@@ -0,0 +1,54 @@
package updater
import (
"context"
"fmt"
"os/exec"
"strings"
"github.com/shcizo/package-updater/internal/discovery"
)
// ComposeExecutor invokes `docker compose` as a subprocess.
type ComposeExecutor struct{}
// NewComposeExecutor returns an executor that shells out to docker compose.
func NewComposeExecutor() *ComposeExecutor {
return &ComposeExecutor{}
}
// Execute runs `docker compose -f <file>... -p <project> pull <service>`
// followed by `... up -d <service>`. Working directory is set to
// job.WorkingDir so any relative paths in the compose file resolve correctly.
func (e *ComposeExecutor) Execute(ctx context.Context, job discovery.Job) error {
if job.Refused {
return fmt.Errorf("refused: %s", job.RefusedReason)
}
if err := e.run(ctx, job, "pull"); err != nil {
return fmt.Errorf("pull: %w", err)
}
if err := e.run(ctx, job, "up", "-d"); err != nil {
return fmt.Errorf("up: %w", err)
}
return nil
}
func (e *ComposeExecutor) run(ctx context.Context, job discovery.Job, args ...string) error {
cliArgs := []string{"compose"}
for _, f := range job.ConfigFiles {
cliArgs = append(cliArgs, "-f", f)
}
cliArgs = append(cliArgs, "-p", job.Project)
cliArgs = append(cliArgs, args...)
cliArgs = append(cliArgs, job.Service)
cmd := exec.CommandContext(ctx, "docker", cliArgs...)
cmd.Dir = job.WorkingDir
out, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("docker %s: %w (output: %s)",
strings.Join(args, " "), err, strings.TrimSpace(string(out)))
}
return nil
}
+15
View File
@@ -0,0 +1,15 @@
// Package updater contains the FIFO job queue, the single worker that
// drains it, and the abstraction over `docker compose` invocation.
package updater
import (
"context"
"github.com/shcizo/package-updater/internal/discovery"
)
// Executor runs `docker compose pull` then `up -d` for a single job.
// Defined as an interface so the worker can be tested with a fake.
type Executor interface {
Execute(ctx context.Context, job discovery.Job) error
}
+144
View File
@@ -0,0 +1,144 @@
package updater
import (
"context"
"time"
"github.com/shcizo/package-updater/internal/discovery"
"github.com/shcizo/package-updater/internal/metrics"
)
// Status describes the outcome of executing a single Job.
type Status string
const (
StatusUpdated Status = "updated"
StatusFailed Status = "failed"
StatusRefused Status = "refused"
StatusTimeout Status = "timeout"
)
// Result captures a per-Job outcome to be surfaced in the HTTP response.
type Result struct {
Job discovery.Job
Status Status
Error string
DurationMs int64
}
// Queue serialises Job execution through a single worker so we never
// run two `docker compose` commands against the same stack concurrently.
// It also satisfies the "single global FIFO worker" design choice in
// spec section 5.7.
type Queue struct {
exec Executor
metrics *metrics.Metrics
ch chan submission
stop chan struct{}
done chan struct{}
}
type submission struct {
ctx context.Context
jobs []discovery.Job
results chan []Result
}
// NewQueue constructs a queue bound to the given executor. m may be nil,
// in which case metrics recording is silently skipped.
func NewQueue(exec Executor, m *metrics.Metrics) *Queue {
return &Queue{
exec: exec,
metrics: m,
ch: make(chan submission, 16),
stop: make(chan struct{}),
done: make(chan struct{}),
}
}
// Start launches the single background worker. Call Stop to terminate.
func (q *Queue) Start(ctx context.Context) {
go q.run(ctx)
}
// Stop signals the worker to exit and waits for it to finish.
func (q *Queue) Stop() {
close(q.stop)
<-q.done
}
// Submit blocks until all jobs in the batch have been processed by the
// worker, then returns per-job results in the same order. The ctx
// timeout (if any) applies to each individual Job's execution and is
// surfaced as StatusTimeout.
func (q *Queue) Submit(ctx context.Context, jobs []discovery.Job) []Result {
resCh := make(chan []Result, 1)
q.ch <- submission{ctx: ctx, jobs: jobs, results: resCh}
if q.metrics != nil {
q.metrics.QueueDepth.Set(float64(len(q.ch)))
}
return <-resCh
}
func (q *Queue) run(_ context.Context) {
defer close(q.done)
for {
select {
case <-q.stop:
return
case s := <-q.ch:
results := make([]Result, len(s.jobs))
for i, j := range s.jobs {
results[i] = q.runOne(s.ctx, j)
}
s.results <- results
}
}
}
func (q *Queue) runOne(ctx context.Context, job discovery.Job) Result {
start := time.Now()
r := Result{Job: job}
if job.Refused {
r.Status = StatusRefused
r.Error = job.RefusedReason
r.DurationMs = time.Since(start).Milliseconds()
if q.metrics != nil {
q.metrics.UpdateJobs.WithLabelValues(job.Project, job.Service, string(StatusRefused)).Inc()
q.metrics.UpdateDuration.WithLabelValues(job.Project, job.Service).Observe(time.Since(start).Seconds())
}
return r
}
err := q.exec.Execute(ctx, job)
r.DurationMs = time.Since(start).Milliseconds()
switch {
case err == nil:
r.Status = StatusUpdated
case errorsIsContextDeadline(err) || ctxDeadlineExceeded(ctx):
r.Status = StatusTimeout
r.Error = err.Error()
default:
r.Status = StatusFailed
r.Error = err.Error()
}
if q.metrics != nil {
elapsed := time.Since(start).Seconds()
q.metrics.UpdateJobs.WithLabelValues(job.Project, job.Service, string(r.Status)).Inc()
q.metrics.UpdateDuration.WithLabelValues(job.Project, job.Service).Observe(elapsed)
if r.Status == StatusUpdated {
q.metrics.LastUpdateTime.WithLabelValues(job.Project, job.Service).SetToCurrentTime()
}
}
return r
}
func errorsIsContextDeadline(err error) bool {
return err == context.DeadlineExceeded
}
func ctxDeadlineExceeded(ctx context.Context) bool {
return ctx.Err() == context.DeadlineExceeded
}
+129
View File
@@ -0,0 +1,129 @@
package updater_test
import (
"context"
"errors"
"sync"
"testing"
"time"
dto "github.com/prometheus/client_model/go"
"github.com/prometheus/client_golang/prometheus"
"github.com/shcizo/package-updater/internal/discovery"
"github.com/shcizo/package-updater/internal/metrics"
"github.com/shcizo/package-updater/internal/updater"
"github.com/stretchr/testify/require"
)
type fakeExec struct {
mu sync.Mutex
calls []discovery.Job
delay time.Duration
errForSvc map[string]error
}
func (f *fakeExec) Execute(ctx context.Context, job discovery.Job) error {
f.mu.Lock()
f.calls = append(f.calls, job)
f.mu.Unlock()
if f.delay > 0 {
select {
case <-time.After(f.delay):
case <-ctx.Done():
return ctx.Err()
}
}
if err, ok := f.errForSvc[job.Service]; ok {
return err
}
return nil
}
func (f *fakeExec) callsCopy() []discovery.Job {
f.mu.Lock()
defer f.mu.Unlock()
return append([]discovery.Job(nil), f.calls...)
}
func TestQueue_ProcessesFIFO(t *testing.T) {
exec := &fakeExec{delay: 20 * time.Millisecond}
q := updater.NewQueue(exec, nil)
q.Start(context.Background())
defer q.Stop()
job1 := discovery.Job{Project: "a", Service: "svc"}
job2 := discovery.Job{Project: "b", Service: "svc"}
job3 := discovery.Job{Project: "c", Service: "svc"}
results := make(chan []updater.Result, 3)
go func() { results <- q.Submit(context.Background(), []discovery.Job{job1}) }()
time.Sleep(5 * time.Millisecond)
go func() { results <- q.Submit(context.Background(), []discovery.Job{job2}) }()
time.Sleep(5 * time.Millisecond)
go func() { results <- q.Submit(context.Background(), []discovery.Job{job3}) }()
for i := 0; i < 3; i++ {
<-results
}
calls := exec.callsCopy()
require.Equal(t, []string{"a", "b", "c"}, []string{calls[0].Project, calls[1].Project, calls[2].Project})
}
func TestQueue_ReturnsPerJobResults(t *testing.T) {
exec := &fakeExec{errForSvc: map[string]error{"failing": errors.New("boom")}}
q := updater.NewQueue(exec, nil)
q.Start(context.Background())
defer q.Stop()
jobs := []discovery.Job{
{Project: "p1", Service: "ok"},
{Project: "p2", Service: "failing"},
{Project: "p3", Service: "refused-svc", Refused: true, RefusedReason: "outside root"},
}
results := q.Submit(context.Background(), jobs)
require.Len(t, results, 3)
require.Equal(t, updater.StatusUpdated, results[0].Status)
require.Equal(t, updater.StatusFailed, results[1].Status)
require.Contains(t, results[1].Error, "boom")
require.Equal(t, updater.StatusRefused, results[2].Status)
require.Contains(t, results[2].Error, "outside root")
}
func TestQueue_Timeout(t *testing.T) {
exec := &fakeExec{delay: 200 * time.Millisecond}
q := updater.NewQueue(exec, nil)
q.Start(context.Background())
defer q.Stop()
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Millisecond)
defer cancel()
jobs := []discovery.Job{{Project: "slow", Service: "svc"}}
results := q.Submit(ctx, jobs)
require.Len(t, results, 1)
require.Equal(t, updater.StatusTimeout, results[0].Status)
}
func TestQueue_RecordsMetrics(t *testing.T) {
reg := prometheus.NewRegistry()
m := metrics.New(reg)
exec := &fakeExec{}
q := updater.NewQueue(exec, m)
q.Start(context.Background())
defer q.Stop()
q.Submit(context.Background(), []discovery.Job{{Project: "p", Service: "s"}})
// Verify UpdateJobs counter was incremented for the successful job.
metric := &dto.Metric{}
require.NoError(t, m.UpdateJobs.WithLabelValues("p", "s", "updated").Write(metric))
require.Equal(t, 1.0, metric.GetCounter().GetValue())
// Verify LastUpdateTime was set (non-zero).
tsMetric := &dto.Metric{}
require.NoError(t, m.LastUpdateTime.WithLabelValues("p", "s").Write(tsMetric))
require.Greater(t, tsMetric.GetGauge().GetValue(), 0.0)
}
+6
View File
@@ -0,0 +1,6 @@
package updater
// Worker logic currently lives in queue.go (single in-process worker
// goroutine). This file is reserved for the eventual per-stack-mutex
// implementation called out in spec section 15 ("Multi-worker concurrency
// with per-stack mutex"). Intentionally empty for v1.
+38
View File
@@ -0,0 +1,38 @@
package updater_test
import (
"context"
"sync/atomic"
"testing"
"time"
"github.com/shcizo/package-updater/internal/discovery"
"github.com/shcizo/package-updater/internal/updater"
"github.com/stretchr/testify/require"
)
type counterExec struct{ n atomic.Int32 }
func (c *counterExec) Execute(_ context.Context, _ discovery.Job) error {
c.n.Add(1)
time.Sleep(10 * time.Millisecond)
return nil
}
func TestWorker_RunsExactlyOneAtATime(t *testing.T) {
exec := &counterExec{}
q := updater.NewQueue(exec, nil)
q.Start(context.Background())
defer q.Stop()
jobs := make([]discovery.Job, 10)
for i := range jobs {
jobs[i] = discovery.Job{Project: "p", Service: "svc"}
}
start := time.Now()
q.Submit(context.Background(), jobs)
elapsed := time.Since(start)
require.GreaterOrEqual(t, elapsed, 90*time.Millisecond)
require.Equal(t, int32(10), exec.n.Load())
}