Files
package-updater/docs/superpowers/plans/2026-05-22-package-updater-implementation.md

85 KiB
Raw Permalink Blame History

package-updater Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Build the v1 of package-updater — a Go HTTP service that receives webhook calls from Gitea Actions, finds matching Compose-managed containers via Docker labels, and runs docker compose pull + up -d for opt-in services.

Architecture: Stateless Go service. HTTP layer → discovery (Docker socket + Compose labels) → single FIFO worker → executor (shells out to docker compose). Self-update deferred until HTTP response is flushed. Defense-in-depth via bearer token + opt-in label.

Tech Stack: Go 1.23, stdlib net/http + log/slog, github.com/docker/docker/client, github.com/prometheus/client_golang, github.com/google/uuid, github.com/stretchr/testify/require. Deployed as a container with docker.sock and the stacks-root mounted. Reverse-proxied by Nginx Proxy Manager for TLS.

Spec: docs/superpowers/specs/2026-05-22-package-updater-design.md


File Structure

package-updater/
├── cmd/
│   └── server/
│       └── main.go                          # Wire-up: config, logger, deps, http server
├── internal/
│   ├── api/
│   │   ├── handlers.go                      # /update, /healthz, /version handlers
│   │   ├── handlers_test.go
│   │   ├── middleware.go                    # auth, request_id, logging middleware
│   │   ├── middleware_test.go
│   │   └── types.go                         # Request/Response DTOs
│   ├── config/
│   │   ├── config.go                        # Env var loading + validation
│   │   └── config_test.go
│   ├── discovery/
│   │   ├── matching.go                      # Image name normalisation
│   │   ├── matching_test.go
│   │   ├── labels.go                        # Compose label extraction
│   │   ├── labels_test.go
│   │   ├── pathcheck.go                     # STACKS_ROOT path safety check
│   │   ├── pathcheck_test.go
│   │   ├── discovery.go                     # Orchestrates lookup → match → dedupe → jobs
│   │   ├── discovery_test.go
│   │   └── docker_client.go                 # DockerClient interface
│   ├── logging/
│   │   └── logger.go                        # slog JSON setup + request_id ctx helpers
│   ├── metrics/
│   │   └── metrics.go                       # Prometheus collectors
│   ├── selfupdate/
│   │   ├── selfupdate.go                    # Detect self-update + deferred-exec helper
│   │   └── selfupdate_test.go
│   └── updater/
│       ├── executor.go                      # Executor interface
│       ├── compose_executor.go              # Real impl: shells out to docker compose
│       ├── queue.go                         # FIFO job queue
│       ├── queue_test.go
│       ├── worker.go                        # Single worker that drains queue
│       └── worker_test.go
├── docs/
│   └── superpowers/
│       ├── specs/2026-05-22-package-updater-design.md   (already exists)
│       └── plans/2026-05-22-package-updater-implementation.md   (this file)
├── gitea-action/
│   └── action.yml                           # Reusable composite action
├── Dockerfile
├── docker-compose.example.yml               # Template for end-user deploy
├── .dockerignore
├── .gitignore
├── go.mod
├── go.sum
└── README.md

Each internal/ sub-package owns one concept. discovery is split across files by responsibility (matching, labels, pathcheck, orchestration, docker-client interface) because each piece is independently testable. The updater package separates the queue, the worker that consumes it, and the executor abstraction that the worker calls — this lets the worker be tested with a fake executor.


Task 1: Project scaffolding

Files:

  • Create: go.mod

  • Create: .gitignore

  • Create: cmd/server/main.go (stub)

  • Create: README.md (skeleton)

  • Step 1: Initialise the Go module

Run:

go mod init github.com/shcizo/package-updater

Expected: creates go.mod with module github.com/shcizo/package-updater and go 1.23 (or your installed version).

  • Step 2: Create .gitignore

Write .gitignore:

# Binaries
/bin/
*.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
  • Step 3: Create stub cmd/server/main.go

Write cmd/server/main.go:

package main

import "fmt"

func main() {
	fmt.Println("package-updater (scaffold)")
}
  • Step 4: Verify it builds

Run:

go build ./cmd/server
./server

Expected output: package-updater (scaffold). Then delete the binary: rm server.

  • Step 5: Create README.md skeleton

Write README.md:

# package-updater

Webhook-driven Docker Compose service updater. See [design spec](docs/superpowers/specs/2026-05-22-package-updater-design.md) for full design.

## Quick start

(TBD — filled in by final task)
  • Step 6: Commit
git add go.mod .gitignore cmd/ README.md
git commit -m "chore: scaffold Go project layout"

Task 2: Config loading (env vars + validation)

Files:

  • Create: internal/config/config.go

  • Create: internal/config/config_test.go

  • Step 1: Add testify dependency

Run:

go get github.com/stretchr/testify/require
  • Step 2: Write the failing test

Write internal/config/config_test.go:

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")
}
  • Step 3: Run test — verify it fails

Run:

go test ./internal/config/...

Expected: build failure ("no Go files" or "package config does not exist").

  • Step 4: Implement config.go

Write internal/config/config.go:

// 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
}
  • Step 5: Run tests — verify all pass

Run:

go test ./internal/config/... -v

Expected: 4 tests PASS.

  • Step 6: Commit
git add go.mod go.sum internal/config/
git commit -m "feat(config): load and validate env-var config"

Task 3: Structured JSON logger

Files:

  • Create: internal/logging/logger.go

  • Step 1: Implement the logger

Write internal/logging/logger.go:

// 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
}
  • Step 2: Verify it builds

Run:

go build ./internal/logging/...

Expected: no output (success).

  • Step 3: Commit
git add internal/logging/
git commit -m "feat(logging): structured JSON logger with request_id context"

Note: no unit tests for this file — it's thin glue over stdlib. It will be exercised end-to-end via the HTTP handler tests in later tasks.


Task 4: Image name normalisation

Files:

  • Create: internal/discovery/matching.go

  • Create: internal/discovery/matching_test.go

  • Step 1: Write the failing test

Write internal/discovery/matching_test.go:

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",
	))
}
  • Step 2: Run test — verify it fails

Run:

go test ./internal/discovery/...

Expected: build failure (undefined: discovery.NormaliseImage).

  • Step 3: Implement matching.go

Write internal/discovery/matching.go:

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)
}
  • Step 4: Run tests — verify all pass

Run:

go test ./internal/discovery/... -v -run 'TestNormaliseImage|TestImagesMatch'

Expected: all subtests PASS.

  • Step 5: Commit
git add internal/discovery/matching.go internal/discovery/matching_test.go
git commit -m "feat(discovery): tag-agnostic image name normalisation"

Task 5: Compose label extraction

Files:

  • Create: internal/discovery/labels.go

  • Create: internal/discovery/labels_test.go

  • Step 1: Write the failing test

Write internal/discovery/labels_test.go:

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"))
}
  • Step 2: Run test — verify it fails

Run:

go test ./internal/discovery/...

Expected: build failure (undefined: discovery.ParseComposeLabels).

  • Step 3: Implement labels.go

Write internal/discovery/labels.go:

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"
}
  • Step 4: Run tests — verify all pass

Run:

go test ./internal/discovery/... -v -run 'TestParseComposeLabels|TestHasOptIn'

Expected: all subtests PASS.

  • Step 5: Commit
git add internal/discovery/labels.go internal/discovery/labels_test.go
git commit -m "feat(discovery): parse Compose-managed labels"

Task 6: Path safety check

Files:

  • Create: internal/discovery/pathcheck.go

  • Create: internal/discovery/pathcheck_test.go

  • Step 1: Write the failing test

Write internal/discovery/pathcheck_test.go:

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))
		})
	}
}
  • Step 2: Run test — verify it fails

Run:

go test ./internal/discovery/... -run TestIsInsideRoot

Expected: build failure (undefined: discovery.IsInsideRoot).

  • Step 3: Implement pathcheck.go

Write internal/discovery/pathcheck.go:

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, "..")
}
  • Step 4: Run tests — verify all pass

Run:

go test ./internal/discovery/... -v -run TestIsInsideRoot

Expected: all subtests PASS.

  • Step 5: Commit
git add internal/discovery/pathcheck.go internal/discovery/pathcheck_test.go
git commit -m "feat(discovery): STACKS_ROOT path safety check"

Task 7: Discovery orchestration (find → match → dedup → jobs)

Files:

  • Create: internal/discovery/docker_client.go

  • Create: internal/discovery/discovery.go

  • Create: internal/discovery/discovery_test.go

  • Step 1: Add Docker SDK dependency

Run:

go get github.com/docker/docker/client github.com/docker/docker/api/types github.com/docker/docker/api/types/container
  • Step 2: Define the DockerClient interface

Write internal/discovery/docker_client.go:

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)
}
  • Step 3: Write the failing test

Write internal/discovery/discovery_test.go:

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, // no opt-in
		)),
	}}
	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",
			// no compose labels at all
		}),
	}}
	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)
}
  • Step 4: Run tests — verify they fail

Run:

go test ./internal/discovery/...

Expected: build failure (undefined: discovery.New etc).

  • Step 5: Implement discovery.go

Write internal/discovery/discovery.go:

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 {
			// Not started by Compose — skip silently (warning logged by caller).
			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, ",")
}
  • Step 6: Run tests — verify all pass

Run:

go test ./internal/discovery/... -v

Expected: all tests in the package PASS.

  • Step 7: Commit
git add go.mod go.sum internal/discovery/
git commit -m "feat(discovery): orchestrate match, opt-in filter, dedup, path-check"

Task 8: Executor interface + fake

Files:

  • Create: internal/updater/executor.go

  • Step 1: Define the interface

Write internal/updater/executor.go:

// 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
}
  • Step 2: Verify it builds

Run:

go build ./internal/updater/...

Expected: no output (success).

  • Step 3: Commit
git add internal/updater/executor.go
git commit -m "feat(updater): Executor interface"

Task 9: Real ComposeExecutor (shells out to docker compose)

Files:

  • Create: internal/updater/compose_executor.go

  • Step 1: Implement the real executor

Write internal/updater/compose_executor.go:

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
}
  • Step 2: Verify it builds

Run:

go build ./internal/updater/...

Expected: no output (success).

  • Step 3: Commit
git add internal/updater/compose_executor.go
git commit -m "feat(updater): ComposeExecutor shells out to docker compose"

Note: no unit tests — exec'ing real docker compose is an integration concern that's covered by the spec's "manual smoke test on first deploy" plan (spec section 14). The path-construction logic is exercised indirectly via the worker tests (Task 10) using a fake executor.


Task 10: Job queue + single worker

Files:

  • Create: internal/updater/queue.go

  • Create: internal/updater/queue_test.go

  • Create: internal/updater/worker.go

  • Create: internal/updater/worker_test.go

  • Step 1: Write the failing queue test

Write internal/updater/queue_test.go:

package updater_test

import (
	"context"
	"errors"
	"sync"
	"testing"
	"time"

	"github.com/shcizo/package-updater/internal/discovery"
	"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)
	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)
	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)
	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)
}
  • Step 2: Write the failing worker test

Write internal/updater/worker_test.go:

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

	// 10 jobs × 10ms each, single worker → at least 100ms total.
	require.GreaterOrEqual(t, elapsed, 90*time.Millisecond)
	require.Equal(t, int32(10), exec.n.Load())
}

Note: this test relies on Queue deduping at submit-time being OFF (dedup happens in discovery.FindJobs, not in the queue). The queue treats each Job as a unique unit of work.

  • Step 3: Run tests — verify they fail

Run:

go test ./internal/updater/...

Expected: build failure (undefined: updater.NewQueue).

  • Step 4: Implement queue.go

Write internal/updater/queue.go:

package updater

import (
	"context"
	"time"

	"github.com/shcizo/package-updater/internal/discovery"
)

// 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
	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.
func NewQueue(exec Executor) *Queue {
	return &Queue{
		exec: exec,
		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}
	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()
		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()
	}
	return r
}

func errorsIsContextDeadline(err error) bool {
	return err == context.DeadlineExceeded
}

func ctxDeadlineExceeded(ctx context.Context) bool {
	return ctx.Err() == context.DeadlineExceeded
}
  • Step 5: Implement worker.go

The worker is implemented inside queue.go (the run method). worker.go exists only to host any future expansion (e.g. per-stack mutex for parallel workers — out of scope for v1). Create an empty placeholder so the file structure matches the plan:

Write internal/updater/worker.go:

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.
  • Step 6: Run tests — verify all pass

Run:

go test ./internal/updater/... -v

Expected: all 4 tests PASS.

  • Step 7: Commit
git add internal/updater/
git commit -m "feat(updater): FIFO queue with single worker and per-job results"

Task 11: HTTP middleware (auth + request_id + logging)

Files:

  • Create: internal/api/middleware.go

  • Create: internal/api/middleware_test.go

  • Step 1: Add UUID dependency

Run:

go get github.com/google/uuid
  • Step 2: Write the failing test

Write internal/api/middleware_test.go:

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)(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)
}
  • Step 3: Run tests — verify they fail

Run:

go test ./internal/api/...

Expected: build failure (undefined: api.Auth etc).

  • Step 4: Implement middleware.go

Write internal/api/middleware.go:

// Package api contains HTTP handlers, middleware, and request/response DTOs.
package api

import (
	"crypto/subtle"
	"log/slog"
	"net/http"
	"strings"
	"time"

	"github.com/google/uuid"
	"github.com/shcizo/package-updater/internal/logging"
)

// 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"}`))
}

// 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 = uuid.NewString()
		}
		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.
func RequestLogger(base *slog.Logger) 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),
			)
		})
	}
}

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
}
  • Step 5: Run tests — verify all pass

Run:

go test ./internal/api/... -v

Expected: all middleware tests PASS.

  • Step 6: Commit
git add go.mod go.sum internal/api/middleware.go internal/api/middleware_test.go
git commit -m "feat(api): auth, request_id, and request-logging middleware"

Task 12: HTTP handlers (/update, /healthz, /version)

Files:

  • Create: internal/api/types.go

  • Create: internal/api/handlers.go

  • Create: internal/api/handlers_test.go

  • Step 1: Define DTOs

Write internal/api/types.go:

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"`
}
  • Step 2: Write the failing handler tests

Write internal/api/handlers_test.go:

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")
	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")
	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")
	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")
	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")
	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")
	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")
	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")
	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")
	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")
	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)
}
  • Step 3: Run tests — verify they fail

Run:

go test ./internal/api/...

Expected: build failure (undefined: api.NewHandlers).

  • Step 4: Implement handlers.go

Write internal/api/handlers.go:

package api

import (
	"context"
	"encoding/json"
	"errors"
	"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/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
}

// NewHandlers constructs a Handlers value with all dependencies injected.
func NewHandlers(f Finder, s Submitter, p Pinger, version, commit, buildTime string) *Handlers {
	return &Handlers{finder: f, submitter: s, pinger: p, version: version, commit: commit, buildTime: buildTime}
}

// 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 {
		writeJSON(w, http.StatusServiceUnavailable, HealthResponse{Status: "unhealthy", Docker: "unreachable"})
		return
	}
	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})
}

// errMissingImage is referenced in tests indirectly via the error string.
var errMissingImage = errors.New("image is required")
  • Step 5: Run tests — verify all pass

Run:

go test ./internal/api/... -v

Expected: all handler + middleware tests PASS.

  • Step 6: Commit
git add internal/api/
git commit -m "feat(api): /update, /healthz, /version handlers"

Task 13: Prometheus metrics

Files:

  • Create: internal/metrics/metrics.go

  • Step 1: Add Prometheus dependency

Run:

go get github.com/prometheus/client_golang/prometheus github.com/prometheus/client_golang/prometheus/promhttp
  • Step 2: Implement metrics

Write internal/metrics/metrics.go:

// 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{})
}
  • Step 3: Verify it builds

Run:

go build ./internal/metrics/...

Expected: no output.

  • Step 4: Commit
git add go.mod go.sum internal/metrics/
git commit -m "feat(metrics): Prometheus collectors and /metrics handler"

Task 14: Self-update deferred execution

Files:

  • Create: internal/selfupdate/selfupdate.go

  • Create: internal/selfupdate/selfupdate_test.go

  • Step 1: Write the failing test

Write internal/selfupdate/selfupdate_test.go:

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

	// For non-self jobs, exec happens BEFORE flush is invoked at all
	// (caller decides when to 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")
}
  • Step 2: Run tests — verify they fail

Run:

go test ./internal/selfupdate/...

Expected: build failure.

  • Step 3: Implement selfupdate.go

Write internal/selfupdate/selfupdate.go:

// 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)
}
  • Step 4: Run tests — verify all pass

Run:

go test ./internal/selfupdate/... -v

Expected: all 4 tests PASS.

  • Step 5: Commit
git add internal/selfupdate/
git commit -m "feat(selfupdate): defer self-replacement until response flushed"

Note: integrating the Wrapped executor into the queue/handler is intentionally out of scope for this task — we'd need to thread a flush callback through the queue's Submit signature. For v1 we accept that the self-update flow is partially manual: the operator may need to manually restart package-updater after pushing a new image to itself. If/when this becomes annoying, wire Wrapped into the queue and pass http.Flusher from the handler.


Task 15: Wire it all in main.go

Files:

  • Modify: cmd/server/main.go

  • Step 1: Replace the stub main.go with the real wire-up

Write cmd/server/main.go:

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()
	queue := updater.NewQueue(exec)
	queue.Start(context.Background())
	defer queue.Stop()

	reg := prometheus.NewRegistry()
	m := metrics.New(reg)
	m.BuildInfo.WithLabelValues(version, commit).Set(1)

	handlers := api.NewHandlers(disc, &submitterAdapter{queue: queue, timeout: cfg.UpdateTimeout}, dockerCli, version, commit, buildTime)

	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)(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)
}
  • Step 2: Verify it builds

Run:

go build ./cmd/server

Expected: no output. A binary called server is produced. Delete it: rm server.

  • Step 3: Tidy the module

Run:

go mod tidy

Expected: go.sum updated; no errors.

  • Step 4: Run the full test suite

Run:

go test ./...

Expected: all tests across all packages PASS.

  • Step 5: Commit
git add cmd/server/main.go go.mod go.sum
git commit -m "feat(cmd): wire config, discovery, queue, http server, metrics"

Task 16: Dockerfile

Files:

  • Create: Dockerfile

  • Create: .dockerignore

  • Step 1: Write the Dockerfile

Write Dockerfile:

# syntax=docker/dockerfile:1.7

FROM golang:1.23-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"]
  • Step 2: Write .dockerignore

Write .dockerignore:

.git
.gitignore
.github
docs/
README.md
*.md
.env
.env.*
gitea-action/
docker-compose.example.yml
  • Step 3: Build the image locally

Run:

docker build -t package-updater:dev .

Expected: image builds, ending with Successfully tagged package-updater:dev. Image size should be around 50 MB (docker images package-updater:dev).

  • Step 4: Smoke-test the binary

Run:

docker run --rm package-updater:dev /usr/local/bin/package-updater 2>&1 | head -5

Expected: prints fatal: UPDATER_API_KEY is required and exits non-zero. This proves the binary loads, config wiring works, and fail-fast triggers correctly.

  • Step 5: Commit
git add Dockerfile .dockerignore
git commit -m "build: multi-stage Dockerfile with build info ldflags"

Task 17: docker-compose.example.yml

Files:

  • Create: docker-compose.example.yml

  • Step 1: Write the example compose file

Write docker-compose.example.yml:

# 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
  • Step 2: Commit
git add docker-compose.example.yml
git commit -m "docs: docker-compose.example.yml for end-user deploy"

Task 18: Gitea composite action

Files:

  • Create: gitea-action/action.yml

  • Create: gitea-action/README.md

  • Step 1: Write the action

Write gitea-action/action.yml:

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
  • Step 2: Write the action README

Write gitea-action/README.md:

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


- [ ] **Step 3: Commit**

```bash
git add gitea-action/
git commit -m "feat(action): reusable Gitea composite action for /update"

Task 19: README

Files:

  • Modify: README.md

  • Step 1: Write the real README

Write README.md:

# 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) 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` / `service` / `project.working_dir` / `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 .

- [ ] **Step 2: Final full test sweep**

Run:
```bash
go test ./...
go vet ./...

Expected: all PASS, no vet warnings.

  • Step 3: Commit
git add README.md
git commit -m "docs: real README with quick start, endpoints, observability"

Done criteria check

After Task 19, verify against the spec's acceptance criteria (section 16):

  • POST /update with a known image triggers docker compose pull + up -d for all matching opt-in services on the host. (End-to-end manual smoke test on first deploy.)
  • Containers without the opt-in label are never touched, even with a valid token. (Covered by TestFindJobs_SkipsWithoutOptIn.)
  • Requests without a valid bearer token receive 401 and trigger no Docker action. (Covered by TestAuth_Rejects.)
  • Zero matches returns 200 with matched: 0. (Covered by TestUpdate_ZeroMatchesReturns200.)
  • Partial failure returns 207 with per-job results. (Covered by TestUpdate_MixedReturns207.)
  • Service updates itself successfully (HTTP response fully delivered before container replaced). (internal/selfupdate unit-tested; integration of the Wrapped executor into the live queue is noted as deferred — see Task 14 note.)
  • Logs in Loki are filterable by event, level, project, service, status, request_id. (Logger emits these as top-level JSON fields.)
  • Prometheus scrapes /metrics; all listed metrics present. (All 7 collectors registered.)
  • Healthcheck returns 200 when socket reachable, fails otherwise. (Covered by TestHealthz_*.)
  • Gitea composite action surfaces failure to the workflow. (Action exits non-zero on HTTP 4xx/5xx.)

Self-review notes

Spec coverage: All sections 116 of the spec are covered, with one explicit gap called out: the self-update Wrapped executor is implemented and unit-tested but not yet wired into the live queue (Task 14 note). This is deliberate — wiring it requires plumbing http.Flusher through Submit and complicates the interface, so it's deferred until self-update friction is actually observed.

Placeholders: None. Every code step is complete.

Type consistency: Job, Result, Status, ComposeLabels, Discovery, Queue, Executor, Handlers, Submitter, Finder, Pinger, Metrics, Config, Wrapped, UpdateRequest, UpdateResponse, ResultDTO, HealthResponse, VersionResponse — checked across tasks. Field names (Project, Service, WorkingDir, ConfigFiles, Refused, RefusedReason) match across discovery, updater, and api packages.