Compare commits
36
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2fe9b5ba0d | ||
|
|
0e9e34db06 | ||
|
|
4f092eaf79 | ||
|
|
05610391e3 | ||
|
|
e8f7cb3378 | ||
|
|
a596d67728 | ||
|
|
b4894db6d7 | ||
|
|
049324b2f4 | ||
|
|
75e28262f8 | ||
|
|
5d75f9a7da | ||
|
|
8a1fb45971 | ||
|
|
99c553b7c6 | ||
|
|
9b74286c9b | ||
|
|
ad6ff1e066 | ||
|
|
3edaf4e602 | ||
|
|
abee9abb1b | ||
|
|
61e738e39d | ||
|
|
b482c2e29c | ||
|
|
adeddf0254 | ||
|
|
a57df51532 | ||
|
|
d2a8b548c9 | ||
|
|
1c833f9fbe | ||
|
|
9ea082de82 | ||
|
|
50ef1a9a07 | ||
|
|
db84a2f6ba | ||
|
|
9881812eca | ||
|
|
ac3912367f | ||
|
|
9a3a200873 | ||
|
|
f92f8ac6b1 | ||
|
|
34865c9fd0 | ||
|
|
a8524e5ff4 | ||
|
|
9cf484e164 | ||
|
|
1002bb7b96 | ||
|
|
8f999c69bd | ||
|
|
df54bbaacf | ||
|
|
476945722b |
@@ -24,3 +24,4 @@ coverage.html
|
||||
|
||||
# Serena MCP workspace
|
||||
.serena/
|
||||
.superpowers/
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
# package-updater — Claude context
|
||||
|
||||
Webhook-driven Docker Compose updater. Go service, single binary, deployed as
|
||||
a container with `/var/run/docker.sock` mounted.
|
||||
|
||||
See `README.md` for user-facing docs. This file is for working ON the code.
|
||||
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
go test ./... # all tests, fast (no docker required)
|
||||
go test -run TestFoo ./internal/api # single test
|
||||
go build ./cmd/server # produces ./server
|
||||
go build ./cmd/master ./cmd/agent # new master/agent binaries (phase 1: inventory)
|
||||
DATABASE_URL=postgres://... go test -tags integration ./internal/master/store/ # Postgres tests
|
||||
docker build -t package-updater:dev . # multi-stage, builds golang:1.26-alpine
|
||||
|
||||
# Local smoke test (builds from Dockerfile, runs against /var/run/docker.sock):
|
||||
cp env.sample .env && $EDITOR .env # fill UPDATER_API_KEY
|
||||
docker compose -f docker-compose.local.yml up --build
|
||||
curl -sH "Authorization: Bearer $UPDATER_API_KEY" \
|
||||
-d '{"image":"foo"}' http://localhost:8080/update | jq
|
||||
```
|
||||
|
||||
## Architecture (one-line per package)
|
||||
|
||||
- `cmd/server` — wiring only: config → docker client → discovery → queue → http
|
||||
- `internal/api` — HTTP handlers, bearer-token auth, request-id + access-log middleware
|
||||
- `internal/config` — env-var loading; fails fast if `UPDATER_API_KEY` missing
|
||||
- `internal/discovery`— given an image, return Compose `Job`s to run (label parsing, path check, dedup)
|
||||
- `internal/discovery/swarm.go` — SwarmDiscovery: same FindJobs signature, lists `docker service ls`, gates on service-level opt-in label instead of STACKS_ROOT path-check
|
||||
- `internal/updater` — FIFO queue + single worker + `docker compose` subprocess executor
|
||||
- `internal/updater/swarm_executor.go` — SwarmExecutor: `docker service update --image` via the Docker API instead of a `docker compose` subprocess
|
||||
- `internal/selfupdate` — flush-then-exec wrapper for updating ourselves (NOT wired in live, see gotcha)
|
||||
- `internal/metrics` — Prometheus collectors
|
||||
- `internal/logging` — slog JSON, request-id context propagation
|
||||
- `cmd/master`, `cmd/agent` — master/agent architecture (spec `2026-10-12-master-agent-design.md`), phase 1 = inventory only. `cmd/server` keeps doing updates until phase 3.
|
||||
- `internal/protocol` — agent↔master WebSocket messages
|
||||
- `internal/pin` — master key pair, self-signed cert, fingerprint-pinned TLS client
|
||||
- `internal/agenttoken`— per-agent tokens (`pua_` prefix, only the SHA-256 hash is stored)
|
||||
- `internal/imageref` — split `repo:tag@digest`
|
||||
- `internal/agent/...` — inventory collection, Docker event debounce, connection loop
|
||||
- `internal/master/...`— Postgres store, agent hub, change broker, OIDC auth, htmx UI
|
||||
|
||||
## Design intent (do not break without discussion)
|
||||
|
||||
- **Single FIFO worker by design** (`internal/updater/queue.go`). Spec §5.7. Never
|
||||
parallelise the queue — two `docker compose` calls against the same stack race.
|
||||
- **Defense in depth: token AND opt-in label AND STACKS_ROOT prefix** must all
|
||||
hold before a container is touched. Weakening any of these breaks the security
|
||||
model — discuss before changing.
|
||||
- **Auth on `/update` only**. `/healthz`, `/metrics`, `/version` are intentionally
|
||||
unauthenticated (internal network, scraper/healthcheck need them). See
|
||||
`cmd/server/main.go` `routeAuth`.
|
||||
- **`metrics *Metrics` parameters may be nil**; constructors and call sites check.
|
||||
Tests rely on this — don't drop the nil-check.
|
||||
- **Stateless**: no DB, no config file, no on-disk audit log. Docker daemon is
|
||||
the source of truth.
|
||||
- **Compose mode and Swarm mode are selected once per deployment via `MODE`**, never
|
||||
mixed at request time. Swarm mode's security gate is opt-in label only — there is
|
||||
no STACKS_ROOT-equivalent path check, since Swarm services have no local compose
|
||||
file. Don't add one; don't weaken Compose mode's three-factor gate to match.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- **`internal/selfupdate.Wrapped` is implemented and unit-tested but NOT wired
|
||||
into the live queue.** Spec §15 calls this out as a v1 gap. If you "fix" this,
|
||||
read the package doc — flush-before-exec ordering is subtle.
|
||||
- **Image matching is case-sensitive and tag/digest-agnostic.** The tricky case
|
||||
is `localhost:5000/foo:v1` where the first colon is a port, not a tag. See
|
||||
`internal/discovery/matching.go` `NormaliseImage`.
|
||||
- **Path safety uses `filepath.Rel` + ".." prefix check**, NOT `strings.HasPrefix`.
|
||||
Prevents the `/foo-evil` vs `/foo` confusion. See `internal/discovery/pathcheck.go`.
|
||||
- **`STACKS_ROOT` defaults to `/home/shcizo/self-hosted`** in prod but `env.sample`
|
||||
suggests `/tmp` for smoke tests. The default in `config.go` is the prod value
|
||||
— set explicitly in test/dev envs.
|
||||
- **Go 1.26.3** (`go.mod`). Dockerfile pins `golang:1.26-alpine`. Bumping one
|
||||
without the other has caused a fix commit already.
|
||||
- **`<summary>` C# convention from global CLAUDE.md does not apply here** — this
|
||||
is Go. Use idiomatic GoDoc (`// FuncName does X.`).
|
||||
- **Swarm mode requires manager-node API access.** `docker service update` fails
|
||||
with a permission error against a worker-only node. This is an operator/deployment
|
||||
concern (point `DOCKER_HOST` at a manager, or schedule the updater on a manager),
|
||||
not something the code can detect or work around.
|
||||
- **WebSocket read limit is raised to 1 MiB on both sides.** coder/websocket defaults to 32 KiB, which a full snapshot from a busy machine exceeds — symptom is an agent stuck in a reconnect loop.
|
||||
- **The master's key lives in Postgres (`master_identity`).** Wiping the database means every agent needs a new `MASTER_KEY`.
|
||||
- **SSE responses must flush through every wrapper.** `web.events` uses `http.ResponseController`; a middleware that wraps `ResponseWriter` without `Unwrap()` silently breaks live updates. Nginx needs `X-Accel-Buffering: no` (set by the handler).
|
||||
- **The "stateless" and "label gate" design intents above describe `cmd/server` only.** The master is stateful by design and gates updates per service in the UI (spec 2026-10-12).
|
||||
- **The master does OIDC discovery once at startup and exits if the issuer is unreachable.** It does not retry; Swarm restarts the container, so a short Authentik outage during deploy resolves itself.
|
||||
|
||||
## References
|
||||
|
||||
- Design spec: `docs/superpowers/specs/2026-05-22-package-updater-design.md` (489 lines, authoritative)
|
||||
- Implementation plan: `docs/superpowers/plans/2026-05-22-package-updater-implementation.md`
|
||||
- Consumer-side CI integration: `gitea-action/`
|
||||
- Master/agent spec: `docs/superpowers/specs/2026-10-12-master-agent-design.md`
|
||||
- Phase 1 plan: `docs/superpowers/plans/2026-10-12-master-agent-phase-1-inventory.md`
|
||||
|
||||
## Conventions
|
||||
|
||||
- Conventional Commits (`feat:`, `fix:`, `docs:`, `ci:`, `build:`, `chore:`).
|
||||
- One package = one responsibility; interfaces defined at consumer site
|
||||
(`api.Finder`, `api.Submitter`, `api.Pinger`) for testability.
|
||||
- Table-driven tests with `stretchr/testify`. No mocks beyond hand-written fakes.
|
||||
+9
-7
@@ -8,15 +8,17 @@ 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
|
||||
RUN for cmd in server:package-updater master:updater-master agent:updater-agent; do \
|
||||
CGO_ENABLED=0 go build \
|
||||
-ldflags="-s -w \
|
||||
-X main.version=${VERSION} \
|
||||
-X main.commit=${COMMIT} \
|
||||
-X main.buildTime=${BUILD_TIME}" \
|
||||
-o /out/${cmd#*:} ./cmd/${cmd%%:*} || exit 1; \
|
||||
done
|
||||
|
||||
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/
|
||||
COPY --from=build /out/ /usr/local/bin/
|
||||
EXPOSE 8080
|
||||
ENTRYPOINT ["/usr/local/bin/package-updater"]
|
||||
|
||||
@@ -27,6 +27,40 @@ A container is eligible for update only if it has **both**:
|
||||
|
||||
Defense in depth: a valid bearer token AND the opt-in label must both be present before any container is touched.
|
||||
|
||||
## Swarm mode
|
||||
|
||||
Set `MODE=swarm` to update Docker Swarm services instead of Compose stacks. The
|
||||
service runs `docker service update --image` via the Docker API instead of
|
||||
shelling out to `docker compose`.
|
||||
|
||||
Two things to get right when running in Swarm mode:
|
||||
|
||||
- **The opt-in label goes on the service, not the container/task.** Swarm mode
|
||||
reads `Service.Spec.Labels`, so add it with
|
||||
`docker service update --label-add se.shcizo.auto-update=true <service>` or
|
||||
set it under `deploy.labels` in the stack file — a plain `labels:` entry on
|
||||
the service (container-level) is not visible to Swarm mode's discovery.
|
||||
- **The updater must talk to a Swarm manager.** `docker service update`
|
||||
requires manager API access, so either point `DOCKER_HOST` at a manager node
|
||||
or schedule the updater container itself on a manager with the socket
|
||||
mounted. This is a deployment concern the service cannot detect or work
|
||||
around.
|
||||
|
||||
## Master/agent (in progress)
|
||||
|
||||
A master with one agent per machine is replacing the single-instance webhook
|
||||
model; see the [design spec](docs/superpowers/specs/2026-10-12-master-agent-design.md).
|
||||
Phase 1 delivers a live inventory: every Compose stack, Swarm service and
|
||||
container on every machine, behind Authentik login. Updates still go through
|
||||
the existing `/update` webhook until phase 3.
|
||||
|
||||
- Master: `updater-master` in the same image, example stack in `deploy/master.stack.yml`.
|
||||
At startup it does OIDC discovery against `OIDC_ISSUER` and exits if the issuer
|
||||
is unreachable; Swarm then restarts it.
|
||||
- Agents: add one per machine on the master's **Agents** page; it shows a
|
||||
ready-to-run compose file with the agent's token and the master's key
|
||||
fingerprint.
|
||||
|
||||
## Quick start
|
||||
|
||||
1. Build and push the image (e.g. via your own CI).
|
||||
@@ -49,6 +83,7 @@ All via environment variables.
|
||||
| `LOG_LEVEL` | no | `info` | `debug` / `info` / `warn` / `error`. |
|
||||
| `UPDATE_TIMEOUT` | no | `5m` | Per-job timeout (Go duration). |
|
||||
| `OPT_IN_LABEL` | no | `se.shcizo.auto-update` | Label name to check; value must equal `"true"`. |
|
||||
| `MODE` | no | `compose` | `compose` or `swarm`. Selects the update mechanism for the whole deployment; not mixed per-request. |
|
||||
|
||||
## Endpoints
|
||||
|
||||
@@ -80,5 +115,7 @@ These are tracked in the spec's section 2 and section 15 as deliberate out-of-sc
|
||||
|
||||
- **Self-update wiring**: `internal/selfupdate.Wrapped` exists and is unit-tested but is not wired into the live queue. The HTTP response flush ordering for self-replacement is a future enhancement; for now, expect to manually rerun `docker compose up -d` on the host if pushing a new image of `package-updater` itself causes a mid-response interruption.
|
||||
- **No rollback**: Compose's "keep old container if new fails to start" is the only safety net.
|
||||
- **Single host only**.
|
||||
- **Single host only in Compose mode**. Swarm mode (`MODE=swarm`) is the
|
||||
multi-node path, but only from a manager node's point of view — the updater
|
||||
itself still needs manager API access (see "Swarm mode" above).
|
||||
- **No per-repo API keys**: a single shared bearer token is used.
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
// Command updater-agent reports this machine's Docker services to the
|
||||
// package-updater master.
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/docker/docker/client"
|
||||
|
||||
"github.com/shcizo/package-updater/internal/agent/config"
|
||||
"github.com/shcizo/package-updater/internal/agent/conn"
|
||||
"github.com/shcizo/package-updater/internal/agent/inventory"
|
||||
"github.com/shcizo/package-updater/internal/logging"
|
||||
)
|
||||
|
||||
// Build info is injected at link time via -ldflags="-X main.version=...".
|
||||
var version = "dev"
|
||||
|
||||
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)
|
||||
|
||||
dockerCli, err := client.NewClientWithOpts(client.FromEnv, client.WithAPIVersionNegotiation())
|
||||
if err != nil {
|
||||
return fmt.Errorf("docker client: %w", err)
|
||||
}
|
||||
defer dockerCli.Close()
|
||||
|
||||
collector := inventory.New(dockerCli, cfg.SelfID)
|
||||
agent := conn.New(conn.Config{
|
||||
URL: cfg.MasterURL, Token: cfg.Token, Fingerprint: cfg.MasterKey, Version: version,
|
||||
}, collector, logger)
|
||||
|
||||
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
|
||||
defer stop()
|
||||
|
||||
go watchLoop(ctx, dockerCli, agent, logger)
|
||||
logger.Info("agent_starting", "version", version, "master_url", cfg.MasterURL)
|
||||
return agent.Run(ctx)
|
||||
}
|
||||
|
||||
// watchLoop keeps a Docker event subscription alive. When the stream breaks
|
||||
// (daemon restart), it resubscribes and asks for a refresh, because events
|
||||
// may have been missed in between. It also asks for a full re-inventory every
|
||||
// 60 seconds, since a Swarm replica dying on another node emits no event here.
|
||||
func watchLoop(ctx context.Context, src inventory.EventSource, agent *conn.Agent, log *slog.Logger) {
|
||||
go func() {
|
||||
ticker := time.NewTicker(60 * time.Second)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
agent.Notify()
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
for {
|
||||
err := inventory.Watch(ctx, src, 2*time.Second, agent.Notify)
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
log.Warn("docker_events_lost", "error", err)
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-time.After(5 * time.Second):
|
||||
}
|
||||
agent.Notify()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
// Command updater-master serves the package-updater UI and accepts agent
|
||||
// connections.
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/shcizo/package-updater/internal/logging"
|
||||
"github.com/shcizo/package-updater/internal/master/config"
|
||||
"github.com/shcizo/package-updater/internal/master/events"
|
||||
"github.com/shcizo/package-updater/internal/master/hub"
|
||||
"github.com/shcizo/package-updater/internal/master/store"
|
||||
"github.com/shcizo/package-updater/internal/master/web"
|
||||
"github.com/shcizo/package-updater/internal/master/webauth"
|
||||
"github.com/shcizo/package-updater/internal/pin"
|
||||
)
|
||||
|
||||
// Build info is injected at link time via -ldflags="-X main.version=...".
|
||||
var version = "dev"
|
||||
|
||||
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)
|
||||
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
|
||||
defer stop()
|
||||
|
||||
st, err := store.Open(ctx, cfg.DatabaseURL)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer st.Close()
|
||||
|
||||
key, err := st.LoadOrCreateIdentity(ctx, pin.GenerateKey)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
cert, err := pin.Certificate(key)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fingerprint, err := pin.Fingerprint(key.Public())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
logger.Info("master_starting", "version", version, "master_key", fingerprint)
|
||||
|
||||
broker := events.NewBroker()
|
||||
agentHub := hub.New(st, broker, logger)
|
||||
|
||||
provider, err := webauth.NewOIDCProvider(ctx, webauth.OIDCConfig{
|
||||
Issuer: cfg.OIDCIssuer, ClientID: cfg.OIDCClientID, ClientSecret: cfg.OIDCClientSecret,
|
||||
RedirectURL: cfg.PublicURL + "/auth/callback",
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
auth := webauth.New(provider, st, cfg.AllowedGroup, strings.HasPrefix(cfg.PublicURL, "https://"), logger)
|
||||
|
||||
ui, err := web.New(st, agentHub, broker, web.Config{
|
||||
AgentURL: cfg.AgentURL, MasterKey: fingerprint, AgentImage: cfg.AgentImage,
|
||||
}, logger)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) {
|
||||
if err := st.Ping(r.Context()); err != nil {
|
||||
http.Error(w, "database unreachable", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
_, _ = w.Write([]byte("ok"))
|
||||
})
|
||||
mux.HandleFunc("GET /auth/login", auth.Login)
|
||||
mux.HandleFunc("GET /auth/callback", auth.Callback)
|
||||
mux.Handle("POST /auth/logout", auth.Require(http.HandlerFunc(auth.Logout)))
|
||||
ui.Register(mux, auth.Require)
|
||||
|
||||
// BaseContext ties every request context to the signal context, so on
|
||||
// shutdown the long-lived SSE handlers and agent connections return
|
||||
// instead of holding Shutdown until its timeout.
|
||||
baseContext := func(net.Listener) context.Context { return ctx }
|
||||
|
||||
// No WriteTimeout on either server: SSE streams and agent connections
|
||||
// are long-lived by design.
|
||||
webSrv := &http.Server{
|
||||
Addr: ":" + cfg.Port, Handler: mux, ReadHeaderTimeout: 10 * time.Second,
|
||||
BaseContext: baseContext,
|
||||
}
|
||||
|
||||
agentMux := http.NewServeMux()
|
||||
agentMux.Handle("GET /agent", agentHub)
|
||||
agentSrv := &http.Server{
|
||||
Addr: ":" + cfg.AgentPort, Handler: agentMux, ReadHeaderTimeout: 10 * time.Second,
|
||||
TLSConfig: &tls.Config{Certificates: []tls.Certificate{cert}, MinVersion: tls.VersionTLS13},
|
||||
BaseContext: baseContext,
|
||||
}
|
||||
|
||||
errCh := make(chan error, 2)
|
||||
go func() { errCh <- webSrv.ListenAndServe() }()
|
||||
go func() { errCh <- agentSrv.ListenAndServeTLS("", "") }()
|
||||
logger.Info("listening", "web", webSrv.Addr, "agents", agentSrv.Addr)
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
logger.Info("shutdown_signal")
|
||||
case err := <-errCh:
|
||||
if !errors.Is(err, http.ErrServerClosed) {
|
||||
return err
|
||||
}
|
||||
}
|
||||
shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
_ = webSrv.Shutdown(shutdownCtx)
|
||||
_ = agentSrv.Shutdown(shutdownCtx)
|
||||
return nil
|
||||
}
|
||||
+17
-4
@@ -42,7 +42,7 @@ func run() error {
|
||||
logger := logging.New(cfg.LogLevel)
|
||||
logger.Info("starting",
|
||||
"version", version, "commit", commit, "port", cfg.Port,
|
||||
"stacks_root", cfg.StacksRoot, "opt_in_label", cfg.OptInLabel,
|
||||
"mode", cfg.Mode, "stacks_root", cfg.StacksRoot, "opt_in_label", cfg.OptInLabel,
|
||||
)
|
||||
|
||||
dockerCli, err := client.NewClientWithOpts(client.FromEnv, client.WithAPIVersionNegotiation())
|
||||
@@ -51,8 +51,21 @@ func run() error {
|
||||
}
|
||||
defer dockerCli.Close()
|
||||
|
||||
disc := discovery.New(dockerCli, cfg.StacksRoot, cfg.OptInLabel)
|
||||
exec := updater.NewComposeExecutor()
|
||||
var finder api.Finder
|
||||
var exec updater.Executor
|
||||
switch cfg.Mode {
|
||||
case "swarm":
|
||||
finder = discovery.NewSwarm(dockerCli, cfg.OptInLabel)
|
||||
exec = updater.NewSwarmExecutor(dockerCli)
|
||||
case "compose":
|
||||
finder = discovery.New(dockerCli, cfg.StacksRoot, cfg.OptInLabel)
|
||||
exec = updater.NewComposeExecutor()
|
||||
default:
|
||||
// Unreachable: config.Load already validates Mode, but guard
|
||||
// here too rather than silently falling through to a nil
|
||||
// finder/exec pair.
|
||||
return fmt.Errorf("unknown MODE %q", cfg.Mode)
|
||||
}
|
||||
|
||||
reg := prometheus.NewRegistry()
|
||||
m := metrics.New(reg)
|
||||
@@ -62,7 +75,7 @@ func run() error {
|
||||
queue.Start(context.Background())
|
||||
defer queue.Stop()
|
||||
|
||||
handlers := api.NewHandlers(disc, &submitterAdapter{queue: queue, timeout: cfg.UpdateTimeout}, dockerCli, version, commit, buildTime, m)
|
||||
handlers := api.NewHandlers(finder, &submitterAdapter{queue: queue, timeout: cfg.UpdateTimeout}, dockerCli, version, commit, buildTime, m)
|
||||
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("POST /update", handlers.Update)
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
# Example Swarm stack for the package-updater master (phase 1: inventory only).
|
||||
# Deploy on the manager: docker stack deploy -c master.stack.yml package-updater-master
|
||||
#
|
||||
# Before deploying:
|
||||
# 1. In Postgres: CREATE USER package_updater ...; CREATE DATABASE package_updater OWNER package_updater;
|
||||
# 2. In Authentik: create an OAuth2/OpenID provider + application with redirect URI
|
||||
# https://updater.example.com/auth/callback, and a group "updater-admins".
|
||||
# 3. docker secret create pu_oidc_client_secret - ; docker secret create pu_database_url -
|
||||
services:
|
||||
master:
|
||||
image: gitea.shcizo.se/shcizo/package-updater:latest
|
||||
entrypoint: ["/bin/sh", "-c", "DATABASE_URL=$$(cat /run/secrets/pu_database_url) OIDC_CLIENT_SECRET=$$(cat /run/secrets/pu_oidc_client_secret) exec /usr/local/bin/updater-master"]
|
||||
environment:
|
||||
PUBLIC_URL: https://updater.example.com
|
||||
AGENT_URL: wss://10.0.0.5:8090/agent
|
||||
OIDC_ISSUER: https://auth.example.com/application/o/package-updater/
|
||||
OIDC_CLIENT_ID: package-updater
|
||||
ALLOWED_GROUP: updater-admins
|
||||
secrets:
|
||||
- pu_database_url
|
||||
- pu_oidc_client_secret
|
||||
ports:
|
||||
# Agents connect here directly over the internal network, not via the proxy.
|
||||
- target: 8090
|
||||
published: 8090
|
||||
mode: host
|
||||
networks:
|
||||
- proxy
|
||||
deploy:
|
||||
placement:
|
||||
constraints: [node.role == manager]
|
||||
|
||||
secrets:
|
||||
pu_database_url:
|
||||
external: true
|
||||
pu_oidc_client_secret:
|
||||
external: true
|
||||
|
||||
networks:
|
||||
proxy:
|
||||
external: true
|
||||
@@ -0,0 +1,859 @@
|
||||
# Docker Swarm Support Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Add a `MODE=swarm` operating mode so package-updater can update Docker Swarm services (`docker service update --image`) as an alternative to the existing Compose-only flow (`docker compose pull && up -d`), selected once per deployment via config — not mixed within one running instance.
|
||||
|
||||
**Architecture:** Introduce parallel `SwarmDiscovery` (internal/discovery) and `SwarmExecutor` (internal/updater) implementations alongside the existing `Discovery`/`ComposeExecutor`. Both new types satisfy the exact same `api.Finder` / `updater.Executor` interfaces already in use, so `internal/api` and the FIFO `Queue` need no structural changes — only `cmd/server/main.go` branches on `cfg.Mode` to decide which pair to construct. The Compose-specific security gate (`STACKS_ROOT` path-check) does not apply to Swarm services (no local compose file exists); it is replaced by the same opt-in label mechanism, checked against the Swarm service's own labels instead of container labels.
|
||||
|
||||
**Tech Stack:** Go 1.26.3, `github.com/docker/docker` SDK v28.5.2+incompatible (`client`, `api/types`, `api/types/swarm`), `stretchr/testify`.
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- Go 1.26.3 (go.mod) — do not bump without also bumping the Dockerfile `golang:1.26-alpine` base image.
|
||||
- Single FIFO worker in `internal/updater/queue.go` must remain single — never parallelize job execution, including for Swarm jobs.
|
||||
- Defense in depth for Compose mode (token + opt-in label + STACKS_ROOT prefix) must keep holding unchanged. Swarm mode's gate is opt-in label only (no filesystem path exists to check) — this is a deliberate, narrower model for Swarm and must not be backported to weaken Compose mode.
|
||||
- Auth remains on `/update` only; `/healthz`, `/metrics`, `/version` stay unauthenticated.
|
||||
- `metrics *Metrics` parameters may be nil; every new call site must follow the existing `if m.metrics != nil { ... }` pattern — never assume non-nil.
|
||||
- Stateless: no DB, no config file, no on-disk audit log required for Swarm mode either.
|
||||
- Conventional Commits (`feat:`, `fix:`, `docs:`, `test:`) for every commit in this plan.
|
||||
- Table-driven / one-assertion-focus tests with `testify/require`, matching existing style in `internal/discovery/discovery_test.go`.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Add `Mode` to config
|
||||
|
||||
**Files:**
|
||||
- Modify: `internal/config/config.go`
|
||||
- Test: `internal/config/config_test.go`
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: `Config.Mode string` — always one of `"compose"` or `"swarm"` after `Load()` returns successfully. Later tasks (`cmd/server/main.go`) branch on this exact string.
|
||||
|
||||
- [ ] **Step 1: Write the failing tests**
|
||||
|
||||
Add to `internal/config/config_test.go` (create the file if it doesn't already cover this — check first; if it exists, add these functions alongside the existing ones):
|
||||
|
||||
```go
|
||||
func TestLoad_DefaultsToComposeMode(t *testing.T) {
|
||||
t.Setenv("UPDATER_API_KEY", "secret")
|
||||
cfg, err := config.Load()
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "compose", cfg.Mode)
|
||||
}
|
||||
|
||||
func TestLoad_AcceptsSwarmMode(t *testing.T) {
|
||||
t.Setenv("UPDATER_API_KEY", "secret")
|
||||
t.Setenv("MODE", "swarm")
|
||||
cfg, err := config.Load()
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "swarm", cfg.Mode)
|
||||
}
|
||||
|
||||
func TestLoad_RejectsInvalidMode(t *testing.T) {
|
||||
t.Setenv("UPDATER_API_KEY", "secret")
|
||||
t.Setenv("MODE", "kubernetes")
|
||||
_, err := config.Load()
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "MODE")
|
||||
}
|
||||
```
|
||||
|
||||
Make sure the file imports `"testing"`, `"github.com/shcizo/package-updater/internal/config"`, and `"github.com/stretchr/testify/require"` (match whatever the existing `config_test.go` already imports).
|
||||
|
||||
- [ ] **Step 2: Run tests to verify they fail**
|
||||
|
||||
Run: `go test ./internal/config/... -run 'TestLoad_.*Mode' -v`
|
||||
Expected: FAIL — `cfg.Mode` does not compile (`Config` has no field `Mode`).
|
||||
|
||||
- [ ] **Step 3: Implement**
|
||||
|
||||
In `internal/config/config.go`, add the field and validation:
|
||||
|
||||
```go
|
||||
// Config holds all runtime configuration for the service.
|
||||
type Config struct {
|
||||
APIKey string
|
||||
StacksRoot string
|
||||
Port string
|
||||
LogLevel string
|
||||
UpdateTimeout time.Duration
|
||||
OptInLabel string
|
||||
Mode string
|
||||
}
|
||||
```
|
||||
|
||||
In `Load()`, after the existing field assignments (right after `OptInLabel`), add:
|
||||
|
||||
```go
|
||||
Mode: getenvDefault("MODE", "compose"),
|
||||
```
|
||||
|
||||
so the literal becomes:
|
||||
|
||||
```go
|
||||
cfg := &Config{
|
||||
APIKey: os.Getenv("UPDATER_API_KEY"),
|
||||
StacksRoot: getenvDefault("STACKS_ROOT", "/home/shcizo/self-hosted"),
|
||||
Port: getenvDefault("PORT", "8080"),
|
||||
LogLevel: getenvDefault("LOG_LEVEL", "info"),
|
||||
OptInLabel: getenvDefault("OPT_IN_LABEL", "se.shcizo.auto-update"),
|
||||
Mode: getenvDefault("MODE", "compose"),
|
||||
}
|
||||
```
|
||||
|
||||
Then, after the `APIKey` check and before the `UPDATE_TIMEOUT` parsing, add mode validation:
|
||||
|
||||
```go
|
||||
if cfg.Mode != "compose" && cfg.Mode != "swarm" {
|
||||
return nil, fmt.Errorf("MODE %q is invalid: must be %q or %q", cfg.Mode, "compose", "swarm")
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run tests to verify they pass**
|
||||
|
||||
Run: `go test ./internal/config/... -v`
|
||||
Expected: PASS — all config tests including the three new ones, and no regressions in `TestLoad_RequiresAPIKey`, `_AppliesDefaults`, `_OverridesViaEnv`, `_InvalidTimeoutErrors`.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add internal/config/config.go internal/config/config_test.go
|
||||
git commit -m "feat(config): add MODE env var to select compose or swarm operation"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Generalize `discovery.Job` for non-Compose jobs
|
||||
|
||||
**Files:**
|
||||
- Modify: `internal/discovery/discovery.go`
|
||||
- Test: `internal/discovery/discovery_test.go`
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: `discovery.Job` gains two fields, `Image string` (the full image reference the update request asked for — needed later by `SwarmExecutor` to know what to update *to*, since Swarm has no local compose file to `pull` against) and `ServiceID string` (empty for Compose jobs; the Swarm service ID for Swarm jobs, needed later by `SwarmExecutor` to target the right service). Both default to `""` for existing Compose jobs, so no existing caller breaks.
|
||||
- Consumes: nothing new — this task only widens the struct and populates `Image` from the existing `FindJobs(ctx, image string)` parameter.
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
Add to `internal/discovery/discovery_test.go`:
|
||||
|
||||
```go
|
||||
func TestFindJobs_PopulatesImageField(t *testing.T) {
|
||||
fake := &fakeDockerClient{containers: []types.Container{
|
||||
mkContainer("registry.example.com/myapp:v1", mkComposeLabels(
|
||||
"myapp-prod", "web",
|
||||
"/home/shcizo/self-hosted/myapp-prod",
|
||||
"/home/shcizo/self-hosted/myapp-prod/docker-compose.yml",
|
||||
true,
|
||||
)),
|
||||
}}
|
||||
d := discovery.New(fake, "/home/shcizo/self-hosted", "se.shcizo.auto-update")
|
||||
jobs, err := d.FindJobs(context.Background(), "registry.example.com/myapp")
|
||||
require.NoError(t, err)
|
||||
require.Len(t, jobs, 1)
|
||||
require.Equal(t, "registry.example.com/myapp", jobs[0].Image)
|
||||
require.Empty(t, jobs[0].ServiceID)
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `go test ./internal/discovery/... -run TestFindJobs_PopulatesImageField -v`
|
||||
Expected: FAIL — `jobs[0].Image` does not compile (`Job` has no field `Image`).
|
||||
|
||||
- [ ] **Step 3: Implement**
|
||||
|
||||
In `internal/discovery/discovery.go`, widen the struct:
|
||||
|
||||
```go
|
||||
// Job describes a single (project, service, config_files) update to execute.
|
||||
type Job struct {
|
||||
Project string
|
||||
Service string
|
||||
WorkingDir string
|
||||
ConfigFiles []string
|
||||
// Image is the full image reference the update request asked for.
|
||||
// Compose jobs ignore it (compose.yml already pins the reference to
|
||||
// pull); Swarm jobs need it to know what to set on the service spec.
|
||||
Image string
|
||||
// ServiceID is the Swarm service ID. Empty for Compose jobs.
|
||||
ServiceID string
|
||||
// Refused is true when the WorkingDir falls outside STACKS_ROOT.
|
||||
// The job is returned so the caller can surface a per-job "refused"
|
||||
// result, but it MUST NOT be executed.
|
||||
Refused bool
|
||||
RefusedReason string
|
||||
}
|
||||
```
|
||||
|
||||
In `FindJobs`, populate `Image` on the constructed job (leave `ServiceID` as its zero value):
|
||||
|
||||
```go
|
||||
jobs = append(jobs, Job{
|
||||
Project: cl.Project,
|
||||
Service: cl.Service,
|
||||
WorkingDir: cl.WorkingDir,
|
||||
ConfigFiles: cl.ConfigFiles,
|
||||
Image: image,
|
||||
Refused: refused,
|
||||
RefusedReason: reason,
|
||||
})
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run tests to verify they pass**
|
||||
|
||||
Run: `go test ./internal/discovery/... -v`
|
||||
Expected: PASS — all discovery tests, including the new one and all pre-existing `TestFindJobs_*` cases (they don't assert on `Image`/`ServiceID` so they're unaffected).
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add internal/discovery/discovery.go internal/discovery/discovery_test.go
|
||||
git commit -m "feat(discovery): add Image and ServiceID fields to Job for swarm support"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: `SwarmDiscovery` — find matching Swarm services
|
||||
|
||||
**Files:**
|
||||
- Create: `internal/discovery/swarm.go`
|
||||
- Test: `internal/discovery/swarm_test.go`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `discovery.Job{Project, Service, Image, ServiceID, Refused, RefusedReason}` (Task 2), `discovery.NormaliseImage(string) string` and `discovery.ImagesMatch(a, b string) bool` (existing, `internal/discovery/matching.go`), `discovery.HasOptIn(labels map[string]string, key string) bool` (existing, `internal/discovery/labels.go`).
|
||||
- Produces: `discovery.SwarmDockerClient` interface, `discovery.NewSwarm(cli SwarmDockerClient, optInLabel string) *SwarmDiscovery`, method `(*SwarmDiscovery) FindJobs(ctx context.Context, image string) ([]Job, error)` — same signature as `*Discovery.FindJobs`, so both satisfy `api.Finder` (`internal/api/handlers.go`) without any change there.
|
||||
|
||||
- [ ] **Step 1: Verify the Docker SDK method signature before writing code**
|
||||
|
||||
Run: `go doc github.com/docker/docker/client.Client.ServiceList`
|
||||
|
||||
Expected output shape (confirm it matches before proceeding — if the SDK signature differs, adjust the interface in Step 3 to match what `go doc` actually reports instead of the assumed signature below):
|
||||
|
||||
```
|
||||
func (cli *Client) ServiceList(ctx context.Context, options types.ServiceListOptions) ([]swarm.Service, error)
|
||||
```
|
||||
|
||||
Also run: `go doc github.com/docker/docker/api/types/swarm.Service` and `go doc github.com/docker/docker/api/types/swarm.ServiceSpec` to confirm `Service.ID`, `Service.Spec.Name` (via embedded `Annotations`), `Service.Spec.Labels` (via embedded `Annotations`), and `Service.Spec.TaskTemplate.ContainerSpec.Image` are reachable as described. Adjust field access in later steps if the embedding differs.
|
||||
|
||||
- [ ] **Step 2: Write the failing tests**
|
||||
|
||||
Create `internal/discovery/swarm_test.go`:
|
||||
|
||||
```go
|
||||
package discovery_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/docker/docker/api/types"
|
||||
"github.com/docker/docker/api/types/swarm"
|
||||
"github.com/shcizo/package-updater/internal/discovery"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
type fakeSwarmDockerClient struct {
|
||||
services []swarm.Service
|
||||
err error
|
||||
}
|
||||
|
||||
func (f *fakeSwarmDockerClient) ServiceList(_ context.Context, _ types.ServiceListOptions) ([]swarm.Service, error) {
|
||||
return f.services, f.err
|
||||
}
|
||||
|
||||
func mkService(id, name, image string, labels map[string]string) swarm.Service {
|
||||
return swarm.Service{
|
||||
ID: id,
|
||||
Spec: swarm.ServiceSpec{
|
||||
Annotations: swarm.Annotations{Name: name, Labels: labels},
|
||||
TaskTemplate: swarm.TaskSpec{
|
||||
ContainerSpec: &swarm.ContainerSpec{Image: image},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func TestSwarmFindJobs_MatchAndOptIn(t *testing.T) {
|
||||
fake := &fakeSwarmDockerClient{services: []swarm.Service{
|
||||
mkService("svc-myapp", "myapp_web", "registry.example.com/myapp:v1", map[string]string{
|
||||
"se.shcizo.auto-update": "true",
|
||||
}),
|
||||
mkService("svc-other", "other_web", "registry.example.com/other:v1", map[string]string{
|
||||
"se.shcizo.auto-update": "true",
|
||||
}),
|
||||
}}
|
||||
d := discovery.NewSwarm(fake, "se.shcizo.auto-update")
|
||||
jobs, err := d.FindJobs(context.Background(), "registry.example.com/myapp")
|
||||
require.NoError(t, err)
|
||||
require.Len(t, jobs, 1)
|
||||
require.Equal(t, "myapp_web", jobs[0].Service)
|
||||
require.Equal(t, "svc-myapp", jobs[0].ServiceID)
|
||||
require.Equal(t, "registry.example.com/myapp", jobs[0].Image)
|
||||
require.False(t, jobs[0].Refused)
|
||||
require.Empty(t, jobs[0].WorkingDir)
|
||||
require.Empty(t, jobs[0].ConfigFiles)
|
||||
}
|
||||
|
||||
func TestSwarmFindJobs_SkipsWithoutOptIn(t *testing.T) {
|
||||
fake := &fakeSwarmDockerClient{services: []swarm.Service{
|
||||
mkService("svc-myapp", "myapp_web", "registry.example.com/myapp:v1", nil),
|
||||
}}
|
||||
d := discovery.NewSwarm(fake, "se.shcizo.auto-update")
|
||||
jobs, err := d.FindJobs(context.Background(), "registry.example.com/myapp")
|
||||
require.NoError(t, err)
|
||||
require.Empty(t, jobs)
|
||||
}
|
||||
|
||||
func TestSwarmFindJobs_DockerError(t *testing.T) {
|
||||
fake := &fakeSwarmDockerClient{err: errors.New("connection refused")}
|
||||
d := discovery.NewSwarm(fake, "se.shcizo.auto-update")
|
||||
_, err := d.FindJobs(context.Background(), "registry.example.com/myapp")
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
func TestSwarmFindJobs_NoContainerSpecIsSkipped(t *testing.T) {
|
||||
fake := &fakeSwarmDockerClient{services: []swarm.Service{
|
||||
{
|
||||
ID: "svc-weird",
|
||||
Spec: swarm.ServiceSpec{
|
||||
Annotations: swarm.Annotations{Name: "weird", Labels: map[string]string{"se.shcizo.auto-update": "true"}},
|
||||
TaskTemplate: swarm.TaskSpec{ContainerSpec: nil},
|
||||
},
|
||||
},
|
||||
}}
|
||||
d := discovery.NewSwarm(fake, "se.shcizo.auto-update")
|
||||
jobs, err := d.FindJobs(context.Background(), "registry.example.com/myapp")
|
||||
require.NoError(t, err)
|
||||
require.Empty(t, jobs)
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Run tests to verify they fail**
|
||||
|
||||
Run: `go test ./internal/discovery/... -run TestSwarmFindJobs -v`
|
||||
Expected: FAIL to compile — `discovery.NewSwarm` and `discovery.SwarmDockerClient` don't exist yet.
|
||||
|
||||
- [ ] **Step 4: Implement**
|
||||
|
||||
Create `internal/discovery/swarm.go`:
|
||||
|
||||
```go
|
||||
package discovery
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/docker/docker/api/types"
|
||||
"github.com/docker/docker/api/types/swarm"
|
||||
)
|
||||
|
||||
// SwarmDockerClient is the subset of the Docker SDK SwarmDiscovery depends
|
||||
// on. Defined as an interface so tests can supply a fake.
|
||||
type SwarmDockerClient interface {
|
||||
ServiceList(ctx context.Context, opts types.ServiceListOptions) ([]swarm.Service, error)
|
||||
}
|
||||
|
||||
// SwarmDiscovery orchestrates "given an image, which Swarm services should
|
||||
// we update?". Unlike Discovery, it has no filesystem path to check — the
|
||||
// opt-in label on the service itself is the only gate, since Swarm services
|
||||
// have no local compose file to anchor a STACKS_ROOT check against.
|
||||
type SwarmDiscovery struct {
|
||||
cli SwarmDockerClient
|
||||
optInLabel string
|
||||
}
|
||||
|
||||
// NewSwarm returns a SwarmDiscovery bound to the given Docker client and
|
||||
// opt-in label.
|
||||
func NewSwarm(cli SwarmDockerClient, optInLabel string) *SwarmDiscovery {
|
||||
return &SwarmDiscovery{cli: cli, optInLabel: optInLabel}
|
||||
}
|
||||
|
||||
// FindJobs lists Swarm services, filters by image match + opt-in label, and
|
||||
// returns one Job per matching service. Signature matches Discovery.FindJobs
|
||||
// so both satisfy api.Finder.
|
||||
func (d *SwarmDiscovery) FindJobs(ctx context.Context, image string) ([]Job, error) {
|
||||
services, err := d.cli.ServiceList(ctx, types.ServiceListOptions{})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("docker service list: %w", err)
|
||||
}
|
||||
|
||||
var jobs []Job
|
||||
for _, svc := range services {
|
||||
if svc.Spec.TaskTemplate.ContainerSpec == nil {
|
||||
continue
|
||||
}
|
||||
if !ImagesMatch(image, svc.Spec.TaskTemplate.ContainerSpec.Image) {
|
||||
continue
|
||||
}
|
||||
if !HasOptIn(svc.Spec.Labels, d.optInLabel) {
|
||||
continue
|
||||
}
|
||||
|
||||
jobs = append(jobs, Job{
|
||||
Service: svc.Spec.Name,
|
||||
ServiceID: svc.ID,
|
||||
Image: image,
|
||||
})
|
||||
}
|
||||
|
||||
return jobs, nil
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Run tests to verify they pass**
|
||||
|
||||
Run: `go test ./internal/discovery/... -v`
|
||||
Expected: PASS — all discovery tests including the four new `TestSwarmFindJobs_*` cases, no regressions.
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add internal/discovery/swarm.go internal/discovery/swarm_test.go
|
||||
git commit -m "feat(discovery): add SwarmDiscovery to find opted-in Swarm services by image"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: `SwarmExecutor` — run `docker service update --image`
|
||||
|
||||
**Files:**
|
||||
- Create: `internal/updater/swarm_executor.go`
|
||||
- Test: `internal/updater/swarm_executor_test.go`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `discovery.Job{Service, ServiceID, Image, Refused, RefusedReason}` (Task 2/3).
|
||||
- Produces: `updater.SwarmDockerClient` interface, `updater.NewSwarmExecutor(cli SwarmDockerClient) *SwarmExecutor`, method `(*SwarmExecutor) Execute(ctx context.Context, job discovery.Job) error` — satisfies `updater.Executor` (`internal/updater/executor.go`), so `Queue` (`internal/updater/queue.go`) needs no changes.
|
||||
|
||||
- [ ] **Step 1: Verify the Docker SDK method signatures before writing code**
|
||||
|
||||
Run: `go doc github.com/docker/docker/client.Client.ServiceInspectWithRaw` and `go doc github.com/docker/docker/client.Client.ServiceUpdate`
|
||||
|
||||
Expected output shape (confirm before proceeding — adjust the interface in Step 3 if the SDK reports something different):
|
||||
|
||||
```
|
||||
func (cli *Client) ServiceInspectWithRaw(ctx context.Context, serviceID string, options types.ServiceInspectOptions) (swarm.Service, []byte, error)
|
||||
func (cli *Client) ServiceUpdate(ctx context.Context, serviceID string, version swarm.Version, service swarm.ServiceSpec, options types.ServiceUpdateOptions) (types.ServiceUpdateResponse, error)
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Write the failing tests**
|
||||
|
||||
Create `internal/updater/swarm_executor_test.go`:
|
||||
|
||||
```go
|
||||
package updater_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/docker/docker/api/types"
|
||||
"github.com/docker/docker/api/types/swarm"
|
||||
"github.com/shcizo/package-updater/internal/discovery"
|
||||
"github.com/shcizo/package-updater/internal/updater"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
type fakeSwarmDockerClient struct {
|
||||
inspectService swarm.Service
|
||||
inspectErr error
|
||||
updateErr error
|
||||
|
||||
updateCalledServiceID string
|
||||
updateCalledVersion swarm.Version
|
||||
updateCalledSpec swarm.ServiceSpec
|
||||
updateCalledOpts types.ServiceUpdateOptions
|
||||
}
|
||||
|
||||
func (f *fakeSwarmDockerClient) ServiceInspectWithRaw(_ context.Context, _ string, _ types.ServiceInspectOptions) (swarm.Service, []byte, error) {
|
||||
return f.inspectService, nil, f.inspectErr
|
||||
}
|
||||
|
||||
func (f *fakeSwarmDockerClient) ServiceUpdate(_ context.Context, serviceID string, version swarm.Version, spec swarm.ServiceSpec, opts types.ServiceUpdateOptions) (types.ServiceUpdateResponse, error) {
|
||||
f.updateCalledServiceID = serviceID
|
||||
f.updateCalledVersion = version
|
||||
f.updateCalledSpec = spec
|
||||
f.updateCalledOpts = opts
|
||||
return types.ServiceUpdateResponse{}, f.updateErr
|
||||
}
|
||||
|
||||
func mkInspectService(version uint64, image string) swarm.Service {
|
||||
return swarm.Service{
|
||||
ID: "svc-myapp",
|
||||
Version: swarm.Version{Index: version},
|
||||
Spec: swarm.ServiceSpec{
|
||||
Annotations: swarm.Annotations{Name: "myapp_web"},
|
||||
TaskTemplate: swarm.TaskSpec{ContainerSpec: &swarm.ContainerSpec{Image: image}},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func TestSwarmExecutor_UpdatesImageAndVersion(t *testing.T) {
|
||||
fake := &fakeSwarmDockerClient{inspectService: mkInspectService(7, "registry.example.com/myapp:v1")}
|
||||
e := updater.NewSwarmExecutor(fake)
|
||||
|
||||
job := discovery.Job{Service: "myapp_web", ServiceID: "svc-myapp", Image: "registry.example.com/myapp:v2"}
|
||||
err := e.Execute(context.Background(), job)
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "svc-myapp", fake.updateCalledServiceID)
|
||||
require.Equal(t, uint64(7), fake.updateCalledVersion.Index)
|
||||
require.Equal(t, "registry.example.com/myapp:v2", fake.updateCalledSpec.TaskTemplate.ContainerSpec.Image)
|
||||
require.True(t, fake.updateCalledOpts.QueryRegistry)
|
||||
}
|
||||
|
||||
func TestSwarmExecutor_InspectError(t *testing.T) {
|
||||
fake := &fakeSwarmDockerClient{inspectErr: errors.New("service not found")}
|
||||
e := updater.NewSwarmExecutor(fake)
|
||||
err := e.Execute(context.Background(), discovery.Job{ServiceID: "svc-missing", Image: "x:v2"})
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
func TestSwarmExecutor_UpdateError(t *testing.T) {
|
||||
fake := &fakeSwarmDockerClient{
|
||||
inspectService: mkInspectService(1, "registry.example.com/myapp:v1"),
|
||||
updateErr: errors.New("update rejected"),
|
||||
}
|
||||
e := updater.NewSwarmExecutor(fake)
|
||||
err := e.Execute(context.Background(), discovery.Job{ServiceID: "svc-myapp", Image: "registry.example.com/myapp:v2"})
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
func TestSwarmExecutor_RefusedJobIsNotExecuted(t *testing.T) {
|
||||
fake := &fakeSwarmDockerClient{}
|
||||
e := updater.NewSwarmExecutor(fake)
|
||||
job := discovery.Job{ServiceID: "svc-myapp", Image: "x:v2", Refused: true, RefusedReason: "not opted in"}
|
||||
err := e.Execute(context.Background(), job)
|
||||
require.Error(t, err)
|
||||
require.Empty(t, fake.updateCalledServiceID)
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Run tests to verify they fail**
|
||||
|
||||
Run: `go test ./internal/updater/... -run TestSwarmExecutor -v`
|
||||
Expected: FAIL to compile — `updater.NewSwarmExecutor` doesn't exist yet.
|
||||
|
||||
- [ ] **Step 4: Implement**
|
||||
|
||||
Create `internal/updater/swarm_executor.go`:
|
||||
|
||||
```go
|
||||
package updater
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/docker/docker/api/types"
|
||||
"github.com/docker/docker/api/types/swarm"
|
||||
"github.com/shcizo/package-updater/internal/discovery"
|
||||
)
|
||||
|
||||
// SwarmDockerClient is the subset of the Docker SDK SwarmExecutor depends
|
||||
// on. Defined as an interface so tests can supply a fake.
|
||||
type SwarmDockerClient interface {
|
||||
ServiceInspectWithRaw(ctx context.Context, serviceID string, opts types.ServiceInspectOptions) (swarm.Service, []byte, error)
|
||||
ServiceUpdate(ctx context.Context, serviceID string, version swarm.Version, service swarm.ServiceSpec, opts types.ServiceUpdateOptions) (types.ServiceUpdateResponse, error)
|
||||
}
|
||||
|
||||
// SwarmExecutor updates a Swarm service's image via the Docker API,
|
||||
// equivalent to `docker service update --image`.
|
||||
type SwarmExecutor struct {
|
||||
cli SwarmDockerClient
|
||||
}
|
||||
|
||||
// NewSwarmExecutor returns an executor that drives Swarm service updates
|
||||
// through the Docker API.
|
||||
func NewSwarmExecutor(cli SwarmDockerClient) *SwarmExecutor {
|
||||
return &SwarmExecutor{cli: cli}
|
||||
}
|
||||
|
||||
// Execute inspects the service to get its current spec + version (required
|
||||
// by the Docker API as an optimistic-concurrency token), sets the new image
|
||||
// on the container spec, and calls ServiceUpdate. QueryRegistry is set so a
|
||||
// floating tag (e.g. ":latest") resolves to a fresh digest and actually
|
||||
// triggers a rolling update instead of being treated as unchanged.
|
||||
func (e *SwarmExecutor) Execute(ctx context.Context, job discovery.Job) error {
|
||||
if job.Refused {
|
||||
return fmt.Errorf("refused: %s", job.RefusedReason)
|
||||
}
|
||||
|
||||
svc, _, err := e.cli.ServiceInspectWithRaw(ctx, job.ServiceID, types.ServiceInspectOptions{})
|
||||
if err != nil {
|
||||
return fmt.Errorf("inspect service %s: %w", job.ServiceID, err)
|
||||
}
|
||||
|
||||
spec := svc.Spec
|
||||
spec.TaskTemplate.ContainerSpec.Image = job.Image
|
||||
|
||||
if _, err := e.cli.ServiceUpdate(ctx, job.ServiceID, svc.Version, spec, types.ServiceUpdateOptions{QueryRegistry: true}); err != nil {
|
||||
return fmt.Errorf("update service %s: %w", job.ServiceID, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Run tests to verify they pass**
|
||||
|
||||
Run: `go test ./internal/updater/... -v`
|
||||
Expected: PASS — all updater tests including the four new `TestSwarmExecutor_*` cases, no regressions in `TestQueue_*` or `TestWorker_*`.
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add internal/updater/swarm_executor.go internal/updater/swarm_executor_test.go
|
||||
git commit -m "feat(updater): add SwarmExecutor to run docker service update via the Docker API"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: Surface `ServiceID` in the API response
|
||||
|
||||
**Files:**
|
||||
- Modify: `internal/api/types.go`
|
||||
- Modify: `internal/api/handlers.go`
|
||||
- Test: `internal/api/swarm_result_test.go`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `discovery.Job.ServiceID` (Task 2), `updater.Result{Job discovery.Job, Status Status, Error string, DurationMs int64}` (existing, `internal/updater/queue.go`).
|
||||
- Produces: `api.ResultDTO` gains `ServiceID string` (json `service_id`, omitempty) alongside the existing `ComposeFile` field, which stays empty for Swarm jobs (already guarded by the existing `len(res.Job.ConfigFiles) > 0` check).
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
Create `internal/api/swarm_result_test.go`:
|
||||
|
||||
```go
|
||||
package api_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/docker/docker/api/types"
|
||||
"github.com/shcizo/package-updater/internal/api"
|
||||
"github.com/shcizo/package-updater/internal/discovery"
|
||||
"github.com/shcizo/package-updater/internal/updater"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
type swarmResultFinder struct{ jobs []discovery.Job }
|
||||
|
||||
func (f *swarmResultFinder) FindJobs(_ context.Context, _ string) ([]discovery.Job, error) {
|
||||
return f.jobs, nil
|
||||
}
|
||||
|
||||
type swarmResultSubmitter struct{ results []updater.Result }
|
||||
|
||||
func (s *swarmResultSubmitter) Submit(_ context.Context, _ []discovery.Job) []updater.Result {
|
||||
return s.results
|
||||
}
|
||||
|
||||
type swarmResultPinger struct{}
|
||||
|
||||
func (swarmResultPinger) Ping(_ context.Context) (types.Ping, error) { return types.Ping{}, nil }
|
||||
|
||||
func TestUpdate_SwarmJobPopulatesServiceID(t *testing.T) {
|
||||
job := discovery.Job{Service: "myapp_web", ServiceID: "svc123", Image: "registry.example.com/myapp:v2"}
|
||||
finder := &swarmResultFinder{jobs: []discovery.Job{job}}
|
||||
submitter := &swarmResultSubmitter{results: []updater.Result{
|
||||
{Job: job, Status: updater.StatusUpdated, DurationMs: 42},
|
||||
}}
|
||||
h := api.NewHandlers(finder, submitter, swarmResultPinger{}, "v", "c", "b", nil)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/update", strings.NewReader(`{"image":"registry.example.com/myapp"}`))
|
||||
rec := httptest.NewRecorder()
|
||||
h.Update(rec, req)
|
||||
|
||||
require.Equal(t, http.StatusOK, rec.Code)
|
||||
require.Contains(t, rec.Body.String(), `"service_id":"svc123"`)
|
||||
require.Contains(t, rec.Body.String(), `"service":"myapp_web"`)
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `go test ./internal/api/... -run TestUpdate_SwarmJobPopulatesServiceID -v`
|
||||
Expected: FAIL — response body has no `service_id` key (`ResultDTO` has no such field yet).
|
||||
|
||||
- [ ] **Step 3: Implement**
|
||||
|
||||
In `internal/api/types.go`, add the field to `ResultDTO`:
|
||||
|
||||
```go
|
||||
// ResultDTO is one row in UpdateResponse.Results.
|
||||
type ResultDTO struct {
|
||||
Project string `json:"project"`
|
||||
Service string `json:"service"`
|
||||
ComposeFile string `json:"compose_file"`
|
||||
ServiceID string `json:"service_id,omitempty"`
|
||||
Status string `json:"status"`
|
||||
Error string `json:"error,omitempty"`
|
||||
DurationMs int64 `json:"duration_ms"`
|
||||
}
|
||||
```
|
||||
|
||||
In `internal/api/handlers.go`, inside the `Update` handler's results loop, add `ServiceID` to the constructed `ResultDTO`:
|
||||
|
||||
```go
|
||||
resp.Results = append(resp.Results, ResultDTO{
|
||||
Project: res.Job.Project,
|
||||
Service: res.Job.Service,
|
||||
ComposeFile: composeFile,
|
||||
ServiceID: res.Job.ServiceID,
|
||||
Status: string(res.Status),
|
||||
Error: res.Error,
|
||||
DurationMs: res.DurationMs,
|
||||
})
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run tests to verify they pass**
|
||||
|
||||
Run: `go test ./internal/api/... -v`
|
||||
Expected: PASS — the new test plus all existing `TestUpdate_*`, `TestHealthz_*`, `TestVersion` cases (they don't assert on absence of `service_id`, and `omitempty` keeps it out of Compose-mode responses since `ServiceID` is `""` for those).
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add internal/api/types.go internal/api/handlers.go internal/api/swarm_result_test.go
|
||||
git commit -m "feat(api): surface ServiceID in update results for swarm jobs"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 6: Wire `MODE` in `cmd/server/main.go`
|
||||
|
||||
**Files:**
|
||||
- Modify: `cmd/server/main.go`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `config.Config.Mode` (Task 1), `discovery.New` / `discovery.NewSwarm` (Task 3), `updater.NewComposeExecutor` / `updater.NewSwarmExecutor` (Task 4), `api.Finder` / `updater.Executor` (existing).
|
||||
- Produces: nothing new for other tasks to consume — this is the final wiring step.
|
||||
|
||||
There is no unit test for `main.go` in this codebase (it's pure wiring); verification is a successful build plus the full test suite passing, run in Step 3.
|
||||
|
||||
- [ ] **Step 1: Modify the wiring**
|
||||
|
||||
In `cmd/server/main.go`, replace:
|
||||
|
||||
```go
|
||||
disc := discovery.New(dockerCli, cfg.StacksRoot, cfg.OptInLabel)
|
||||
exec := updater.NewComposeExecutor()
|
||||
```
|
||||
|
||||
with:
|
||||
|
||||
```go
|
||||
var finder api.Finder
|
||||
var exec updater.Executor
|
||||
switch cfg.Mode {
|
||||
case "swarm":
|
||||
finder = discovery.NewSwarm(dockerCli, cfg.OptInLabel)
|
||||
exec = updater.NewSwarmExecutor(dockerCli)
|
||||
case "compose":
|
||||
finder = discovery.New(dockerCli, cfg.StacksRoot, cfg.OptInLabel)
|
||||
exec = updater.NewComposeExecutor()
|
||||
default:
|
||||
// Unreachable: config.Load already validates Mode, but guard
|
||||
// here too rather than silently falling through to a nil
|
||||
// finder/exec pair.
|
||||
return fmt.Errorf("unknown MODE %q", cfg.Mode)
|
||||
}
|
||||
```
|
||||
|
||||
Then update the `NewHandlers` call, which currently reads:
|
||||
|
||||
```go
|
||||
handlers := api.NewHandlers(disc, &submitterAdapter{queue: queue, timeout: cfg.UpdateTimeout}, dockerCli, version, commit, buildTime, m)
|
||||
```
|
||||
|
||||
to use `finder` instead of `disc`:
|
||||
|
||||
```go
|
||||
handlers := api.NewHandlers(finder, &submitterAdapter{queue: queue, timeout: cfg.UpdateTimeout}, dockerCli, version, commit, buildTime, m)
|
||||
```
|
||||
|
||||
Note `queue := updater.NewQueue(exec, m)` (a few lines above) already references `exec` — no change needed there since `exec` is now declared by the switch instead of a direct `updater.NewComposeExecutor()` call, but it's the same variable name so the line is unchanged. Also update the startup log line to include the mode:
|
||||
|
||||
```go
|
||||
logger.Info("starting",
|
||||
"version", version, "commit", commit, "port", cfg.Port,
|
||||
"mode", cfg.Mode, "stacks_root", cfg.StacksRoot, "opt_in_label", cfg.OptInLabel,
|
||||
)
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Build**
|
||||
|
||||
Run: `go build ./...`
|
||||
Expected: builds cleanly with no errors.
|
||||
|
||||
- [ ] **Step 3: Run the full test suite**
|
||||
|
||||
Run: `go test ./...`
|
||||
Expected: PASS across all packages — this exercises every task in this plan together for the first time.
|
||||
|
||||
- [ ] **Step 4: Manual smoke check (compose mode, default)**
|
||||
|
||||
Run: `go run ./cmd/server &` (with `UPDATER_API_KEY` set) and check the startup log line shows `"mode":"compose"` (or the equivalent field in whatever log format `internal/logging` renders). Stop the process afterward.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add cmd/server/main.go
|
||||
git commit -m "feat: wire MODE config to select compose or swarm discovery/executor"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 7: Documentation
|
||||
|
||||
**Files:**
|
||||
- Modify: `README.md`
|
||||
- Modify: `CLAUDE.md`
|
||||
|
||||
**Interfaces:** None — documentation only, no code interfaces produced or consumed.
|
||||
|
||||
- [ ] **Step 1: Update `README.md`**
|
||||
|
||||
Read the current README first to match its existing structure (env var table, "known limitations" section, etc. — check what's there before editing rather than guessing the exact heading names). Add:
|
||||
- `MODE` to the environment variable table: `MODE` — `compose` (default) or `swarm`. Selects the update mechanism for the whole deployment; not mixed per-request.
|
||||
- A short "Swarm mode" subsection explaining: services must carry the opt-in label (`OPT_IN_LABEL`, default `se.shcizo.auto-update`) directly on the *service* (`docker service update --label-add se.shcizo.auto-update=true <service>` or set in the stack file's `deploy.labels`), not on the container/task, since Swarm mode reads `Service.Spec.Labels`. Also note the updater must run on/against a Swarm manager node (`docker service update` requires manager API access) — pointing `DOCKER_HOST` at a manager, or scheduling the updater container itself on a manager node with the socket mounted, is the operator's responsibility.
|
||||
- Update/remove the existing "single host only" limitation note (if present) to say Compose mode remains single-host; Swarm mode is the multi-node path but only from a manager node's point of view.
|
||||
|
||||
- [ ] **Step 2: Update `CLAUDE.md`**
|
||||
|
||||
In the "Architecture" section, add a one-line entry for the new files:
|
||||
```
|
||||
- `internal/discovery/swarm.go` — SwarmDiscovery: same FindJobs signature, lists `docker service ls`, gates on service-level opt-in label instead of STACKS_ROOT path-check
|
||||
- `internal/updater/swarm_executor.go` — SwarmExecutor: `docker service update --image` via the Docker API instead of a `docker compose` subprocess
|
||||
```
|
||||
|
||||
In "Design intent (do not break without discussion)", add:
|
||||
```
|
||||
- **Compose mode and Swarm mode are selected once per deployment via `MODE`**, never
|
||||
mixed at request time. Swarm mode's security gate is opt-in label only — there is
|
||||
no STACKS_ROOT-equivalent path check, since Swarm services have no local compose
|
||||
file. Don't add one; don't weaken Compose mode's three-factor gate to match.
|
||||
```
|
||||
|
||||
In "Gotchas", add:
|
||||
```
|
||||
- **Swarm mode requires manager-node API access.** `docker service update` fails
|
||||
with a permission error against a worker-only node. This is an operator/deployment
|
||||
concern (point `DOCKER_HOST` at a manager, or schedule the updater on a manager),
|
||||
not something the code can detect or work around.
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add README.md CLAUDE.md
|
||||
git commit -m "docs: document MODE=swarm support and its opt-in label / manager-node requirements"
|
||||
```
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,373 @@
|
||||
# Master/Agent Architecture — Design
|
||||
|
||||
**Date:** 2026-10-12
|
||||
**Status:** Approved in brainstorming, pending written-spec review
|
||||
|
||||
## Problem
|
||||
|
||||
package-updater today is one instance per Docker daemon: a webhook (`POST /update`)
|
||||
finds opted-in containers by label and runs `docker compose` or `docker service update`.
|
||||
It is stateless and has no UI.
|
||||
|
||||
The goal now is a tool that replaces Watchtower and Shepherd across the whole fleet:
|
||||
|
||||
- One overview of every service on every machine — Swarm services and Compose stacks,
|
||||
which can coexist on the same machine.
|
||||
- Per-service choice, made in the UI, between **manual** (show that an update exists,
|
||||
a human clicks) and **auto** (apply as soon as a new version is detected).
|
||||
- Detect new versions both from CI (webhook) and by polling registries, so images we do
|
||||
not build ourselves are covered too.
|
||||
- See available and completed updates, pick any of the recently seen versions
|
||||
(which also gives rollback).
|
||||
- Live updates in the UI.
|
||||
|
||||
Building this ourselves instead of adopting Komodo or Portainer is a deliberate choice:
|
||||
it is a hobby project, and owning it means it works exactly the way we want.
|
||||
|
||||
## Environment and assumptions
|
||||
|
||||
- Several machines on one internal network, reachable from each other by IP.
|
||||
- One machine (`swarm-mgr`) is a Swarm manager; most services are Swarm stacks
|
||||
(`docker-stack.yml`). At least one Compose stack exists (`slusk`).
|
||||
- A central Postgres already runs and will be used.
|
||||
- Authentik runs and is used per application via OIDC (not via the reverse proxy).
|
||||
- A reverse proxy fronts the web UI.
|
||||
- Stack files are version-controlled. Image references are ad hoc: a mix of `repo:tag`,
|
||||
`repo:tag@digest` and bare `repo@digest`. Many digests were written by a Claude instance
|
||||
on the servers, not by an automated process.
|
||||
- Shepherd and Watchtower currently run and will be removed (see Migration).
|
||||
|
||||
## Decisions
|
||||
|
||||
### Topology: one master, one agent per machine
|
||||
|
||||
- **Master** (one instance): Postgres, web UI, OIDC login, webhook, registry polling,
|
||||
job decisions. Runs as a Swarm service on `swarm-mgr`.
|
||||
- **Agent** (one per machine): owns that machine's Docker daemon via `docker.sock`.
|
||||
Reports inventory and runs update jobs. Runs via plain `docker compose` on each host
|
||||
(not a global Swarm service), because each agent has its own token and a global
|
||||
service would hand every node the same environment.
|
||||
The agent mounts `docker.sock` and `STACKS_ROOT` **read-write** (today it is `:ro`),
|
||||
because it writes `compose.override.yaml` files.
|
||||
|
||||
### Connection: agent dials master over WebSocket, live
|
||||
|
||||
The agent opens a long-lived WebSocket to the master and keeps it alive with reconnect
|
||||
and backoff. Inventory changes are pushed live from Docker events; the master forwards
|
||||
them to browsers. Chosen over the master polling agents over HTTP because live updates
|
||||
are an explicit goal.
|
||||
|
||||
Messages are JSON in a common envelope `{type, id, payload}` with one Go type per
|
||||
message kind, shared in `internal/protocol`. JSON over WebSocket rather than gRPC:
|
||||
same tooling as the browser side, debuggable with `websocat`, no protobuf toolchain.
|
||||
|
||||
### Trust in both directions
|
||||
|
||||
Commands flow from master to agent and can restart anything on that machine, so the
|
||||
agent must authenticate the master, not only the other way round.
|
||||
|
||||
- On first start the master generates a key pair and stores it in Postgres
|
||||
(`master_identity`), so it survives container recreation and is covered by DB backups.
|
||||
- The master serves the agent endpoint on its own port with TLS, using a self-signed
|
||||
certificate for that key. No reverse proxy, no CA.
|
||||
- The agent is configured with `MASTER_URL`, `MASTER_KEY` and `AGENT_TOKEN`.
|
||||
`MASTER_KEY` is the SHA-256 fingerprint of the master's public key. The agent skips
|
||||
normal chain verification and instead accepts the connection only if the
|
||||
certificate's public key hashes to `MASTER_KEY` (same idea as SSH `known_hosts`).
|
||||
The TLS handshake proves the master holds the private key.
|
||||
- `AGENT_TOKEN` is per agent, created in the UI ("Add agent", which shows a ready
|
||||
compose snippet including token and `MASTER_KEY`). Only its hash is stored.
|
||||
Agents can be revoked individually. A leaked token lets someone impersonate an agent
|
||||
(feed false inventory), not command machines.
|
||||
- Losing the master key means every agent needs a new `MASTER_KEY`.
|
||||
|
||||
Plain `ws://` with a signed handshake was rejected: anyone able to hijack the TCP
|
||||
stream after the handshake could inject commands.
|
||||
|
||||
### State: the master remembers, agents report reality
|
||||
|
||||
Agents are the truth for what is running. Postgres holds what the master must remember:
|
||||
desired version, update mode, version history and job history. This deliberately
|
||||
retires the "stateless" property for the master; agents stay stateless.
|
||||
|
||||
### Security gate moves from labels to the UI
|
||||
|
||||
The opt-in label no longer decides what may be updated; the per-service mode in the UI
|
||||
does. Consequences:
|
||||
|
||||
- Every newly discovered service starts as `manual`. Nothing is ever `auto` by default.
|
||||
- Access to the UI is equivalent to being able to restart everything, so login is the
|
||||
most important control (see Authentication).
|
||||
- The `STACKS_ROOT` path check stays on the agent as an independent guard for Compose,
|
||||
and matters more now that the agent needs write access there (override files).
|
||||
- The master cannot reliably tell which service is its own Postgres or Authentik, so
|
||||
they are not locked automatically. Putting them on `auto` is the operator's risk and
|
||||
is documented.
|
||||
|
||||
### Changing the version of a service
|
||||
|
||||
| Kind | How the version is changed | What can revert it |
|
||||
|---|---|---|
|
||||
| Compose | Agent writes the service's image into `compose.override.yaml` next to the stack's compose file (the agent owns this file, preserving other services' entries in it), then `docker compose pull <svc>` + `up -d <svc>`. `docker compose` loads the override automatically, so manual `docker compose up -d` keeps the new version. | Deleting the override file. |
|
||||
| Swarm | `service update` via the Docker API with `repo:tag@digest` (the agent resolves the digest; without it Swarm does not re-pull when the tag is unchanged). | Re-running `docker stack deploy` with a file holding an older reference. |
|
||||
|
||||
The updater always writes `repo:tag@digest`. The stack's own files in git are never
|
||||
edited. Writing version bumps back to git (GitOps style) was considered and deferred:
|
||||
it needs repo write access, YAML rewriting and conflict handling — a different kind of
|
||||
tool. It can be added later as a step after a successful update.
|
||||
|
||||
### Drift
|
||||
|
||||
A service drifts when `actual_ref` differs from `desired_ref`. A service the updater has
|
||||
never touched has no `desired_ref` and cannot drift. Drift is **never corrected
|
||||
automatically**, not even in `auto` mode, because a manual `stack deploy` with a new tag
|
||||
may be intentional. The UI shows the drift and offers "restore desired" or
|
||||
"accept running".
|
||||
|
||||
### Service identity and moves
|
||||
|
||||
A service row has its own ID. Location is an attribute with a unique index:
|
||||
Compose = agent + project + service; Swarm = cluster ID + service name (independent of
|
||||
which manager agent reports it); plain container = agent + container name.
|
||||
|
||||
Identity without the agent was rejected: the same stack legitimately runs on several
|
||||
machines (the agent itself, node exporters), and they would be merged into one history.
|
||||
Automatic merging was rejected for the same reason.
|
||||
|
||||
When a Compose service disappears on one agent and exactly one service with the same
|
||||
project and service name appears on another, the UI suggests "Moved from A?" with a
|
||||
**Merge** action: the old row is moved to the new location with its history, mode and
|
||||
`desired_ref`, and the duplicate is removed. Merging can also be done by hand.
|
||||
|
||||
### Versions: what the system has seen
|
||||
|
||||
Every version the system sees is recorded in `image_versions`, from three sources:
|
||||
webhook (every build CI announces), polling (every new digest under a followed tag) and
|
||||
inventory (whatever is running). The service page lists the 10 most recent versions for
|
||||
the service's repo; picking one creates a manual job to exactly that reference. Picking
|
||||
an older one is a rollback.
|
||||
|
||||
`available_*` on the service remains as "the newest suggestion", which drives the
|
||||
"update available" badge and `auto` mode.
|
||||
|
||||
| Reference format | Polling | Webhook | Pick a version |
|
||||
|---|---|---|---|
|
||||
| `repo:tag` | yes | yes | yes |
|
||||
| `repo:tag@digest` | yes (follows the tag) | yes | yes |
|
||||
| `repo@digest` | no — nothing to follow | yes (own images) | yes (seen versions) |
|
||||
|
||||
Services without a tag are flagged "follows no tag" in the UI. Recommended convention
|
||||
for stack files: `repo:tag@digest`, tag = what to follow.
|
||||
|
||||
## Data model (Postgres)
|
||||
|
||||
**`master_identity`** — single row: private key, created_at.
|
||||
|
||||
**`agents`** — `id`, `name` (set when adding), `token_hash`, `hostname`,
|
||||
`agent_version`, `is_swarm_manager` (reported), `last_seen_at`, `revoked_at`, `created_at`.
|
||||
Online/offline is held in memory only.
|
||||
|
||||
**`services`**
|
||||
- `id`
|
||||
- `kind`: `compose` | `swarm` | `container` (plain containers are shown but cannot be
|
||||
updated in v1)
|
||||
- location: `agent_id`, `compose_project`, `compose_service`, `swarm_cluster_id`,
|
||||
`swarm_service`, `container_name` (populated per kind; unique index per kind)
|
||||
- `image_repo` — normalised, used to match webhooks and polling
|
||||
- `update_mode`: `manual` (default) | `auto`
|
||||
- `desired_ref`
|
||||
- `actual_ref`, `actual_digest`, `status` — last known, kept so the UI has something to
|
||||
show while an agent is offline
|
||||
- `available_ref`, `available_digest`, `available_source` (`webhook` | `poll`),
|
||||
`available_at`
|
||||
- `last_poll_error`
|
||||
- `first_seen_at`, `removed_at` — disappeared services are marked, not deleted
|
||||
|
||||
**`image_versions`** — `repo`, `tag`, `digest`, `source` (`webhook` | `poll` | `running`),
|
||||
`first_seen_at`; unique on (repo, tag, digest).
|
||||
|
||||
**`update_jobs`** — `id`, `service_id`, `from_ref`, `to_ref`,
|
||||
`trigger` (`manual` | `webhook` | `poll`), `requested_by` (OIDC subject for manual),
|
||||
`status` (`queued` | `running` | `succeeded` | `failed`), `output`, `error`,
|
||||
`created_at`, `started_at`, `finished_at`.
|
||||
|
||||
**`sessions`** — web sessions for OIDC logins.
|
||||
|
||||
## Flows
|
||||
|
||||
### Inventory
|
||||
|
||||
1. On connect the agent sends `hello` (hostname, version, Swarm manager or not, cluster
|
||||
ID) and a full `snapshot`.
|
||||
2. The master diffs the snapshot against that agent's rows: new services are inserted as
|
||||
`manual`, missing ones get `removed_at`, changed ones get new `actual_*`. Running
|
||||
references are recorded in `image_versions` with source `running`.
|
||||
3. The agent subscribes to Docker events (container start/stop/die/destroy, service
|
||||
update), debounces bursts (a Compose restart emits many events), re-inspects the
|
||||
affected item and sends `upsert` or `remove`.
|
||||
4. Swarm services are reported only by agents on manager nodes. Agents skip containers
|
||||
that belong to Swarm tasks, so a service is not listed once per node.
|
||||
5. The master pushes changes to browsers over SSE.
|
||||
|
||||
### Update
|
||||
|
||||
1. Triggers: a click in the UI, the webhook, or polling. Webhook and polling always set
|
||||
`available_*` and record the version; they create a job only if the service is `auto`.
|
||||
2. The master inserts an `update_jobs` row and sends `job.start` to the owning agent
|
||||
(for Swarm, a connected manager agent).
|
||||
3. The agent puts the job in its FIFO queue (single worker, as today) and executes it as
|
||||
described under "Changing the version of a service".
|
||||
4. The agent sends `job.progress` and `job.result` with output. On success the master
|
||||
sets `desired_ref` and clears `available_*` if that version is now running.
|
||||
|
||||
### Failure handling
|
||||
|
||||
| Situation | Behaviour |
|
||||
|---|---|
|
||||
| Agent offline when a job is created | Job fails immediately with "agent offline". It is not stored for later — a job that runs hours later surprises more than it helps. `available_*` remains, so the user can retry. |
|
||||
| Connection drops mid-job | The agent finishes the job (its queue is local) and keeps results in memory until the master acknowledges them. They are resent after reconnect along with the snapshot. |
|
||||
| Master restarts mid-job | Jobs in `running` are reconciled when the agent reconnects; jobs the agent does not know are marked `failed` ("lost"). |
|
||||
| New trigger while a job for the same service is queued or running | No new job; only `available_*` is updated. Two jobs never race on one service. |
|
||||
| Job exceeds `UPDATE_TIMEOUT` | The agent cancels it, as today. |
|
||||
| Polling fails for one image | Error stored in `last_poll_error` and shown; other images continue. |
|
||||
|
||||
### Self-protection
|
||||
|
||||
The agent reports its own container and the master knows its own service. Both are shown
|
||||
as "updated manually outside the system", and the master refuses to create jobs for
|
||||
them. An agent recreating itself would kill the job it is running.
|
||||
|
||||
## Registry polling
|
||||
|
||||
- The master polls, not the agents: credentials live in one place and identical
|
||||
references are polled once.
|
||||
- Every distinct `repo:tag` in use is polled, including `repo:tag@digest`. Bare
|
||||
`repo@digest` and locally built images without a registry digest are skipped and
|
||||
shown as such.
|
||||
- Mechanism: `HEAD /v2/<repo>/manifests/<tag>`; the returned digest is compared with
|
||||
`actual_digest`. Use `go-containerregistry` — it handles Docker Hub's token auth and
|
||||
multi-arch indexes.
|
||||
- Interval `POLL_INTERVAL`, default 6h. Believed not to count against Docker Hub pull
|
||||
limits for HEAD requests — verify during implementation.
|
||||
- Credentials: a Docker `config.json` (as written by `docker login`) mounted into the
|
||||
master. No credential UI, no encryption in Postgres.
|
||||
|
||||
## Webhook
|
||||
|
||||
- `POST /update` with `{image, tag}` and a bearer token — the same contract as today, so
|
||||
the existing Gitea action works unchanged; only its `endpoint` moves to the master.
|
||||
- `image` is matched against `image_repo` across all agents using today's normalisation
|
||||
(`NormaliseImage`). Each match gets `available_ref = repo:tag` (source `webhook`) and
|
||||
the version is recorded; `auto` services get a job.
|
||||
- The response waits for the `auto` jobs to finish (bounded by `UPDATE_TIMEOUT`), and
|
||||
returns 5xx if any failed or timed out, so CI still surfaces a broken deploy. `manual` matches are reported as "marked", not as
|
||||
errors.
|
||||
- No match returns **404**, which catches typos in CI configuration.
|
||||
- The webhook never creates services. The first deployment of a stack is done by hand.
|
||||
|
||||
## Authentication and UI
|
||||
|
||||
### Authentication
|
||||
|
||||
Three separate mechanisms that never mix:
|
||||
|
||||
| Who | How | Where |
|
||||
|---|---|---|
|
||||
| People | OIDC authorization code flow with PKCE against Authentik (`coreos/go-oidc`, `x/oauth2`) | Web UI, via reverse proxy |
|
||||
| CI | Shared bearer token | `POST /update`, via reverse proxy |
|
||||
| Agents | Per-agent token + pinned master key over TLS | Dedicated agent port, internal network |
|
||||
|
||||
- Sessions are stored in Postgres; the cookie holds a session ID (`HttpOnly`, `Secure`,
|
||||
`SameSite=Lax`).
|
||||
- The master also requires membership of `ALLOWED_GROUP` from the token's `groups`
|
||||
claim. Restricting the application in Authentik alone is not enough: one misconfigured
|
||||
binding would hand every Authentik user the ability to restart the fleet.
|
||||
- All state-changing UI requests carry a CSRF token.
|
||||
- `/healthz`, `/metrics` and `/version` stay unauthenticated, as today.
|
||||
|
||||
### UI
|
||||
|
||||
Server-rendered Go templates with htmx; live updates via htmx's SSE support. Embedded in
|
||||
the master binary with `go:embed`. No Node build step. Visual design is drafted
|
||||
separately; this spec defines what each page contains.
|
||||
|
||||
1. **Overview** — all services grouped per machine (Swarm services per cluster). Each
|
||||
shows status, running version, the manual/auto toggle, and badges for
|
||||
*update available*, *drifted* and *follows no tag*. Live.
|
||||
2. **Service** — the 10 most recent versions with an *Update* action, job history with
|
||||
logs, drift actions (restore / accept), and *Merge* for moves.
|
||||
3. **Agents** — online/offline, version. *Add agent* produces a compose snippet with
|
||||
token and `MASTER_KEY`. *Revoke*.
|
||||
4. **Jobs** — full history; running jobs stream their log live.
|
||||
|
||||
## Code layout
|
||||
|
||||
- `cmd/master`, `cmd/agent` — replace `cmd/server`.
|
||||
- `internal/protocol` — message types shared by master and agent.
|
||||
- `internal/agent/...` — today's `discovery` and `updater` (queue, Compose and Swarm
|
||||
executors) move here, plus inventory, Docker events and the connection loop.
|
||||
- `internal/master/...` — agent hub, jobs, polling, webhook, OIDC, storage.
|
||||
- `web/` — templates and static assets.
|
||||
|
||||
Existing conventions continue: interfaces defined at the consumer, hand-written fakes,
|
||||
nil-able `*Metrics`, Conventional Commits.
|
||||
|
||||
## Testing
|
||||
|
||||
- `go test ./...` must keep passing without Docker or Postgres.
|
||||
- Master logic (mode decisions, drift, merge, webhook matching, job reconciliation) is
|
||||
tested against a storage interface with a hand-written fake.
|
||||
- Postgres storage is tested by integration tests behind the `integration` build tag,
|
||||
requiring `DATABASE_URL`.
|
||||
- Master and agent are tested together in-process: an `httptest` TLS server, a real
|
||||
WebSocket client and a fake Docker. Covers the `MASTER_KEY` handshake (including a
|
||||
wrong key being rejected), reconnect with snapshot, and jobs completed while
|
||||
disconnected.
|
||||
- Table-driven tests with testify.
|
||||
|
||||
## Migration from today's deployment
|
||||
|
||||
1. Create the database and user in Postgres; register the application and group in
|
||||
Authentik.
|
||||
2. Deploy the master.
|
||||
3. **Remove Shepherd and Watchtower.** Running alongside, they would update services
|
||||
behind the master's back and every such update would show up as drift.
|
||||
4. Add one agent per machine through the UI. Inventory fills in; everything is `manual`.
|
||||
5. Point the Gitea action's `endpoint` at the master. Contract unchanged.
|
||||
6. Remove today's package-updater instance.
|
||||
7. Convert bare `repo@digest` references to `repo:tag@digest`, and set `auto` where
|
||||
wanted. Recommended: record the convention in the self-hosted repo's `CLAUDE.md`
|
||||
so Claude instances on the servers stop writing bare digests.
|
||||
|
||||
`CLAUDE.md` in this repo is updated as part of the implementation: the "stateless" and
|
||||
"token AND opt-in label AND STACKS_ROOT" design-intent entries no longer describe the
|
||||
system, and the `MODE` entry is replaced by the agent model.
|
||||
|
||||
## Implementation phases
|
||||
|
||||
Each phase ends with something that works on its own.
|
||||
|
||||
1. **Protocol, agent and master with inventory.** Pinned-key TLS, per-agent tokens,
|
||||
snapshot and live events, Postgres storage, OIDC login, Overview and Agents pages.
|
||||
The overview works; nothing can be updated yet.
|
||||
2. **Jobs and manual update.** Job protocol, agent queue and executors (Compose override
|
||||
file, Swarm with digest), job history, Service and Jobs pages, self-protection.
|
||||
3. **Webhook and auto mode.** `POST /update` on the master, matching, waiting for
|
||||
auto jobs, mode toggle.
|
||||
4. **Polling and version history.** `image_versions`, registry polling, version picker
|
||||
and rollback.
|
||||
5. **Drift and merge.** Drift detection and actions, move suggestion and merge.
|
||||
|
||||
## Out of scope (later specs)
|
||||
|
||||
- Detecting newer version *tags* for images we do not build (e.g. `gitea:1.27.3` →
|
||||
`1.27.4`, `postgres:17.10`, `loki:3.4.2`). Requires listing tags and ordering them;
|
||||
major upgrades such as Postgres 17 → 18 must never be automatic. Revisit after
|
||||
checking the other two servers' stack files.
|
||||
- Notifications.
|
||||
- Export/import of a fleet configuration to restore a setup. The `services` table
|
||||
already holds what an export needs.
|
||||
- Writing version bumps back to git.
|
||||
- Self-update of master and agents (`internal/selfupdate` is the starting point).
|
||||
- Updating plain (non-Compose, non-Swarm) containers.
|
||||
- Credential management UI for registries.
|
||||
@@ -3,9 +3,14 @@ module github.com/shcizo/package-updater
|
||||
go 1.26.3
|
||||
|
||||
require (
|
||||
github.com/coder/websocket v1.8.15
|
||||
github.com/coreos/go-oidc/v3 v3.21.0
|
||||
github.com/docker/docker v28.5.2+incompatible
|
||||
github.com/jackc/pgx/v5 v5.10.0
|
||||
github.com/prometheus/client_golang v1.23.2
|
||||
github.com/prometheus/client_model v0.6.2
|
||||
github.com/stretchr/testify v1.11.1
|
||||
golang.org/x/oauth2 v0.36.0
|
||||
)
|
||||
|
||||
require (
|
||||
@@ -20,8 +25,12 @@ require (
|
||||
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-jose/go-jose/v4 v4.1.4 // indirect
|
||||
github.com/go-logr/logr v1.4.3 // indirect
|
||||
github.com/go-logr/stdr v1.2.2 // indirect
|
||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||
github.com/jackc/puddle/v2 v2.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
|
||||
@@ -31,7 +40,6 @@ require (
|
||||
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
|
||||
@@ -41,7 +49,9 @@ require (
|
||||
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/sync v0.20.0 // indirect
|
||||
golang.org/x/sys v0.42.0 // indirect
|
||||
golang.org/x/text v0.35.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
|
||||
|
||||
@@ -8,12 +8,17 @@ github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1x
|
||||
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/coder/websocket v1.8.15 h1:6B2JPeOGlpff2Uz6vOEH1Vzpi0iUz20A+lPVhPHtNUA=
|
||||
github.com/coder/websocket v1.8.15/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg=
|
||||
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/coreos/go-oidc/v3 v3.21.0 h1:wZo4Q9Pum8dYEj0eMUPrqR+kvuGkeUplbLpNCkBqoWM=
|
||||
github.com/coreos/go-oidc/v3 v3.21.0/go.mod h1:DYCf24+ncYi+XkIH97GY1+dqoRlbaSI26KVTCI9SrY4=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
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=
|
||||
@@ -26,6 +31,8 @@ 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-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA=
|
||||
github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
|
||||
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=
|
||||
@@ -37,6 +44,14 @@ 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/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
|
||||
github.com/jackc/pgx/v5 v5.10.0 h1:VhSvgU2jSli8o3AqIEOTJr7rZwAEUVo4E4XhR94Zfr0=
|
||||
github.com/jackc/pgx/v5 v5.10.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
|
||||
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
|
||||
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
||||
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=
|
||||
@@ -77,6 +92,9 @@ github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0t
|
||||
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/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
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=
|
||||
@@ -105,6 +123,10 @@ 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/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
|
||||
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
|
||||
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
||||
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
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=
|
||||
@@ -122,6 +144,7 @@ google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j
|
||||
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.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
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=
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
// Package config loads the agent's configuration from environment variables.
|
||||
package config
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
var masterKeyPattern = regexp.MustCompile(`^sha256:[0-9a-f]{64}$`)
|
||||
|
||||
// Config holds the agent's runtime configuration.
|
||||
type Config struct {
|
||||
MasterURL string // MASTER_URL, wss://host:port/agent
|
||||
MasterKey string // MASTER_KEY, fingerprint shown in the master UI
|
||||
Token string // AGENT_TOKEN, shown once when the agent was added
|
||||
SelfID string // SELF_ID, defaults to the hostname (= container ID prefix)
|
||||
LogLevel string // LOG_LEVEL, default info
|
||||
}
|
||||
|
||||
// Load reads and validates the agent configuration. It fails fast so a
|
||||
// misconfigured agent never starts dialling.
|
||||
func Load() (*Config, error) {
|
||||
cfg := &Config{
|
||||
MasterURL: os.Getenv("MASTER_URL"),
|
||||
MasterKey: os.Getenv("MASTER_KEY"),
|
||||
Token: os.Getenv("AGENT_TOKEN"),
|
||||
SelfID: os.Getenv("SELF_ID"),
|
||||
LogLevel: os.Getenv("LOG_LEVEL"),
|
||||
}
|
||||
switch {
|
||||
case cfg.MasterURL == "":
|
||||
return nil, errors.New("MASTER_URL is required")
|
||||
case !strings.HasPrefix(cfg.MasterURL, "wss://"):
|
||||
return nil, errors.New("MASTER_URL must start with wss://")
|
||||
case cfg.MasterKey == "":
|
||||
return nil, errors.New("MASTER_KEY is required")
|
||||
case !masterKeyPattern.MatchString(cfg.MasterKey):
|
||||
return nil, errors.New("MASTER_KEY must look like sha256:<64 hex>")
|
||||
case cfg.Token == "":
|
||||
return nil, errors.New("AGENT_TOKEN is required")
|
||||
}
|
||||
if cfg.SelfID == "" {
|
||||
cfg.SelfID, _ = os.Hostname()
|
||||
}
|
||||
if cfg.LogLevel == "" {
|
||||
cfg.LogLevel = "info"
|
||||
}
|
||||
return cfg, nil
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
const validKey = "sha256:0000000000000000000000000000000000000000000000000000000000000000"
|
||||
|
||||
func setValid(t *testing.T) {
|
||||
t.Setenv("MASTER_URL", "wss://10.0.0.5:8090/agent")
|
||||
t.Setenv("MASTER_KEY", validKey)
|
||||
t.Setenv("AGENT_TOKEN", "pua_abc")
|
||||
t.Setenv("SELF_ID", "")
|
||||
t.Setenv("LOG_LEVEL", "")
|
||||
}
|
||||
|
||||
func TestLoad_Valid(t *testing.T) {
|
||||
setValid(t)
|
||||
cfg, err := Load()
|
||||
require.NoError(t, err)
|
||||
host, _ := os.Hostname()
|
||||
assert.Equal(t, &Config{
|
||||
MasterURL: "wss://10.0.0.5:8090/agent", MasterKey: validKey, Token: "pua_abc",
|
||||
SelfID: host, LogLevel: "info",
|
||||
}, cfg)
|
||||
}
|
||||
|
||||
func TestLoad_Invalid(t *testing.T) {
|
||||
cases := []struct {
|
||||
name, key, value, wantErr string
|
||||
}{
|
||||
{"missing url", "MASTER_URL", "", "MASTER_URL is required"},
|
||||
{"plain ws", "MASTER_URL", "ws://10.0.0.5:8090/agent", "MASTER_URL must start with wss://"},
|
||||
{"missing key", "MASTER_KEY", "", "MASTER_KEY is required"},
|
||||
{"malformed key", "MASTER_KEY", "abc", "MASTER_KEY must look like sha256:<64 hex>"},
|
||||
{"missing token", "AGENT_TOKEN", "", "AGENT_TOKEN is required"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
setValid(t)
|
||||
t.Setenv(c.key, c.value)
|
||||
_, err := Load()
|
||||
assert.ErrorContains(t, err, c.wantErr)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
// Package conn keeps an agent connected to the master: it dials over pinned
|
||||
// TLS, announces itself, sends its inventory, and streams changes until the
|
||||
// connection drops, then reconnects with backoff.
|
||||
package conn
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/coder/websocket"
|
||||
"github.com/coder/websocket/wsjson"
|
||||
|
||||
"github.com/shcizo/package-updater/internal/agent/inventory"
|
||||
"github.com/shcizo/package-updater/internal/pin"
|
||||
"github.com/shcizo/package-updater/internal/protocol"
|
||||
)
|
||||
|
||||
// readLimit caps a single incoming message. The library default (32 KiB)
|
||||
// is too small for the master's future job messages; matches the master.
|
||||
const readLimit = 1 << 20
|
||||
|
||||
// dialTimeout bounds the TLS + WebSocket handshake so a master that accepts
|
||||
// TCP but never answers does not hang the agent forever.
|
||||
const dialTimeout = 30 * time.Second
|
||||
|
||||
// Source supplies what the agent reports. *inventory.Collector satisfies it.
|
||||
type Source interface {
|
||||
Hello(ctx context.Context) (protocol.Hello, error)
|
||||
Snapshot(ctx context.Context) ([]protocol.Service, error)
|
||||
}
|
||||
|
||||
// Config holds connection settings. Token is sent as a bearer header and must
|
||||
// never be logged.
|
||||
type Config struct {
|
||||
URL string // wss://host:port/agent
|
||||
Token string // AGENT_TOKEN
|
||||
Fingerprint string // MASTER_KEY
|
||||
Version string
|
||||
MinBackoff time.Duration // default 1s
|
||||
MaxBackoff time.Duration // default 30s
|
||||
}
|
||||
|
||||
// Agent is the agent's side of the master connection.
|
||||
type Agent struct {
|
||||
cfg Config
|
||||
src Source
|
||||
log *slog.Logger
|
||||
changes chan struct{}
|
||||
}
|
||||
|
||||
// New returns an Agent. Call Run to connect.
|
||||
func New(cfg Config, src Source, log *slog.Logger) *Agent {
|
||||
if cfg.MinBackoff == 0 {
|
||||
cfg.MinBackoff = time.Second
|
||||
}
|
||||
if cfg.MaxBackoff == 0 {
|
||||
cfg.MaxBackoff = 30 * time.Second
|
||||
}
|
||||
return &Agent{cfg: cfg, src: src, log: log, changes: make(chan struct{}, 1)}
|
||||
}
|
||||
|
||||
// Notify tells the agent its inventory may have changed. It never blocks;
|
||||
// several calls before the agent gets to them collapse into one refresh.
|
||||
func (a *Agent) Notify() {
|
||||
select {
|
||||
case a.changes <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
// Run keeps the agent connected until ctx ends, then returns nil.
|
||||
func (a *Agent) Run(ctx context.Context) error {
|
||||
backoff := a.cfg.MinBackoff
|
||||
for {
|
||||
connected, err := a.session(ctx)
|
||||
if ctx.Err() != nil {
|
||||
return nil
|
||||
}
|
||||
if connected {
|
||||
backoff = a.cfg.MinBackoff
|
||||
}
|
||||
a.log.Warn("master_connection_lost", "error", err, "retry_in", backoff.String())
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil
|
||||
case <-time.After(backoff):
|
||||
}
|
||||
backoff = min(backoff*2, a.cfg.MaxBackoff)
|
||||
}
|
||||
}
|
||||
|
||||
// session runs one connection until it fails. connected reports whether the
|
||||
// hello was delivered, so Run resets its backoff only after real progress.
|
||||
func (a *Agent) session(parent context.Context) (connected bool, err error) {
|
||||
httpClient := &http.Client{Transport: &http.Transport{TLSClientConfig: pin.ClientConfig(a.cfg.Fingerprint)}}
|
||||
// The dial context only bounds the handshake; coder/websocket does not
|
||||
// tie the established connection to it, so cancelling afterwards is safe.
|
||||
dialCtx, cancelDial := context.WithTimeout(parent, dialTimeout)
|
||||
c, _, err := websocket.Dial(dialCtx, a.cfg.URL, &websocket.DialOptions{
|
||||
HTTPClient: httpClient,
|
||||
HTTPHeader: http.Header{"Authorization": []string{"Bearer " + a.cfg.Token}},
|
||||
})
|
||||
cancelDial()
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("dial master: %w", err)
|
||||
}
|
||||
defer c.CloseNow()
|
||||
c.SetReadLimit(readLimit)
|
||||
|
||||
ctx, cancel := context.WithCancel(parent)
|
||||
defer cancel()
|
||||
// The master sends nothing yet in this phase, but reading is how a
|
||||
// dropped connection is noticed, so a reader runs for the whole session.
|
||||
go func() {
|
||||
defer cancel()
|
||||
for {
|
||||
if _, _, err := c.Read(ctx); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
hello, err := a.src.Hello(ctx)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
hello.AgentVersion = a.cfg.Version
|
||||
if err := send(ctx, c, protocol.TypeHello, hello); err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
last, err := a.src.Snapshot(ctx)
|
||||
if err != nil {
|
||||
return true, err
|
||||
}
|
||||
if err := send(ctx, c, protocol.TypeSnapshot, protocol.Snapshot{Services: last}); err != nil {
|
||||
return true, err
|
||||
}
|
||||
a.log.Info("master_connected", "services", len(last))
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
_ = c.Close(websocket.StatusNormalClosure, "")
|
||||
return true, ctx.Err()
|
||||
case <-a.changes:
|
||||
next, err := a.src.Snapshot(ctx)
|
||||
if err != nil {
|
||||
return true, err
|
||||
}
|
||||
upserts, removes := inventory.Diff(last, next)
|
||||
for _, s := range upserts {
|
||||
if err := send(ctx, c, protocol.TypeUpsert, protocol.Upsert{Service: s}); err != nil {
|
||||
return true, err
|
||||
}
|
||||
}
|
||||
for _, k := range removes {
|
||||
if err := send(ctx, c, protocol.TypeRemove, protocol.Remove{Key: k}); err != nil {
|
||||
return true, err
|
||||
}
|
||||
}
|
||||
last = next
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func send(ctx context.Context, c *websocket.Conn, typ string, payload any) error {
|
||||
env, err := protocol.Encode(typ, payload)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := wsjson.Write(ctx, c, env); err != nil {
|
||||
return fmt.Errorf("send %s: %w", typ, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
package conn
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/coder/websocket"
|
||||
"github.com/coder/websocket/wsjson"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/shcizo/package-updater/internal/pin"
|
||||
"github.com/shcizo/package-updater/internal/protocol"
|
||||
)
|
||||
|
||||
type fakeMaster struct {
|
||||
srv *httptest.Server
|
||||
fingerprint string
|
||||
envs chan protocol.Envelope
|
||||
auth chan string
|
||||
conns chan *websocket.Conn
|
||||
attempts atomic.Int64
|
||||
}
|
||||
|
||||
func newFakeMaster(t *testing.T) *fakeMaster {
|
||||
t.Helper()
|
||||
key, err := pin.GenerateKey()
|
||||
require.NoError(t, err)
|
||||
cert, err := pin.Certificate(key)
|
||||
require.NoError(t, err)
|
||||
fp, err := pin.Fingerprint(key.Public())
|
||||
require.NoError(t, err)
|
||||
|
||||
fm := &fakeMaster{
|
||||
fingerprint: fp,
|
||||
envs: make(chan protocol.Envelope, 100),
|
||||
auth: make(chan string, 10),
|
||||
conns: make(chan *websocket.Conn, 10),
|
||||
}
|
||||
fm.srv = httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
fm.auth <- r.Header.Get("Authorization")
|
||||
c, err := websocket.Accept(w, r, nil)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
c.SetReadLimit(1 << 20)
|
||||
fm.conns <- c
|
||||
for {
|
||||
var env protocol.Envelope
|
||||
if err := wsjson.Read(r.Context(), c, &env); err != nil {
|
||||
return
|
||||
}
|
||||
fm.envs <- env
|
||||
}
|
||||
}))
|
||||
fm.srv.Config.ConnState = func(_ net.Conn, state http.ConnState) {
|
||||
if state == http.StateNew {
|
||||
fm.attempts.Add(1)
|
||||
}
|
||||
}
|
||||
fm.srv.TLS = &tls.Config{Certificates: []tls.Certificate{cert}}
|
||||
fm.srv.StartTLS()
|
||||
t.Cleanup(fm.srv.Close)
|
||||
return fm
|
||||
}
|
||||
|
||||
func (fm *fakeMaster) url() string { return "wss" + strings.TrimPrefix(fm.srv.URL, "https") }
|
||||
|
||||
func (fm *fakeMaster) next(t *testing.T) protocol.Envelope {
|
||||
t.Helper()
|
||||
select {
|
||||
case e := <-fm.envs:
|
||||
return e
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("timed out waiting for a message from the agent")
|
||||
return protocol.Envelope{}
|
||||
}
|
||||
}
|
||||
|
||||
type fakeSource struct {
|
||||
mu sync.Mutex
|
||||
services []protocol.Service
|
||||
}
|
||||
|
||||
func (f *fakeSource) set(s ...protocol.Service) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
f.services = s
|
||||
}
|
||||
|
||||
func (f *fakeSource) Hello(context.Context) (protocol.Hello, error) {
|
||||
return protocol.Hello{Hostname: "node-2"}, nil
|
||||
}
|
||||
|
||||
func (f *fakeSource) Snapshot(context.Context) ([]protocol.Service, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
return append([]protocol.Service(nil), f.services...), nil
|
||||
}
|
||||
|
||||
var (
|
||||
svcA = protocol.Service{Kind: protocol.KindContainer, ContainerName: "a", Image: "a:1", Status: "running"}
|
||||
svcB = protocol.Service{Kind: protocol.KindContainer, ContainerName: "b", Image: "b:1", Status: "running"}
|
||||
)
|
||||
|
||||
func startAgent(t *testing.T, fm *fakeMaster, fingerprint string, src Source) *Agent {
|
||||
t.Helper()
|
||||
a := New(Config{
|
||||
URL: fm.url(), Token: "pua_test", Fingerprint: fingerprint, Version: "v-test",
|
||||
MinBackoff: 10 * time.Millisecond, MaxBackoff: 50 * time.Millisecond,
|
||||
}, src, slog.New(slog.NewTextHandler(io.Discard, nil)))
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
done := make(chan struct{})
|
||||
go func() { _ = a.Run(ctx); close(done) }()
|
||||
t.Cleanup(func() { cancel(); <-done })
|
||||
return a
|
||||
}
|
||||
|
||||
func TestAgent_SendsHelloThenSnapshotWithToken(t *testing.T) {
|
||||
fm := newFakeMaster(t)
|
||||
src := &fakeSource{}
|
||||
src.set(svcA)
|
||||
startAgent(t, fm, fm.fingerprint, src)
|
||||
|
||||
assert.Equal(t, "Bearer pua_test", <-fm.auth)
|
||||
hello := fm.next(t)
|
||||
require.Equal(t, protocol.TypeHello, hello.Type)
|
||||
h, err := protocol.Decode[protocol.Hello](hello)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, protocol.Hello{Hostname: "node-2", AgentVersion: "v-test"}, h)
|
||||
|
||||
snap := fm.next(t)
|
||||
require.Equal(t, protocol.TypeSnapshot, snap.Type)
|
||||
s, err := protocol.Decode[protocol.Snapshot](snap)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, []protocol.Service{svcA}, s.Services)
|
||||
}
|
||||
|
||||
func TestAgent_NotifySendsDiff(t *testing.T) {
|
||||
fm := newFakeMaster(t)
|
||||
src := &fakeSource{}
|
||||
src.set(svcA)
|
||||
a := startAgent(t, fm, fm.fingerprint, src)
|
||||
fm.next(t) // hello
|
||||
fm.next(t) // snapshot
|
||||
|
||||
src.set(svcB)
|
||||
a.Notify()
|
||||
|
||||
up := fm.next(t)
|
||||
require.Equal(t, protocol.TypeUpsert, up.Type)
|
||||
u, err := protocol.Decode[protocol.Upsert](up)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, svcB, u.Service)
|
||||
|
||||
rm := fm.next(t)
|
||||
require.Equal(t, protocol.TypeRemove, rm.Type)
|
||||
r, err := protocol.Decode[protocol.Remove](rm)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "container|a", r.Key)
|
||||
}
|
||||
|
||||
func TestAgent_ReconnectsAndResendsSnapshot(t *testing.T) {
|
||||
fm := newFakeMaster(t)
|
||||
src := &fakeSource{}
|
||||
src.set(svcA)
|
||||
startAgent(t, fm, fm.fingerprint, src)
|
||||
fm.next(t)
|
||||
fm.next(t)
|
||||
|
||||
first := <-fm.conns
|
||||
first.CloseNow()
|
||||
|
||||
assert.Equal(t, protocol.TypeHello, fm.next(t).Type)
|
||||
assert.Equal(t, protocol.TypeSnapshot, fm.next(t).Type)
|
||||
}
|
||||
|
||||
func TestAgent_RefusesMasterWithWrongKey(t *testing.T) {
|
||||
fm := newFakeMaster(t)
|
||||
other, err := pin.GenerateKey()
|
||||
require.NoError(t, err)
|
||||
wrong, err := pin.Fingerprint(other.Public())
|
||||
require.NoError(t, err)
|
||||
|
||||
startAgent(t, fm, wrong, &fakeSource{})
|
||||
require.Eventually(t, func() bool { return fm.attempts.Load() >= 1 },
|
||||
2*time.Second, 10*time.Millisecond, "the agent must actually try to connect")
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
assert.Empty(t, fm.auth, "the token must never be sent to a server with the wrong key")
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
// Package inventory turns what the local Docker daemon runs into the
|
||||
// services an agent reports to the master.
|
||||
package inventory
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/docker/docker/api/types/container"
|
||||
"github.com/docker/docker/api/types/image"
|
||||
"github.com/docker/docker/api/types/swarm"
|
||||
"github.com/docker/docker/api/types/system"
|
||||
|
||||
"github.com/shcizo/package-updater/internal/imageref"
|
||||
"github.com/shcizo/package-updater/internal/protocol"
|
||||
)
|
||||
|
||||
const (
|
||||
labelProject = "com.docker.compose.project"
|
||||
labelService = "com.docker.compose.service"
|
||||
labelWorkingDir = "com.docker.compose.project.working_dir"
|
||||
labelSwarmTask = "com.docker.swarm.service.id"
|
||||
)
|
||||
|
||||
// DockerClient is the subset of the Docker SDK the collector needs.
|
||||
// *client.Client satisfies it.
|
||||
type DockerClient interface {
|
||||
ContainerList(ctx context.Context, opts container.ListOptions) ([]container.Summary, error)
|
||||
ContainerInspect(ctx context.Context, containerID string) (container.InspectResponse, error)
|
||||
ImageList(ctx context.Context, opts image.ListOptions) ([]image.Summary, error)
|
||||
ServiceList(ctx context.Context, opts swarm.ServiceListOptions) ([]swarm.Service, error)
|
||||
Info(ctx context.Context) (system.Info, error)
|
||||
}
|
||||
|
||||
// Collector reads the local daemon's inventory.
|
||||
type Collector struct {
|
||||
cli DockerClient
|
||||
selfID string
|
||||
}
|
||||
|
||||
// New returns a Collector. selfID is a prefix of the agent's own container
|
||||
// ID (inside a container, the hostname); that container is marked Self.
|
||||
func New(cli DockerClient, selfID string) *Collector {
|
||||
return &Collector{cli: cli, selfID: selfID}
|
||||
}
|
||||
|
||||
// Hello returns the machine facts sent on connect. AgentVersion is left for
|
||||
// the caller to fill in.
|
||||
func (c *Collector) Hello(ctx context.Context) (protocol.Hello, error) {
|
||||
info, err := c.cli.Info(ctx)
|
||||
if err != nil {
|
||||
return protocol.Hello{}, fmt.Errorf("docker info: %w", err)
|
||||
}
|
||||
h := protocol.Hello{Hostname: info.Name, SwarmManager: info.Swarm.ControlAvailable}
|
||||
if info.Swarm.Cluster != nil {
|
||||
h.SwarmClusterID = info.Swarm.Cluster.ID
|
||||
}
|
||||
return h, nil
|
||||
}
|
||||
|
||||
// Snapshot returns every service on this machine, sorted by Key.
|
||||
func (c *Collector) Snapshot(ctx context.Context) ([]protocol.Service, error) {
|
||||
info, err := c.cli.Info(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("docker info: %w", err)
|
||||
}
|
||||
containers, err := c.cli.ContainerList(ctx, container.ListOptions{All: true})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("docker container list: %w", err)
|
||||
}
|
||||
images, err := c.cli.ImageList(ctx, image.ListOptions{})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("docker image list: %w", err)
|
||||
}
|
||||
repoDigests := make(map[string][]string, len(images))
|
||||
for _, img := range images {
|
||||
repoDigests[img.ID] = img.RepoDigests
|
||||
}
|
||||
|
||||
byKey := make(map[string]protocol.Service)
|
||||
for _, ct := range containers {
|
||||
if _, isTask := ct.Labels[labelSwarmTask]; isTask {
|
||||
continue
|
||||
}
|
||||
ref, err := c.imageRef(ctx, ct)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
svc := c.fromContainer(ct, ref, repoDigests[ct.ImageID])
|
||||
if prev, ok := byKey[svc.Key()]; ok {
|
||||
svc = mergeReplicas(prev, svc)
|
||||
}
|
||||
byKey[svc.Key()] = svc
|
||||
}
|
||||
|
||||
if info.Swarm.ControlAvailable {
|
||||
services, err := c.cli.ServiceList(ctx, swarm.ServiceListOptions{Status: true})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("docker service list: %w", err)
|
||||
}
|
||||
for _, s := range services {
|
||||
if s.Spec.TaskTemplate.ContainerSpec == nil {
|
||||
continue
|
||||
}
|
||||
svc := fromSwarmService(s)
|
||||
byKey[svc.Key()] = svc
|
||||
}
|
||||
}
|
||||
|
||||
out := make([]protocol.Service, 0, len(byKey))
|
||||
for _, s := range byKey {
|
||||
out = append(out, s)
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].Key() < out[j].Key() })
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// imageRef returns the image reference the container was created from.
|
||||
// When the tag has since moved to another image, ContainerList reports the
|
||||
// image ID ("sha256:...") instead, so the reference is read from the
|
||||
// container's config.
|
||||
func (c *Collector) imageRef(ctx context.Context, ct container.Summary) (string, error) {
|
||||
if !strings.HasPrefix(ct.Image, "sha256:") {
|
||||
return ct.Image, nil
|
||||
}
|
||||
insp, err := c.cli.ContainerInspect(ctx, ct.ID)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("docker container inspect %s: %w", ct.ID, err)
|
||||
}
|
||||
if insp.Config == nil {
|
||||
return ct.Image, nil
|
||||
}
|
||||
return insp.Config.Image, nil
|
||||
}
|
||||
|
||||
func (c *Collector) fromContainer(ct container.Summary, ref string, repoDigests []string) protocol.Service {
|
||||
svc := protocol.Service{
|
||||
Image: ref,
|
||||
Digest: digestFor(ref, repoDigests),
|
||||
Status: containerStatus(ct.State),
|
||||
Self: c.selfID != "" && strings.HasPrefix(ct.ID, c.selfID),
|
||||
}
|
||||
project, service := ct.Labels[labelProject], ct.Labels[labelService]
|
||||
if project != "" && service != "" {
|
||||
svc.Kind = protocol.KindCompose
|
||||
svc.ComposeProject = project
|
||||
svc.ComposeService = service
|
||||
svc.WorkingDir = ct.Labels[labelWorkingDir]
|
||||
return svc
|
||||
}
|
||||
svc.Kind = protocol.KindContainer
|
||||
svc.ContainerName = ct.ID
|
||||
if len(ct.Names) > 0 {
|
||||
svc.ContainerName = strings.TrimPrefix(ct.Names[0], "/")
|
||||
}
|
||||
return svc
|
||||
}
|
||||
|
||||
func fromSwarmService(s swarm.Service) protocol.Service {
|
||||
ref := s.Spec.TaskTemplate.ContainerSpec.Image
|
||||
return protocol.Service{
|
||||
Kind: protocol.KindSwarm,
|
||||
SwarmService: s.Spec.Name,
|
||||
Image: ref,
|
||||
Digest: imageref.Parse(ref).Digest,
|
||||
Status: swarmStatus(s.ServiceStatus),
|
||||
}
|
||||
}
|
||||
|
||||
// mergeReplicas folds a second container of the same Compose service into
|
||||
// the first: the service counts as running if any replica runs.
|
||||
func mergeReplicas(a, b protocol.Service) protocol.Service {
|
||||
if b.Status == "running" {
|
||||
a.Status = "running"
|
||||
}
|
||||
a.Self = a.Self || b.Self
|
||||
return a
|
||||
}
|
||||
|
||||
// digestFor returns the registry digest the container's image was pulled
|
||||
// as. A reference that already pins a digest wins; otherwise the image's
|
||||
// RepoDigests entry for the same repository is used.
|
||||
func digestFor(ref string, repoDigests []string) string {
|
||||
r := imageref.Parse(ref)
|
||||
if r.Digest != "" {
|
||||
return r.Digest
|
||||
}
|
||||
for _, rd := range repoDigests {
|
||||
if p := imageref.Parse(rd); p.Repo == r.Repo {
|
||||
return p.Digest
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func containerStatus(state string) string {
|
||||
if state == "running" {
|
||||
return "running"
|
||||
}
|
||||
return "stopped"
|
||||
}
|
||||
|
||||
func swarmStatus(st *swarm.ServiceStatus) string {
|
||||
switch {
|
||||
case st == nil:
|
||||
return "unknown"
|
||||
case st.DesiredTasks == 0:
|
||||
return "stopped"
|
||||
case st.RunningTasks >= st.DesiredTasks:
|
||||
return "running"
|
||||
default:
|
||||
return "degraded"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
package inventory
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/docker/docker/api/types/container"
|
||||
"github.com/docker/docker/api/types/image"
|
||||
"github.com/docker/docker/api/types/swarm"
|
||||
"github.com/docker/docker/api/types/system"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/shcizo/package-updater/internal/protocol"
|
||||
)
|
||||
|
||||
type fakeDocker struct {
|
||||
containers []container.Summary
|
||||
images []image.Summary
|
||||
services []swarm.Service
|
||||
info system.Info
|
||||
containerErr error
|
||||
serviceListCalls int
|
||||
inspect map[string]container.InspectResponse
|
||||
inspectErr error
|
||||
}
|
||||
|
||||
func (f *fakeDocker) ContainerList(context.Context, container.ListOptions) ([]container.Summary, error) {
|
||||
return f.containers, f.containerErr
|
||||
}
|
||||
|
||||
func (f *fakeDocker) ContainerInspect(_ context.Context, id string) (container.InspectResponse, error) {
|
||||
return f.inspect[id], f.inspectErr
|
||||
}
|
||||
|
||||
func (f *fakeDocker) ImageList(context.Context, image.ListOptions) ([]image.Summary, error) {
|
||||
return f.images, nil
|
||||
}
|
||||
|
||||
func (f *fakeDocker) ServiceList(context.Context, swarm.ServiceListOptions) ([]swarm.Service, error) {
|
||||
f.serviceListCalls++
|
||||
return f.services, nil
|
||||
}
|
||||
|
||||
func (f *fakeDocker) Info(context.Context) (system.Info, error) { return f.info, nil }
|
||||
|
||||
func composeContainer(id, project, service, img, imageID, state string) container.Summary {
|
||||
return container.Summary{
|
||||
ID: id, Image: img, ImageID: imageID, State: state, Names: []string{"/" + project + "-" + service + "-1"},
|
||||
Labels: map[string]string{
|
||||
labelProject: project,
|
||||
labelService: service,
|
||||
labelWorkingDir: "/home/shcizo/self-hosted/" + project,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func managerInfo() system.Info {
|
||||
var info system.Info
|
||||
info.Name = "swarm-mgr"
|
||||
info.Swarm.ControlAvailable = true
|
||||
info.Swarm.Cluster = &swarm.ClusterInfo{ID: "cluster-1"}
|
||||
return info
|
||||
}
|
||||
|
||||
func TestSnapshot_ComposeContainerWithRepoDigest(t *testing.T) {
|
||||
cli := &fakeDocker{
|
||||
containers: []container.Summary{composeContainer("abc123", "slusk", "web", "gitea.shcizo.se/shcizo/slusk:1.4.2", "sha256:img1", "running")},
|
||||
images: []image.Summary{{ID: "sha256:img1", RepoDigests: []string{"gitea.shcizo.se/shcizo/slusk@sha256:d1"}}},
|
||||
}
|
||||
got, err := New(cli, "").Snapshot(context.Background())
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, []protocol.Service{{
|
||||
Kind: protocol.KindCompose, ComposeProject: "slusk", ComposeService: "web",
|
||||
WorkingDir: "/home/shcizo/self-hosted/slusk",
|
||||
Image: "gitea.shcizo.se/shcizo/slusk:1.4.2", Digest: "sha256:d1", Status: "running",
|
||||
}}, got)
|
||||
}
|
||||
|
||||
func TestSnapshot_MovedTagUsesConfiguredReference(t *testing.T) {
|
||||
ct := composeContainer("abc123", "slusk", "web", "sha256:img1", "sha256:img1", "running")
|
||||
cli := &fakeDocker{
|
||||
containers: []container.Summary{ct},
|
||||
images: []image.Summary{{ID: "sha256:img1", RepoDigests: []string{"gitea.shcizo.se/shcizo/slusk@sha256:d1"}}},
|
||||
inspect: map[string]container.InspectResponse{
|
||||
"abc123": {Config: &container.Config{Image: "gitea.shcizo.se/shcizo/slusk:1.4.2"}},
|
||||
},
|
||||
}
|
||||
got, err := New(cli, "").Snapshot(context.Background())
|
||||
require.NoError(t, err)
|
||||
require.Len(t, got, 1)
|
||||
assert.Equal(t, "gitea.shcizo.se/shcizo/slusk:1.4.2", got[0].Image)
|
||||
assert.Equal(t, "sha256:d1", got[0].Digest)
|
||||
}
|
||||
|
||||
func TestSnapshot_InspectErrorPropagates(t *testing.T) {
|
||||
cli := &fakeDocker{
|
||||
containers: []container.Summary{composeContainer("abc123", "slusk", "web", "sha256:img1", "sha256:img1", "running")},
|
||||
inspectErr: errors.New("gone"),
|
||||
}
|
||||
_, err := New(cli, "").Snapshot(context.Background())
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "docker container inspect")
|
||||
}
|
||||
|
||||
func TestSnapshot_ReplicasMergeAndRunningWins(t *testing.T) {
|
||||
cli := &fakeDocker{containers: []container.Summary{
|
||||
composeContainer("a1", "slusk", "worker", "slusk:1", "sha256:i", "exited"),
|
||||
composeContainer("a2", "slusk", "worker", "slusk:1", "sha256:i", "running"),
|
||||
}}
|
||||
got, err := New(cli, "").Snapshot(context.Background())
|
||||
require.NoError(t, err)
|
||||
require.Len(t, got, 1)
|
||||
assert.Equal(t, "running", got[0].Status)
|
||||
}
|
||||
|
||||
func TestSnapshot_PlainContainerAndSwarmTaskSkipped(t *testing.T) {
|
||||
cli := &fakeDocker{containers: []container.Summary{
|
||||
{ID: "p1", Names: []string{"/adhoc"}, Image: "redis:alpine", State: "exited"},
|
||||
{ID: "t1", Names: []string{"/gitea_gitea.1.x"}, Image: "gitea/gitea:1.27.3", State: "running",
|
||||
Labels: map[string]string{labelSwarmTask: "svc1"}},
|
||||
}}
|
||||
got, err := New(cli, "").Snapshot(context.Background())
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, []protocol.Service{{
|
||||
Kind: protocol.KindContainer, ContainerName: "adhoc", Image: "redis:alpine", Status: "stopped",
|
||||
}}, got)
|
||||
}
|
||||
|
||||
func TestSnapshot_SelfFlag(t *testing.T) {
|
||||
cli := &fakeDocker{containers: []container.Summary{
|
||||
composeContainer("0123456789abcdef", "updater-agent", "agent", "pu:1", "sha256:i", "running"),
|
||||
}}
|
||||
got, err := New(cli, "0123456789ab").Snapshot(context.Background())
|
||||
require.NoError(t, err)
|
||||
require.Len(t, got, 1)
|
||||
assert.True(t, got[0].Self)
|
||||
}
|
||||
|
||||
func TestSnapshot_SwarmServicesOnlyOnManager(t *testing.T) {
|
||||
svc := swarm.Service{
|
||||
Spec: swarm.ServiceSpec{
|
||||
Annotations: swarm.Annotations{Name: "gitea_gitea"},
|
||||
TaskTemplate: swarm.TaskSpec{ContainerSpec: &swarm.ContainerSpec{Image: "gitea/gitea:1.27.3@sha256:87a6"}},
|
||||
},
|
||||
ServiceStatus: &swarm.ServiceStatus{RunningTasks: 1, DesiredTasks: 1},
|
||||
}
|
||||
|
||||
worker := &fakeDocker{services: []swarm.Service{svc}}
|
||||
got, err := New(worker, "").Snapshot(context.Background())
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, got)
|
||||
assert.Equal(t, 0, worker.serviceListCalls, "workers must not call the manager-only API")
|
||||
|
||||
manager := &fakeDocker{services: []swarm.Service{svc}, info: managerInfo()}
|
||||
got, err = New(manager, "").Snapshot(context.Background())
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, []protocol.Service{{
|
||||
Kind: protocol.KindSwarm, SwarmService: "gitea_gitea",
|
||||
Image: "gitea/gitea:1.27.3@sha256:87a6", Digest: "sha256:87a6", Status: "running",
|
||||
}}, got)
|
||||
}
|
||||
|
||||
func TestSwarmStatus(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
in *swarm.ServiceStatus
|
||||
want string
|
||||
}{
|
||||
{"no status", nil, "unknown"},
|
||||
{"scaled to zero", &swarm.ServiceStatus{DesiredTasks: 0}, "stopped"},
|
||||
{"all running", &swarm.ServiceStatus{RunningTasks: 2, DesiredTasks: 2}, "running"},
|
||||
{"some running", &swarm.ServiceStatus{RunningTasks: 1, DesiredTasks: 2}, "degraded"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) { assert.Equal(t, c.want, swarmStatus(c.in)) })
|
||||
}
|
||||
}
|
||||
|
||||
func TestSnapshot_SortedByKey(t *testing.T) {
|
||||
cli := &fakeDocker{containers: []container.Summary{
|
||||
{ID: "2", Names: []string{"/zeta"}, Image: "z", State: "running"},
|
||||
{ID: "1", Names: []string{"/alpha"}, Image: "a", State: "running"},
|
||||
}}
|
||||
got, err := New(cli, "").Snapshot(context.Background())
|
||||
require.NoError(t, err)
|
||||
require.Len(t, got, 2)
|
||||
assert.Equal(t, "alpha", got[0].ContainerName)
|
||||
}
|
||||
|
||||
func TestSnapshot_PropagatesDockerErrors(t *testing.T) {
|
||||
cli := &fakeDocker{containerErr: errors.New("daemon down")}
|
||||
_, err := New(cli, "").Snapshot(context.Background())
|
||||
assert.ErrorContains(t, err, "daemon down")
|
||||
}
|
||||
|
||||
func TestHello(t *testing.T) {
|
||||
got, err := New(&fakeDocker{info: managerInfo()}, "").Hello(context.Background())
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, protocol.Hello{Hostname: "swarm-mgr", SwarmManager: true, SwarmClusterID: "cluster-1"}, got)
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package inventory
|
||||
|
||||
import "github.com/shcizo/package-updater/internal/protocol"
|
||||
|
||||
// Diff compares two snapshots. upserts holds services that are new or
|
||||
// changed in next; removes holds the keys of services missing from next.
|
||||
func Diff(prev, next []protocol.Service) (upserts []protocol.Service, removes []string) {
|
||||
old := make(map[string]protocol.Service, len(prev))
|
||||
for _, s := range prev {
|
||||
old[s.Key()] = s
|
||||
}
|
||||
seen := make(map[string]struct{}, len(next))
|
||||
for _, s := range next {
|
||||
seen[s.Key()] = struct{}{}
|
||||
if o, ok := old[s.Key()]; !ok || o != s {
|
||||
upserts = append(upserts, s)
|
||||
}
|
||||
}
|
||||
for _, s := range prev {
|
||||
if _, ok := seen[s.Key()]; !ok {
|
||||
removes = append(removes, s.Key())
|
||||
}
|
||||
}
|
||||
return upserts, removes
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package inventory
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
||||
"github.com/shcizo/package-updater/internal/protocol"
|
||||
)
|
||||
|
||||
func TestDiff(t *testing.T) {
|
||||
a := protocol.Service{Kind: protocol.KindContainer, ContainerName: "a", Image: "a:1", Status: "running"}
|
||||
b := protocol.Service{Kind: protocol.KindContainer, ContainerName: "b", Image: "b:1", Status: "running"}
|
||||
bStopped := b
|
||||
bStopped.Status = "stopped"
|
||||
c := protocol.Service{Kind: protocol.KindContainer, ContainerName: "c", Image: "c:1", Status: "running"}
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
prev, next []protocol.Service
|
||||
wantUpserts []protocol.Service
|
||||
wantRemoves []string
|
||||
}{
|
||||
{"unchanged", []protocol.Service{a, b}, []protocol.Service{a, b}, nil, nil},
|
||||
{"added", []protocol.Service{a}, []protocol.Service{a, c}, []protocol.Service{c}, nil},
|
||||
{"changed", []protocol.Service{a, b}, []protocol.Service{a, bStopped}, []protocol.Service{bStopped}, nil},
|
||||
{"removed", []protocol.Service{a, b}, []protocol.Service{a}, nil, []string{"container|b"}},
|
||||
{"from empty", nil, []protocol.Service{a}, []protocol.Service{a}, nil},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
ups, rms := Diff(tc.prev, tc.next)
|
||||
assert.Equal(t, tc.wantUpserts, ups)
|
||||
assert.Equal(t, tc.wantRemoves, rms)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package inventory
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/docker/docker/api/types/events"
|
||||
"github.com/docker/docker/api/types/filters"
|
||||
)
|
||||
|
||||
// EventSource is the subset of the Docker SDK Watch needs.
|
||||
// *client.Client satisfies it.
|
||||
type EventSource interface {
|
||||
Events(ctx context.Context, opts events.ListOptions) (<-chan events.Message, <-chan error)
|
||||
}
|
||||
|
||||
// Watch subscribes to container and service lifecycle events and calls
|
||||
// onChange once after each burst has been quiet for the given duration.
|
||||
// A Compose restart emits a dozen events; this turns them into one
|
||||
// inventory refresh. Returns nil when ctx ends, or an error if the event
|
||||
// stream breaks (for example when the daemon restarts) — the caller should
|
||||
// resubscribe and refresh, since events may have been missed.
|
||||
func Watch(ctx context.Context, src EventSource, quiet time.Duration, onChange func()) error {
|
||||
args := filters.NewArgs(
|
||||
filters.Arg("type", string(events.ContainerEventType)),
|
||||
filters.Arg("type", string(events.ServiceEventType)),
|
||||
)
|
||||
for _, action := range []string{"create", "start", "stop", "die", "destroy", "rename", "update", "remove"} {
|
||||
args.Add("event", action)
|
||||
}
|
||||
msgs, errs := src.Events(ctx, events.ListOptions{Filters: args})
|
||||
|
||||
timer := time.NewTimer(quiet)
|
||||
timer.Stop()
|
||||
defer timer.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil
|
||||
case err := <-errs:
|
||||
if ctx.Err() != nil {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("docker events: %w", err)
|
||||
case <-msgs:
|
||||
timer.Reset(quiet)
|
||||
case <-timer.C:
|
||||
onChange()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package inventory
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/docker/docker/api/types/events"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
type fakeEvents struct {
|
||||
msgs chan events.Message
|
||||
errs chan error
|
||||
}
|
||||
|
||||
func newFakeEvents() *fakeEvents {
|
||||
return &fakeEvents{msgs: make(chan events.Message, 10), errs: make(chan error, 1)}
|
||||
}
|
||||
|
||||
func (f *fakeEvents) Events(context.Context, events.ListOptions) (<-chan events.Message, <-chan error) {
|
||||
return f.msgs, f.errs
|
||||
}
|
||||
|
||||
func TestWatch_DebouncesBursts(t *testing.T) {
|
||||
src := newFakeEvents()
|
||||
var calls atomic.Int32
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
done := make(chan error, 1)
|
||||
go func() { done <- Watch(ctx, src, 30*time.Millisecond, func() { calls.Add(1) }) }()
|
||||
|
||||
for range 3 {
|
||||
src.msgs <- events.Message{Type: events.ContainerEventType, Action: "start"}
|
||||
}
|
||||
require.Eventually(t, func() bool { return calls.Load() == 1 }, time.Second, 5*time.Millisecond)
|
||||
|
||||
src.msgs <- events.Message{Type: events.ServiceEventType, Action: "update"}
|
||||
require.Eventually(t, func() bool { return calls.Load() == 2 }, time.Second, 5*time.Millisecond)
|
||||
|
||||
cancel()
|
||||
assert.NoError(t, <-done)
|
||||
}
|
||||
|
||||
func TestWatch_ReturnsStreamError(t *testing.T) {
|
||||
src := newFakeEvents()
|
||||
src.errs <- errors.New("daemon restarted")
|
||||
err := Watch(context.Background(), src, time.Millisecond, func() {})
|
||||
assert.ErrorContains(t, err, "daemon restarted")
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
// Package agenttoken creates and hashes the per-agent tokens agents present
|
||||
// to the master.
|
||||
package agenttoken
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
const prefix = "pua_"
|
||||
|
||||
// Generate returns a new random agent token. Show it to the operator once;
|
||||
// store only Hash(token).
|
||||
func Generate() (string, error) {
|
||||
var b [32]byte
|
||||
if _, err := rand.Read(b[:]); err != nil {
|
||||
return "", fmt.Errorf("generate agent token: %w", err)
|
||||
}
|
||||
return prefix + base64.RawURLEncoding.EncodeToString(b[:]), nil
|
||||
}
|
||||
|
||||
// Hash returns the value stored for token. A plain SHA-256 is sufficient
|
||||
// because tokens carry 256 bits of randomness; salting protects low-entropy
|
||||
// passwords, which these are not.
|
||||
func Hash(token string) string {
|
||||
sum := sha256.Sum256([]byte(token))
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package agenttoken
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestGenerate(t *testing.T) {
|
||||
a, err := Generate()
|
||||
require.NoError(t, err)
|
||||
b, err := Generate()
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Regexp(t, regexp.MustCompile(`^pua_[A-Za-z0-9_-]{43}$`), a)
|
||||
assert.NotEqual(t, a, b)
|
||||
}
|
||||
|
||||
func TestHash(t *testing.T) {
|
||||
assert.Equal(t, Hash("pua_x"), Hash("pua_x"))
|
||||
assert.NotEqual(t, Hash("pua_x"), Hash("pua_y"))
|
||||
assert.Regexp(t, regexp.MustCompile(`^[0-9a-f]{64}$`), Hash("pua_x"))
|
||||
}
|
||||
@@ -56,7 +56,12 @@ func (h *Handlers) Update(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
jobs, err := h.finder.FindJobs(r.Context(), req.Image)
|
||||
requestedImage := req.Image
|
||||
if req.Tag != "" {
|
||||
requestedImage = req.Image + ":" + req.Tag
|
||||
}
|
||||
|
||||
jobs, err := h.finder.FindJobs(r.Context(), requestedImage)
|
||||
if err != nil {
|
||||
writeJSONError(w, http.StatusInternalServerError, "discovery failed: "+err.Error())
|
||||
return
|
||||
@@ -86,6 +91,7 @@ func (h *Handlers) Update(w http.ResponseWriter, r *http.Request) {
|
||||
Project: res.Job.Project,
|
||||
Service: res.Job.Service,
|
||||
ComposeFile: composeFile,
|
||||
ServiceID: res.Job.ServiceID,
|
||||
Status: string(res.Status),
|
||||
Error: res.Error,
|
||||
DurationMs: res.DurationMs,
|
||||
|
||||
@@ -21,9 +21,21 @@ import (
|
||||
type fakeFinder struct {
|
||||
jobs []discovery.Job
|
||||
err error
|
||||
|
||||
// gotImage records the image argument passed to FindJobs, for
|
||||
// assertions on the exact reference the handler built.
|
||||
gotImage string
|
||||
// swarmShaped, when true, makes FindJobs return a Job whose Image
|
||||
// field carries the received image argument, simulating
|
||||
// discovery.SwarmDiscovery.FindJobs.
|
||||
swarmShaped bool
|
||||
}
|
||||
|
||||
func (f *fakeFinder) FindJobs(_ context.Context, _ string) ([]discovery.Job, error) {
|
||||
func (f *fakeFinder) FindJobs(_ context.Context, image string) ([]discovery.Job, error) {
|
||||
f.gotImage = image
|
||||
if f.swarmShaped {
|
||||
return []discovery.Job{{Service: "myapp_web", ServiceID: "svc1", Image: image}}, f.err
|
||||
}
|
||||
return f.jobs, f.err
|
||||
}
|
||||
|
||||
@@ -157,6 +169,42 @@ func TestHealthz_503WhenDockerDown(t *testing.T) {
|
||||
require.Equal(t, http.StatusServiceUnavailable, w.Code)
|
||||
}
|
||||
|
||||
func TestUpdate_FindJobsCalledWithTaggedImage(t *testing.T) {
|
||||
finder := &fakeFinder{}
|
||||
h := api.NewHandlers(finder, &fakeSubmitter{}, &fakePinger{}, "v0.0.0", "abc", "now", nil)
|
||||
body, _ := json.Marshal(api.UpdateRequest{Image: "registry.example.com/myapp", Tag: "v2"})
|
||||
req := httptest.NewRequest(http.MethodPost, "/update", bytes.NewReader(body))
|
||||
w := httptest.NewRecorder()
|
||||
h.Update(w, req)
|
||||
require.Equal(t, "registry.example.com/myapp:v2", finder.gotImage)
|
||||
}
|
||||
|
||||
func TestUpdate_FindJobsCalledWithBareImageWhenNoTag(t *testing.T) {
|
||||
finder := &fakeFinder{}
|
||||
h := api.NewHandlers(finder, &fakeSubmitter{}, &fakePinger{}, "v0.0.0", "abc", "now", nil)
|
||||
body, _ := json.Marshal(api.UpdateRequest{Image: "registry.example.com/myapp"})
|
||||
req := httptest.NewRequest(http.MethodPost, "/update", bytes.NewReader(body))
|
||||
w := httptest.NewRecorder()
|
||||
h.Update(w, req)
|
||||
require.Equal(t, "registry.example.com/myapp", finder.gotImage)
|
||||
}
|
||||
|
||||
func TestUpdate_SwarmModeThreadsTagThroughDiscovery(t *testing.T) {
|
||||
finder := &fakeFinder{swarmShaped: true}
|
||||
h := api.NewHandlers(finder, &fakeSubmitter{}, &fakePinger{}, "v0.0.0", "abc", "now", nil)
|
||||
body, _ := json.Marshal(api.UpdateRequest{Image: "registry.example.com/myapp", Tag: "v2"})
|
||||
req := httptest.NewRequest(http.MethodPost, "/update", bytes.NewReader(body))
|
||||
w := httptest.NewRecorder()
|
||||
h.Update(w, req)
|
||||
require.Equal(t, http.StatusOK, w.Code)
|
||||
// The fake Finder's returned Job.Image (as SwarmDiscovery would build
|
||||
// it) reflects the image argument it received from the handler.
|
||||
// Asserting on the captured argument proves the full request ->
|
||||
// discovery flow carries the tag through to what would reach the
|
||||
// SwarmExecutor.
|
||||
require.Equal(t, "registry.example.com/myapp:v2", finder.gotImage)
|
||||
}
|
||||
|
||||
func TestVersion(t *testing.T) {
|
||||
h := api.NewHandlers(&fakeFinder{}, &fakeSubmitter{}, &fakePinger{}, "v1.2.3", "abcdef", "2026-05-22T00:00:00Z", nil)
|
||||
req := httptest.NewRequest(http.MethodGet, "/version", nil)
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
package api_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/docker/docker/api/types"
|
||||
"github.com/shcizo/package-updater/internal/api"
|
||||
"github.com/shcizo/package-updater/internal/discovery"
|
||||
"github.com/shcizo/package-updater/internal/updater"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
type swarmResultFinder struct{ jobs []discovery.Job }
|
||||
|
||||
func (f *swarmResultFinder) FindJobs(_ context.Context, _ string) ([]discovery.Job, error) {
|
||||
return f.jobs, nil
|
||||
}
|
||||
|
||||
type swarmResultSubmitter struct{ results []updater.Result }
|
||||
|
||||
func (s *swarmResultSubmitter) Submit(_ context.Context, _ []discovery.Job) []updater.Result {
|
||||
return s.results
|
||||
}
|
||||
|
||||
type swarmResultPinger struct{}
|
||||
|
||||
func (swarmResultPinger) Ping(_ context.Context) (types.Ping, error) { return types.Ping{}, nil }
|
||||
|
||||
func TestUpdate_SwarmJobPopulatesServiceID(t *testing.T) {
|
||||
job := discovery.Job{Service: "myapp_web", ServiceID: "svc123", Image: "registry.example.com/myapp:v2"}
|
||||
finder := &swarmResultFinder{jobs: []discovery.Job{job}}
|
||||
submitter := &swarmResultSubmitter{results: []updater.Result{
|
||||
{Job: job, Status: updater.StatusUpdated, DurationMs: 42},
|
||||
}}
|
||||
h := api.NewHandlers(finder, submitter, swarmResultPinger{}, "v", "c", "b", nil)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/update", strings.NewReader(`{"image":"registry.example.com/myapp"}`))
|
||||
rec := httptest.NewRecorder()
|
||||
h.Update(rec, req)
|
||||
|
||||
require.Equal(t, http.StatusOK, rec.Code)
|
||||
require.Contains(t, rec.Body.String(), `"service_id":"svc123"`)
|
||||
require.Contains(t, rec.Body.String(), `"service":"myapp_web"`)
|
||||
}
|
||||
@@ -20,6 +20,7 @@ type ResultDTO struct {
|
||||
Project string `json:"project"`
|
||||
Service string `json:"service"`
|
||||
ComposeFile string `json:"compose_file"`
|
||||
ServiceID string `json:"service_id,omitempty"`
|
||||
Status string `json:"status"`
|
||||
Error string `json:"error,omitempty"`
|
||||
DurationMs int64 `json:"duration_ms"`
|
||||
|
||||
@@ -16,6 +16,7 @@ type Config struct {
|
||||
LogLevel string
|
||||
UpdateTimeout time.Duration
|
||||
OptInLabel string
|
||||
Mode string
|
||||
}
|
||||
|
||||
// Load reads configuration from environment variables, applies defaults,
|
||||
@@ -28,12 +29,17 @@ func Load() (*Config, error) {
|
||||
Port: getenvDefault("PORT", "8080"),
|
||||
LogLevel: getenvDefault("LOG_LEVEL", "info"),
|
||||
OptInLabel: getenvDefault("OPT_IN_LABEL", "se.shcizo.auto-update"),
|
||||
Mode: getenvDefault("MODE", "compose"),
|
||||
}
|
||||
|
||||
if cfg.APIKey == "" {
|
||||
return nil, errors.New("UPDATER_API_KEY is required")
|
||||
}
|
||||
|
||||
if cfg.Mode != "compose" && cfg.Mode != "swarm" {
|
||||
return nil, fmt.Errorf("MODE %q is invalid: must be %q or %q", cfg.Mode, "compose", "swarm")
|
||||
}
|
||||
|
||||
timeoutStr := getenvDefault("UPDATE_TIMEOUT", "5m")
|
||||
d, err := time.ParseDuration(timeoutStr)
|
||||
if err != nil {
|
||||
|
||||
@@ -57,3 +57,26 @@ func TestLoad_InvalidTimeoutErrors(t *testing.T) {
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "UPDATE_TIMEOUT")
|
||||
}
|
||||
|
||||
func TestLoad_DefaultsToComposeMode(t *testing.T) {
|
||||
t.Setenv("UPDATER_API_KEY", "secret")
|
||||
cfg, err := config.Load()
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "compose", cfg.Mode)
|
||||
}
|
||||
|
||||
func TestLoad_AcceptsSwarmMode(t *testing.T) {
|
||||
t.Setenv("UPDATER_API_KEY", "secret")
|
||||
t.Setenv("MODE", "swarm")
|
||||
cfg, err := config.Load()
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "swarm", cfg.Mode)
|
||||
}
|
||||
|
||||
func TestLoad_RejectsInvalidMode(t *testing.T) {
|
||||
t.Setenv("UPDATER_API_KEY", "secret")
|
||||
t.Setenv("MODE", "kubernetes")
|
||||
_, err := config.Load()
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "MODE")
|
||||
}
|
||||
|
||||
@@ -11,10 +11,16 @@ import (
|
||||
|
||||
// Job describes a single (project, service, config_files) update to execute.
|
||||
type Job struct {
|
||||
Project string
|
||||
Service string
|
||||
WorkingDir string
|
||||
Project string
|
||||
Service string
|
||||
WorkingDir string
|
||||
ConfigFiles []string
|
||||
// Image is the full image reference the update request asked for.
|
||||
// Compose jobs ignore it (compose.yml already pins the reference to
|
||||
// pull); Swarm jobs need it to know what to set on the service spec.
|
||||
Image string
|
||||
// ServiceID is the Swarm service ID. Empty for Compose jobs.
|
||||
ServiceID string
|
||||
// Refused is true when the WorkingDir falls outside STACKS_ROOT.
|
||||
// The job is returned so the caller can surface a per-job "refused"
|
||||
// result, but it MUST NOT be executed.
|
||||
@@ -74,6 +80,7 @@ func (d *Discovery) FindJobs(ctx context.Context, image string) ([]Job, error) {
|
||||
Service: cl.Service,
|
||||
WorkingDir: cl.WorkingDir,
|
||||
ConfigFiles: cl.ConfigFiles,
|
||||
Image: image,
|
||||
Refused: refused,
|
||||
RefusedReason: reason,
|
||||
})
|
||||
|
||||
@@ -153,3 +153,20 @@ func TestFindJobs_NonComposeContainerIsSkipped(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
require.Empty(t, jobs)
|
||||
}
|
||||
|
||||
func TestFindJobs_PopulatesImageField(t *testing.T) {
|
||||
fake := &fakeDockerClient{containers: []types.Container{
|
||||
mkContainer("registry.example.com/myapp:v1", mkComposeLabels(
|
||||
"myapp-prod", "web",
|
||||
"/home/shcizo/self-hosted/myapp-prod",
|
||||
"/home/shcizo/self-hosted/myapp-prod/docker-compose.yml",
|
||||
true,
|
||||
)),
|
||||
}}
|
||||
d := discovery.New(fake, "/home/shcizo/self-hosted", "se.shcizo.auto-update")
|
||||
jobs, err := d.FindJobs(context.Background(), "registry.example.com/myapp")
|
||||
require.NoError(t, err)
|
||||
require.Len(t, jobs, 1)
|
||||
require.Equal(t, "registry.example.com/myapp", jobs[0].Image)
|
||||
require.Empty(t, jobs[0].ServiceID)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
package discovery
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/docker/docker/api/types/swarm"
|
||||
)
|
||||
|
||||
// SwarmDockerClient is the subset of the Docker SDK SwarmDiscovery depends
|
||||
// on. Defined as an interface so tests can supply a fake.
|
||||
type SwarmDockerClient interface {
|
||||
ServiceList(ctx context.Context, opts swarm.ServiceListOptions) ([]swarm.Service, error)
|
||||
}
|
||||
|
||||
// SwarmDiscovery orchestrates "given an image, which Swarm services should
|
||||
// we update?". Unlike Discovery, it has no filesystem path to check — the
|
||||
// opt-in label on the service itself is the only gate, since Swarm services
|
||||
// have no local compose file to anchor a STACKS_ROOT check against.
|
||||
type SwarmDiscovery struct {
|
||||
cli SwarmDockerClient
|
||||
optInLabel string
|
||||
}
|
||||
|
||||
// NewSwarm returns a SwarmDiscovery bound to the given Docker client and
|
||||
// opt-in label.
|
||||
func NewSwarm(cli SwarmDockerClient, optInLabel string) *SwarmDiscovery {
|
||||
return &SwarmDiscovery{cli: cli, optInLabel: optInLabel}
|
||||
}
|
||||
|
||||
// FindJobs lists Swarm services, filters by image match + opt-in label, and
|
||||
// returns one Job per matching service. Signature matches Discovery.FindJobs
|
||||
// so both satisfy api.Finder.
|
||||
func (d *SwarmDiscovery) FindJobs(ctx context.Context, image string) ([]Job, error) {
|
||||
services, err := d.cli.ServiceList(ctx, swarm.ServiceListOptions{})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("docker service list: %w", err)
|
||||
}
|
||||
|
||||
var jobs []Job
|
||||
for _, svc := range services {
|
||||
if svc.Spec.TaskTemplate.ContainerSpec == nil {
|
||||
continue
|
||||
}
|
||||
if !ImagesMatch(image, svc.Spec.TaskTemplate.ContainerSpec.Image) {
|
||||
continue
|
||||
}
|
||||
if !HasOptIn(svc.Spec.Labels, d.optInLabel) {
|
||||
continue
|
||||
}
|
||||
|
||||
jobs = append(jobs, Job{
|
||||
Service: svc.Spec.Name,
|
||||
ServiceID: svc.ID,
|
||||
Image: image,
|
||||
})
|
||||
}
|
||||
|
||||
return jobs, nil
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
package discovery_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/docker/docker/api/types/swarm"
|
||||
"github.com/shcizo/package-updater/internal/discovery"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
type fakeSwarmDockerClient struct {
|
||||
services []swarm.Service
|
||||
err error
|
||||
}
|
||||
|
||||
func (f *fakeSwarmDockerClient) ServiceList(_ context.Context, _ swarm.ServiceListOptions) ([]swarm.Service, error) {
|
||||
return f.services, f.err
|
||||
}
|
||||
|
||||
func mkService(id, name, image string, labels map[string]string) swarm.Service {
|
||||
return swarm.Service{
|
||||
ID: id,
|
||||
Spec: swarm.ServiceSpec{
|
||||
Annotations: swarm.Annotations{Name: name, Labels: labels},
|
||||
TaskTemplate: swarm.TaskSpec{
|
||||
ContainerSpec: &swarm.ContainerSpec{Image: image},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func TestSwarmFindJobs_MatchAndOptIn(t *testing.T) {
|
||||
fake := &fakeSwarmDockerClient{services: []swarm.Service{
|
||||
mkService("svc-myapp", "myapp_web", "registry.example.com/myapp:v1", map[string]string{
|
||||
"se.shcizo.auto-update": "true",
|
||||
}),
|
||||
mkService("svc-other", "other_web", "registry.example.com/other:v1", map[string]string{
|
||||
"se.shcizo.auto-update": "true",
|
||||
}),
|
||||
}}
|
||||
d := discovery.NewSwarm(fake, "se.shcizo.auto-update")
|
||||
jobs, err := d.FindJobs(context.Background(), "registry.example.com/myapp")
|
||||
require.NoError(t, err)
|
||||
require.Len(t, jobs, 1)
|
||||
require.Equal(t, "myapp_web", jobs[0].Service)
|
||||
require.Equal(t, "svc-myapp", jobs[0].ServiceID)
|
||||
require.Equal(t, "registry.example.com/myapp", jobs[0].Image)
|
||||
require.False(t, jobs[0].Refused)
|
||||
require.Empty(t, jobs[0].WorkingDir)
|
||||
require.Empty(t, jobs[0].ConfigFiles)
|
||||
}
|
||||
|
||||
func TestSwarmFindJobs_SkipsWithoutOptIn(t *testing.T) {
|
||||
fake := &fakeSwarmDockerClient{services: []swarm.Service{
|
||||
mkService("svc-myapp", "myapp_web", "registry.example.com/myapp:v1", nil),
|
||||
}}
|
||||
d := discovery.NewSwarm(fake, "se.shcizo.auto-update")
|
||||
jobs, err := d.FindJobs(context.Background(), "registry.example.com/myapp")
|
||||
require.NoError(t, err)
|
||||
require.Empty(t, jobs)
|
||||
}
|
||||
|
||||
func TestSwarmFindJobs_DockerError(t *testing.T) {
|
||||
fake := &fakeSwarmDockerClient{err: errors.New("connection refused")}
|
||||
d := discovery.NewSwarm(fake, "se.shcizo.auto-update")
|
||||
_, err := d.FindJobs(context.Background(), "registry.example.com/myapp")
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
func TestSwarmFindJobs_NoContainerSpecIsSkipped(t *testing.T) {
|
||||
fake := &fakeSwarmDockerClient{services: []swarm.Service{
|
||||
{
|
||||
ID: "svc-weird",
|
||||
Spec: swarm.ServiceSpec{
|
||||
Annotations: swarm.Annotations{Name: "weird", Labels: map[string]string{"se.shcizo.auto-update": "true"}},
|
||||
TaskTemplate: swarm.TaskSpec{ContainerSpec: nil},
|
||||
},
|
||||
},
|
||||
}}
|
||||
d := discovery.NewSwarm(fake, "se.shcizo.auto-update")
|
||||
jobs, err := d.FindJobs(context.Background(), "registry.example.com/myapp")
|
||||
require.NoError(t, err)
|
||||
require.Empty(t, jobs)
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
// Package e2e runs a real agent connection against a real hub over TLS with
|
||||
// a pinned key, in one process, with in-memory storage and fake Docker.
|
||||
package e2e
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"io"
|
||||
"log"
|
||||
"log/slog"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/shcizo/package-updater/internal/agent/conn"
|
||||
"github.com/shcizo/package-updater/internal/agenttoken"
|
||||
"github.com/shcizo/package-updater/internal/master/events"
|
||||
"github.com/shcizo/package-updater/internal/master/hub"
|
||||
"github.com/shcizo/package-updater/internal/master/model"
|
||||
"github.com/shcizo/package-updater/internal/pin"
|
||||
"github.com/shcizo/package-updater/internal/protocol"
|
||||
)
|
||||
|
||||
type memStore struct {
|
||||
mu sync.Mutex
|
||||
agents map[string]model.Agent
|
||||
lookups int
|
||||
snapshots int
|
||||
services map[string]protocol.Service
|
||||
}
|
||||
|
||||
func (m *memStore) AgentByTokenHash(_ context.Context, hash string) (model.Agent, error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.lookups++
|
||||
a, ok := m.agents[hash]
|
||||
if !ok {
|
||||
return model.Agent{}, model.ErrNotFound
|
||||
}
|
||||
return a, nil
|
||||
}
|
||||
|
||||
func (m *memStore) RecordHello(context.Context, int64, protocol.Hello, time.Time) error { return nil }
|
||||
|
||||
func (m *memStore) ApplySnapshot(_ context.Context, _ int64, _ protocol.Hello, s []protocol.Service, _ time.Time) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.snapshots++
|
||||
m.services = map[string]protocol.Service{}
|
||||
for _, x := range s {
|
||||
m.services[x.Key()] = x
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *memStore) UpsertService(_ context.Context, _ int64, _ string, s protocol.Service, _ time.Time) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.services[s.Key()] = s
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *memStore) RemoveService(_ context.Context, _ int64, _, key string, _ time.Time) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
delete(m.services, key)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *memStore) MarkSeen(context.Context, int64, time.Time) error { return nil }
|
||||
|
||||
func (m *memStore) keys() []string {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
var out []string
|
||||
for k := range m.services {
|
||||
out = append(out, k)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
type staticSource struct {
|
||||
mu sync.Mutex
|
||||
services []protocol.Service
|
||||
}
|
||||
|
||||
func (s *staticSource) Hello(context.Context) (protocol.Hello, error) {
|
||||
return protocol.Hello{Hostname: "node-2"}, nil
|
||||
}
|
||||
|
||||
func (s *staticSource) Snapshot(context.Context) ([]protocol.Service, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return append([]protocol.Service(nil), s.services...), nil
|
||||
}
|
||||
|
||||
func (s *staticSource) set(v ...protocol.Service) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.services = v
|
||||
}
|
||||
|
||||
// testMaster is a running TLS test server with the hub mounted on /agent.
|
||||
type testMaster struct {
|
||||
url string
|
||||
fingerprint string
|
||||
hub *hub.Hub
|
||||
attempts *atomic.Int64 // TCP connections accepted by the server
|
||||
}
|
||||
|
||||
func startMaster(t *testing.T, store *memStore) testMaster {
|
||||
t.Helper()
|
||||
key, err := pin.GenerateKey()
|
||||
require.NoError(t, err)
|
||||
cert, err := pin.Certificate(key)
|
||||
require.NoError(t, err)
|
||||
fingerprint, err := pin.Fingerprint(key.Public())
|
||||
require.NoError(t, err)
|
||||
|
||||
h := hub.New(store, events.NewBroker(), slog.New(slog.NewTextHandler(io.Discard, nil)))
|
||||
mux := http.NewServeMux()
|
||||
mux.Handle("GET /agent", h)
|
||||
attempts := &atomic.Int64{}
|
||||
srv := httptest.NewUnstartedServer(mux)
|
||||
srv.Config.ErrorLog = log.New(io.Discard, "", 0)
|
||||
srv.Config.ConnState = func(_ net.Conn, s http.ConnState) {
|
||||
if s == http.StateNew {
|
||||
attempts.Add(1)
|
||||
}
|
||||
}
|
||||
srv.TLS = &tls.Config{Certificates: []tls.Certificate{cert}, MinVersion: tls.VersionTLS13}
|
||||
srv.StartTLS()
|
||||
t.Cleanup(srv.Close)
|
||||
return testMaster{url: "wss" + strings.TrimPrefix(srv.URL, "https") + "/agent", fingerprint: fingerprint, hub: h, attempts: attempts}
|
||||
}
|
||||
|
||||
func runAgent(t *testing.T, url, fingerprint, token string, src conn.Source) *conn.Agent {
|
||||
t.Helper()
|
||||
a := conn.New(conn.Config{URL: url, Token: token, Fingerprint: fingerprint, Version: "e2e",
|
||||
MinBackoff: 10 * time.Millisecond, MaxBackoff: 50 * time.Millisecond,
|
||||
}, src, slog.New(slog.NewTextHandler(io.Discard, nil)))
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
done := make(chan struct{})
|
||||
go func() { _ = a.Run(ctx); close(done) }()
|
||||
t.Cleanup(func() { cancel(); <-done })
|
||||
return a
|
||||
}
|
||||
|
||||
var (
|
||||
web = protocol.Service{Kind: protocol.KindCompose, ComposeProject: "slusk", ComposeService: "web", Image: "slusk:1", Status: "running"}
|
||||
cache = protocol.Service{Kind: protocol.KindContainer, ContainerName: "cache", Image: "redis:alpine", Status: "running"}
|
||||
)
|
||||
|
||||
func TestInventoryFlowsFromAgentToStore(t *testing.T) {
|
||||
store := &memStore{agents: map[string]model.Agent{agenttoken.Hash("pua_ok"): {ID: 2, Name: "node-2"}}}
|
||||
m := startMaster(t, store)
|
||||
src := &staticSource{}
|
||||
src.set(web)
|
||||
agent := runAgent(t, m.url, m.fingerprint, "pua_ok", src)
|
||||
|
||||
require.Eventually(t, func() bool { return len(store.keys()) == 1 }, 2*time.Second, 10*time.Millisecond)
|
||||
|
||||
src.set(cache)
|
||||
agent.Notify()
|
||||
require.Eventually(t, func() bool {
|
||||
k := store.keys()
|
||||
return len(k) == 1 && k[0] == "container|cache"
|
||||
}, 2*time.Second, 10*time.Millisecond)
|
||||
}
|
||||
|
||||
func TestAgentWithWrongKeyNeverReachesHub(t *testing.T) {
|
||||
store := &memStore{agents: map[string]model.Agent{agenttoken.Hash("pua_ok"): {ID: 2, Name: "node-2"}}}
|
||||
m := startMaster(t, store)
|
||||
other, err := pin.GenerateKey()
|
||||
require.NoError(t, err)
|
||||
wrong, err := pin.Fingerprint(other.Public())
|
||||
require.NoError(t, err)
|
||||
|
||||
runAgent(t, m.url, wrong, "pua_ok", &staticSource{})
|
||||
require.Eventually(t, func() bool { return m.attempts.Load() >= 1 }, 2*time.Second, 10*time.Millisecond, "the agent must actually try to connect")
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
store.mu.Lock()
|
||||
defer store.mu.Unlock()
|
||||
assert.Zero(t, store.lookups, "TLS must fail before the token is ever presented")
|
||||
}
|
||||
|
||||
func TestRevokedAgentIsRejected(t *testing.T) {
|
||||
store := &memStore{agents: map[string]model.Agent{
|
||||
agenttoken.Hash("pua_old"): {ID: 3, Name: "old", RevokedAt: func() *time.Time { n := time.Now(); return &n }()},
|
||||
}}
|
||||
m := startMaster(t, store)
|
||||
src := &staticSource{}
|
||||
src.set(web)
|
||||
runAgent(t, m.url, m.fingerprint, "pua_old", src)
|
||||
|
||||
require.Eventually(t, func() bool {
|
||||
store.mu.Lock()
|
||||
defer store.mu.Unlock()
|
||||
return store.lookups >= 2
|
||||
}, 2*time.Second, 10*time.Millisecond, "the agent keeps retrying")
|
||||
assert.Empty(t, store.keys())
|
||||
}
|
||||
|
||||
func TestAgentReconnectsAndResendsSnapshot(t *testing.T) {
|
||||
store := &memStore{agents: map[string]model.Agent{agenttoken.Hash("pua_ok"): {ID: 2, Name: "node-2"}}}
|
||||
m := startMaster(t, store)
|
||||
src := &staticSource{}
|
||||
src.set(web)
|
||||
runAgent(t, m.url, m.fingerprint, "pua_ok", src)
|
||||
|
||||
snapshots := func() int {
|
||||
store.mu.Lock()
|
||||
defer store.mu.Unlock()
|
||||
return store.snapshots
|
||||
}
|
||||
require.Eventually(t, func() bool { return snapshots() >= 1 }, 2*time.Second, 10*time.Millisecond)
|
||||
|
||||
m.hub.Disconnect(2)
|
||||
|
||||
require.Eventually(t, func() bool { return snapshots() >= 2 }, 2*time.Second, 10*time.Millisecond, "agent reconnects and sends a fresh snapshot")
|
||||
assert.Len(t, store.keys(), 1)
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
// Package imageref splits container image references into repository, tag
|
||||
// and digest.
|
||||
package imageref
|
||||
|
||||
import "strings"
|
||||
|
||||
// Ref is an image reference split into its parts. Tag and Digest are empty
|
||||
// when the reference has none.
|
||||
type Ref struct {
|
||||
Repo string
|
||||
Tag string
|
||||
Digest string
|
||||
}
|
||||
|
||||
// Parse splits ref into repository, tag and digest. A colon before the last
|
||||
// "/" belongs to a registry host and port ("localhost:5000/foo"), not a tag.
|
||||
func Parse(ref string) Ref {
|
||||
var r Ref
|
||||
if at := strings.Index(ref, "@"); at >= 0 {
|
||||
r.Digest = ref[at+1:]
|
||||
ref = ref[:at]
|
||||
}
|
||||
slash := strings.LastIndex(ref, "/")
|
||||
last := ref[slash+1:]
|
||||
if colon := strings.Index(last, ":"); colon >= 0 {
|
||||
r.Tag = last[colon+1:]
|
||||
ref = ref[:slash+1+colon]
|
||||
}
|
||||
r.Repo = ref
|
||||
return r
|
||||
}
|
||||
|
||||
// String reassembles the reference as repo[:tag][@digest].
|
||||
func (r Ref) String() string {
|
||||
s := r.Repo
|
||||
if r.Tag != "" {
|
||||
s += ":" + r.Tag
|
||||
}
|
||||
if r.Digest != "" {
|
||||
s += "@" + r.Digest
|
||||
}
|
||||
return s
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package imageref
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestParse(t *testing.T) {
|
||||
cases := []struct {
|
||||
in string
|
||||
want Ref
|
||||
}{
|
||||
{"postgres", Ref{Repo: "postgres"}},
|
||||
{"postgres:17.10", Ref{Repo: "postgres", Tag: "17.10"}},
|
||||
{"gitea/gitea:1.27.3@sha256:87a6", Ref{Repo: "gitea/gitea", Tag: "1.27.3", Digest: "sha256:87a6"}},
|
||||
{"lscr.io/linuxserver/jellyfin@sha256:0f42", Ref{Repo: "lscr.io/linuxserver/jellyfin", Digest: "sha256:0f42"}},
|
||||
{"localhost:5000/foo", Ref{Repo: "localhost:5000/foo"}},
|
||||
{"localhost:5000/foo:v1", Ref{Repo: "localhost:5000/foo", Tag: "v1"}},
|
||||
{"localhost:5000/foo:v1@sha256:ab", Ref{Repo: "localhost:5000/foo", Tag: "v1", Digest: "sha256:ab"}},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.in, func(t *testing.T) {
|
||||
got := Parse(c.in)
|
||||
assert.Equal(t, c.want, got)
|
||||
assert.Equal(t, c.in, got.String(), "String must round-trip")
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
// Package config loads the master's configuration from environment variables.
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Config holds the master's runtime configuration.
|
||||
type Config struct {
|
||||
DatabaseURL string
|
||||
PublicURL string // where people reach the UI, without trailing slash
|
||||
AgentURL string // what agents dial, shown in the add-agent snippet
|
||||
AgentImage string
|
||||
OIDCIssuer string
|
||||
OIDCClientID string
|
||||
OIDCClientSecret string
|
||||
AllowedGroup string
|
||||
Port string
|
||||
AgentPort string
|
||||
LogLevel string
|
||||
}
|
||||
|
||||
// Load reads and validates the master configuration, failing fast on
|
||||
// anything missing so a half-configured master never starts.
|
||||
func Load() (*Config, error) {
|
||||
cfg := &Config{
|
||||
DatabaseURL: os.Getenv("DATABASE_URL"),
|
||||
PublicURL: strings.TrimSuffix(os.Getenv("PUBLIC_URL"), "/"),
|
||||
AgentURL: os.Getenv("AGENT_URL"),
|
||||
AgentImage: getenvDefault("AGENT_IMAGE", "gitea.shcizo.se/shcizo/package-updater:latest"),
|
||||
OIDCIssuer: os.Getenv("OIDC_ISSUER"),
|
||||
OIDCClientID: os.Getenv("OIDC_CLIENT_ID"),
|
||||
OIDCClientSecret: os.Getenv("OIDC_CLIENT_SECRET"),
|
||||
AllowedGroup: os.Getenv("ALLOWED_GROUP"),
|
||||
Port: getenvDefault("PORT", "8080"),
|
||||
AgentPort: getenvDefault("AGENT_PORT", "8090"),
|
||||
LogLevel: getenvDefault("LOG_LEVEL", "info"),
|
||||
}
|
||||
for _, r := range []struct{ name, value string }{
|
||||
{"DATABASE_URL", cfg.DatabaseURL},
|
||||
{"PUBLIC_URL", cfg.PublicURL},
|
||||
{"AGENT_URL", cfg.AgentURL},
|
||||
{"OIDC_ISSUER", cfg.OIDCIssuer},
|
||||
{"OIDC_CLIENT_ID", cfg.OIDCClientID},
|
||||
{"OIDC_CLIENT_SECRET", cfg.OIDCClientSecret},
|
||||
{"ALLOWED_GROUP", cfg.AllowedGroup},
|
||||
} {
|
||||
if r.value == "" {
|
||||
return nil, fmt.Errorf("%s is required", r.name)
|
||||
}
|
||||
}
|
||||
if !strings.HasPrefix(cfg.PublicURL, "http://") && !strings.HasPrefix(cfg.PublicURL, "https://") {
|
||||
return nil, fmt.Errorf("PUBLIC_URL must start with http:// or https://")
|
||||
}
|
||||
if !strings.HasPrefix(cfg.AgentURL, "wss://") {
|
||||
return nil, fmt.Errorf("AGENT_URL must start with wss://")
|
||||
}
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
func getenvDefault(key, fallback string) string {
|
||||
if v := os.Getenv(key); v != "" {
|
||||
return v
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
var required = map[string]string{
|
||||
"DATABASE_URL": "postgres://u:p@db/pu",
|
||||
"PUBLIC_URL": "https://updater.example.com",
|
||||
"AGENT_URL": "wss://10.0.0.5:8090/agent",
|
||||
"OIDC_ISSUER": "https://auth.example.com/application/o/pu/",
|
||||
"OIDC_CLIENT_ID": "id",
|
||||
"OIDC_CLIENT_SECRET": "secret",
|
||||
"ALLOWED_GROUP": "updater-admins",
|
||||
}
|
||||
|
||||
func setAll(t *testing.T) {
|
||||
for k, v := range required {
|
||||
t.Setenv(k, v)
|
||||
}
|
||||
for _, k := range []string{"PORT", "AGENT_PORT", "AGENT_IMAGE", "LOG_LEVEL"} {
|
||||
t.Setenv(k, "")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoad_Defaults(t *testing.T) {
|
||||
setAll(t)
|
||||
cfg, err := Load()
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "8080", cfg.Port)
|
||||
assert.Equal(t, "8090", cfg.AgentPort)
|
||||
assert.Equal(t, "gitea.shcizo.se/shcizo/package-updater:latest", cfg.AgentImage)
|
||||
assert.Equal(t, "info", cfg.LogLevel)
|
||||
assert.Equal(t, "updater-admins", cfg.AllowedGroup)
|
||||
}
|
||||
|
||||
func TestLoad_EachRequiredVariable(t *testing.T) {
|
||||
for key := range required {
|
||||
t.Run(key, func(t *testing.T) {
|
||||
setAll(t)
|
||||
t.Setenv(key, "")
|
||||
_, err := Load()
|
||||
assert.ErrorContains(t, err, key+" is required")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoad_URLFormats(t *testing.T) {
|
||||
cases := []struct{ key, value, wantErr string }{
|
||||
{"PUBLIC_URL", "updater.example.com", "PUBLIC_URL must start with http:// or https://"},
|
||||
{"AGENT_URL", "ws://10.0.0.5:8090/agent", "AGENT_URL must start with wss://"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.key, func(t *testing.T) {
|
||||
setAll(t)
|
||||
t.Setenv(c.key, c.value)
|
||||
_, err := Load()
|
||||
assert.ErrorContains(t, err, c.wantErr)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
// Package events fans out "something changed" signals from the agent hub to
|
||||
// open browser streams.
|
||||
package events
|
||||
|
||||
import "sync"
|
||||
|
||||
// Topic names what changed. Subscribers refetch that part of the UI.
|
||||
type Topic string
|
||||
|
||||
// Topics.
|
||||
const (
|
||||
Services Topic = "services"
|
||||
Agents Topic = "agents"
|
||||
)
|
||||
|
||||
// Broker is an in-process publish/subscribe hub.
|
||||
type Broker struct {
|
||||
mu sync.Mutex
|
||||
subs map[chan Topic]struct{}
|
||||
}
|
||||
|
||||
// NewBroker returns an empty Broker.
|
||||
func NewBroker() *Broker {
|
||||
return &Broker{subs: make(map[chan Topic]struct{})}
|
||||
}
|
||||
|
||||
// Subscribe returns a channel of topics and a cancel function that must be
|
||||
// called when the subscriber goes away. Cancel is safe to call twice.
|
||||
func (b *Broker) Subscribe() (<-chan Topic, func()) {
|
||||
ch := make(chan Topic, 16)
|
||||
b.mu.Lock()
|
||||
b.subs[ch] = struct{}{}
|
||||
b.mu.Unlock()
|
||||
var once sync.Once
|
||||
return ch, func() {
|
||||
once.Do(func() {
|
||||
b.mu.Lock()
|
||||
delete(b.subs, ch)
|
||||
b.mu.Unlock()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Publish signals t to every subscriber. A subscriber whose buffer is full is
|
||||
// skipped rather than blocking the agent hub; it already has signals queued
|
||||
// and will refetch current state when it processes them.
|
||||
func (b *Broker) Publish(t Topic) {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
for ch := range b.subs {
|
||||
select {
|
||||
case ch <- t:
|
||||
default:
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package events
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestBroker_DeliversToAllSubscribers(t *testing.T) {
|
||||
b := NewBroker()
|
||||
a, cancelA := b.Subscribe()
|
||||
defer cancelA()
|
||||
c, cancelC := b.Subscribe()
|
||||
defer cancelC()
|
||||
|
||||
b.Publish(Services)
|
||||
assert.Equal(t, Services, <-a)
|
||||
assert.Equal(t, Services, <-c)
|
||||
}
|
||||
|
||||
func TestBroker_CancelStopsDelivery(t *testing.T) {
|
||||
b := NewBroker()
|
||||
ch, cancel := b.Subscribe()
|
||||
cancel()
|
||||
cancel() // safe to call twice
|
||||
b.Publish(Agents)
|
||||
assert.Empty(t, ch)
|
||||
}
|
||||
|
||||
func TestBroker_SlowSubscriberDoesNotBlockPublish(t *testing.T) {
|
||||
b := NewBroker()
|
||||
_, cancel := b.Subscribe()
|
||||
defer cancel()
|
||||
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
defer close(done)
|
||||
for range 1000 {
|
||||
b.Publish(Services) // must return even though nobody reads
|
||||
}
|
||||
}()
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("Publish blocked on a slow subscriber")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
// Package hub accepts agent connections, authenticates them, and turns their
|
||||
// messages into stored inventory and live UI signals.
|
||||
package hub
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/coder/websocket"
|
||||
"github.com/coder/websocket/wsjson"
|
||||
|
||||
"github.com/shcizo/package-updater/internal/agenttoken"
|
||||
"github.com/shcizo/package-updater/internal/master/events"
|
||||
"github.com/shcizo/package-updater/internal/master/model"
|
||||
"github.com/shcizo/package-updater/internal/protocol"
|
||||
)
|
||||
|
||||
// readLimit caps one incoming message. The library default of 32 KiB is too
|
||||
// small for a full snapshot from a busy machine.
|
||||
const readLimit = 1 << 20
|
||||
|
||||
// Store is the storage the hub writes to. *store.Store satisfies it.
|
||||
type Store interface {
|
||||
AgentByTokenHash(ctx context.Context, hash string) (model.Agent, error)
|
||||
RecordHello(ctx context.Context, agentID int64, h protocol.Hello, at time.Time) error
|
||||
ApplySnapshot(ctx context.Context, agentID int64, h protocol.Hello, services []protocol.Service, at time.Time) error
|
||||
UpsertService(ctx context.Context, agentID int64, clusterID string, svc protocol.Service, at time.Time) error
|
||||
RemoveService(ctx context.Context, agentID int64, clusterID, agentKey string, at time.Time) error
|
||||
MarkSeen(ctx context.Context, agentID int64, at time.Time) error
|
||||
}
|
||||
|
||||
// Publisher receives "something changed" signals. *events.Broker satisfies it.
|
||||
type Publisher interface {
|
||||
Publish(t events.Topic)
|
||||
}
|
||||
|
||||
// Hub is the master's endpoint for agents.
|
||||
type Hub struct {
|
||||
store Store
|
||||
pub Publisher
|
||||
log *slog.Logger
|
||||
now func() time.Time
|
||||
|
||||
mu sync.Mutex
|
||||
conns map[int64]*websocket.Conn
|
||||
}
|
||||
|
||||
// New returns a Hub. Mount it at GET /agent on the TLS listener.
|
||||
func New(store Store, pub Publisher, log *slog.Logger) *Hub {
|
||||
return &Hub{store: store, pub: pub, log: log, now: time.Now, conns: make(map[int64]*websocket.Conn)}
|
||||
}
|
||||
|
||||
// ServeHTTP authenticates the agent's token and runs its connection. The token
|
||||
// is only ever hashed; it is never logged or stored.
|
||||
func (h *Hub) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
token, ok := strings.CutPrefix(r.Header.Get("Authorization"), "Bearer ")
|
||||
if !ok || token == "" {
|
||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
agent, err := h.store.AgentByTokenHash(r.Context(), agenttoken.Hash(token))
|
||||
if err != nil && !errors.Is(err, model.ErrNotFound) {
|
||||
h.log.Error("agent_lookup_failed", "error", err)
|
||||
}
|
||||
if err != nil || agent.RevokedAt != nil {
|
||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
c, err := websocket.Accept(w, r, nil)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
c.SetReadLimit(readLimit)
|
||||
h.register(agent, r.RemoteAddr, c)
|
||||
defer h.unregister(agent.ID, c)
|
||||
|
||||
// Revoking commits revoked_at before calling Disconnect. Re-checking after
|
||||
// register closes the window where a revoke lands between the first lookup
|
||||
// and register, when Disconnect would find nothing to cut.
|
||||
again, err := h.store.AgentByTokenHash(r.Context(), agenttoken.Hash(token))
|
||||
if err != nil || again.ID != agent.ID || again.RevokedAt != nil {
|
||||
_ = c.Close(websocket.StatusPolicyViolation, "revoked")
|
||||
return
|
||||
}
|
||||
|
||||
s := &session{hub: h, agent: agent}
|
||||
err = s.run(r.Context(), c)
|
||||
h.log.Info("agent_disconnected", "agent", agent.Name, "reason", err)
|
||||
}
|
||||
|
||||
// Online reports whether the agent has a live connection.
|
||||
func (h *Hub) Online(agentID int64) bool {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
_, ok := h.conns[agentID]
|
||||
return ok
|
||||
}
|
||||
|
||||
// Disconnect closes the agent's live connection, if any. Call it after
|
||||
// revoking the agent so the revocation takes effect immediately.
|
||||
func (h *Hub) Disconnect(agentID int64) {
|
||||
h.mu.Lock()
|
||||
c := h.conns[agentID]
|
||||
h.mu.Unlock()
|
||||
if c != nil {
|
||||
c.CloseNow()
|
||||
}
|
||||
}
|
||||
|
||||
// register makes c the agent's live connection, closing any older one. A
|
||||
// replacement is logged: it is normal after a network blip, but repeated
|
||||
// replacements mean two agents share a token.
|
||||
func (h *Hub) register(agent model.Agent, remoteAddr string, c *websocket.Conn) {
|
||||
h.mu.Lock()
|
||||
old := h.conns[agent.ID]
|
||||
h.conns[agent.ID] = c
|
||||
h.mu.Unlock()
|
||||
if old != nil {
|
||||
h.log.Warn("agent_replaced", "agent_id", agent.ID, "agent", agent.Name, "remote_addr", remoteAddr)
|
||||
old.CloseNow()
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Hub) unregister(id int64, c *websocket.Conn) {
|
||||
h.mu.Lock()
|
||||
current := h.conns[id] == c
|
||||
if current {
|
||||
delete(h.conns, id)
|
||||
}
|
||||
h.mu.Unlock()
|
||||
if !current {
|
||||
return // replaced by a newer connection; that one owns the state
|
||||
}
|
||||
if err := h.store.MarkSeen(context.Background(), id, h.now()); err != nil {
|
||||
h.log.Error("mark_seen_failed", "agent_id", id, "error", err)
|
||||
}
|
||||
h.pub.Publish(events.Agents)
|
||||
}
|
||||
|
||||
// session is one agent connection's state.
|
||||
type session struct {
|
||||
hub *Hub
|
||||
agent model.Agent
|
||||
hello *protocol.Hello
|
||||
}
|
||||
|
||||
// swarmManager reports whether this agent may report Swarm services. The same
|
||||
// rule gates snapshots, live upserts and live removes.
|
||||
func (s *session) swarmManager() bool {
|
||||
return s.hello.SwarmManager && s.hello.SwarmClusterID != ""
|
||||
}
|
||||
|
||||
func (s *session) run(ctx context.Context, c *websocket.Conn) error {
|
||||
for {
|
||||
var env protocol.Envelope
|
||||
if err := wsjson.Read(ctx, c, &env); err != nil {
|
||||
return err
|
||||
}
|
||||
if s.hello == nil && env.Type != protocol.TypeHello {
|
||||
_ = c.Close(websocket.StatusPolicyViolation, "hello required first")
|
||||
return fmt.Errorf("%q before hello", env.Type)
|
||||
}
|
||||
if err := s.handle(ctx, env); err != nil {
|
||||
_ = c.Close(websocket.StatusPolicyViolation, "bad message")
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *session) handle(ctx context.Context, env protocol.Envelope) error {
|
||||
h := s.hub
|
||||
switch env.Type {
|
||||
case protocol.TypeHello:
|
||||
if s.hello != nil {
|
||||
return errors.New("duplicate hello")
|
||||
}
|
||||
v, err := protocol.Decode[protocol.Hello](env)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := h.store.RecordHello(ctx, s.agent.ID, v, h.now()); err != nil {
|
||||
return err
|
||||
}
|
||||
s.hello = &v
|
||||
h.log.Info("agent_connected", "agent", s.agent.Name, "hostname", v.Hostname, "swarm_manager", v.SwarmManager)
|
||||
h.pub.Publish(events.Agents)
|
||||
case protocol.TypeSnapshot:
|
||||
v, err := protocol.Decode[protocol.Snapshot](env)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := h.store.ApplySnapshot(ctx, s.agent.ID, *s.hello, v.Services, h.now()); err != nil {
|
||||
return err
|
||||
}
|
||||
h.pub.Publish(events.Services)
|
||||
case protocol.TypeUpsert:
|
||||
v, err := protocol.Decode[protocol.Upsert](env)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if v.Service.Kind == protocol.KindSwarm && !s.swarmManager() {
|
||||
return nil
|
||||
}
|
||||
if err := h.store.UpsertService(ctx, s.agent.ID, s.hello.SwarmClusterID, v.Service, h.now()); err != nil {
|
||||
return err
|
||||
}
|
||||
h.pub.Publish(events.Services)
|
||||
case protocol.TypeRemove:
|
||||
v, err := protocol.Decode[protocol.Remove](env)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if strings.HasPrefix(v.Key, string(protocol.KindSwarm)+"|") && !s.swarmManager() {
|
||||
return nil
|
||||
}
|
||||
if err := h.store.RemoveService(ctx, s.agent.ID, s.hello.SwarmClusterID, v.Key, h.now()); err != nil {
|
||||
return err
|
||||
}
|
||||
h.pub.Publish(events.Services)
|
||||
default:
|
||||
return fmt.Errorf("unknown message type %q", env.Type)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,416 @@
|
||||
package hub
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/coder/websocket"
|
||||
"github.com/coder/websocket/wsjson"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/shcizo/package-updater/internal/agenttoken"
|
||||
"github.com/shcizo/package-updater/internal/master/events"
|
||||
"github.com/shcizo/package-updater/internal/master/model"
|
||||
"github.com/shcizo/package-updater/internal/protocol"
|
||||
)
|
||||
|
||||
type fakeStore struct {
|
||||
mu sync.Mutex
|
||||
agents map[string]model.Agent // by token hash
|
||||
hellos []protocol.Hello
|
||||
snapshots [][]protocol.Service
|
||||
upserts []string // "clusterID|key"
|
||||
removes []string // "clusterID|key"
|
||||
seen []int64
|
||||
lookups int
|
||||
// revokeFromLookup, when > 0, makes AgentByTokenHash return the agent as
|
||||
// revoked from that (1-based) lookup onwards.
|
||||
revokeFromLookup int
|
||||
}
|
||||
|
||||
func newFakeStore(agents ...model.Agent) *fakeStore {
|
||||
f := &fakeStore{agents: map[string]model.Agent{}}
|
||||
for _, a := range agents {
|
||||
f.agents[agenttoken.Hash("pua_"+a.Name)] = a
|
||||
}
|
||||
return f
|
||||
}
|
||||
|
||||
func (f *fakeStore) AgentByTokenHash(_ context.Context, hash string) (model.Agent, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
f.lookups++
|
||||
a, ok := f.agents[hash]
|
||||
if !ok {
|
||||
return model.Agent{}, model.ErrNotFound
|
||||
}
|
||||
if f.revokeFromLookup > 0 && f.lookups >= f.revokeFromLookup {
|
||||
a.RevokedAt = ptr(time.Now())
|
||||
}
|
||||
return a, nil
|
||||
}
|
||||
|
||||
func (f *fakeStore) RecordHello(_ context.Context, _ int64, h protocol.Hello, _ time.Time) error {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
f.hellos = append(f.hellos, h)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeStore) ApplySnapshot(_ context.Context, _ int64, _ protocol.Hello, s []protocol.Service, _ time.Time) error {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
f.snapshots = append(f.snapshots, s)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeStore) UpsertService(_ context.Context, _ int64, cluster string, s protocol.Service, _ time.Time) error {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
f.upserts = append(f.upserts, cluster+"|"+s.Key())
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeStore) RemoveService(_ context.Context, _ int64, cluster, key string, _ time.Time) error {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
f.removes = append(f.removes, cluster+"|"+key)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeStore) MarkSeen(_ context.Context, id int64, _ time.Time) error {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
f.seen = append(f.seen, id)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeStore) count(get func(*fakeStore) int) func() int {
|
||||
return func() int {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
return get(f)
|
||||
}
|
||||
}
|
||||
|
||||
type fakePub struct {
|
||||
mu sync.Mutex
|
||||
topics []events.Topic
|
||||
}
|
||||
|
||||
func (p *fakePub) Publish(t events.Topic) {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
p.topics = append(p.topics, t)
|
||||
}
|
||||
|
||||
func (p *fakePub) has(t events.Topic) bool {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
for _, x := range p.topics {
|
||||
if x == t {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// reset forgets every topic published so far.
|
||||
func (p *fakePub) reset() {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
p.topics = nil
|
||||
}
|
||||
|
||||
var (
|
||||
node2 = model.Agent{ID: 2, Name: "node-2"}
|
||||
mgr = model.Agent{ID: 1, Name: "swarm-mgr"}
|
||||
revoked = model.Agent{ID: 3, Name: "old", RevokedAt: ptr(time.Now())}
|
||||
)
|
||||
|
||||
func ptr[T any](v T) *T { return &v }
|
||||
|
||||
func newHub(t *testing.T, store *fakeStore) (*Hub, *fakePub, *httptest.Server) {
|
||||
t.Helper()
|
||||
pub := &fakePub{}
|
||||
h := New(store, pub, slog.New(slog.NewTextHandler(io.Discard, nil)))
|
||||
srv := httptest.NewServer(h)
|
||||
t.Cleanup(srv.Close)
|
||||
return h, pub, srv
|
||||
}
|
||||
|
||||
func dial(t *testing.T, srv *httptest.Server, token string) (*websocket.Conn, *http.Response, error) {
|
||||
t.Helper()
|
||||
header := http.Header{}
|
||||
if token != "" {
|
||||
header.Set("Authorization", "Bearer "+token)
|
||||
}
|
||||
return websocket.Dial(context.Background(), "ws"+strings.TrimPrefix(srv.URL, "http"),
|
||||
&websocket.DialOptions{HTTPHeader: header})
|
||||
}
|
||||
|
||||
func send(t *testing.T, c *websocket.Conn, typ string, payload any) {
|
||||
t.Helper()
|
||||
env, err := protocol.Encode(typ, payload)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, wsjson.Write(context.Background(), c, env))
|
||||
}
|
||||
|
||||
func TestHub_RejectsBadTokens(t *testing.T) {
|
||||
_, _, srv := newHub(t, newFakeStore(node2, revoked))
|
||||
for name, token := range map[string]string{"missing": "", "unknown": "pua_nobody", "revoked": "pua_old"} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
_, resp, err := dial(t, srv, token)
|
||||
require.Error(t, err)
|
||||
require.NotNil(t, resp)
|
||||
assert.Equal(t, http.StatusUnauthorized, resp.StatusCode)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestHub_HelloAndSnapshotReachStore(t *testing.T) {
|
||||
store := newFakeStore(node2)
|
||||
h, pub, srv := newHub(t, store)
|
||||
c, _, err := dial(t, srv, "pua_node-2")
|
||||
require.NoError(t, err)
|
||||
defer c.CloseNow()
|
||||
|
||||
send(t, c, protocol.TypeHello, protocol.Hello{Hostname: "node-2"})
|
||||
send(t, c, protocol.TypeSnapshot, protocol.Snapshot{Services: []protocol.Service{
|
||||
{Kind: protocol.KindContainer, ContainerName: "a", Image: "a:1", Status: "running"},
|
||||
}})
|
||||
|
||||
require.Eventually(t, func() bool { return store.count(func(f *fakeStore) int { return len(f.snapshots) })() == 1 }, time.Second, 5*time.Millisecond)
|
||||
assert.True(t, h.Online(node2.ID))
|
||||
assert.True(t, pub.has(events.Agents))
|
||||
assert.True(t, pub.has(events.Services))
|
||||
}
|
||||
|
||||
func TestHub_LargeSnapshotArrivesIntact(t *testing.T) {
|
||||
store := newFakeStore(node2)
|
||||
_, _, srv := newHub(t, store)
|
||||
c, _, err := dial(t, srv, "pua_node-2")
|
||||
require.NoError(t, err)
|
||||
defer c.CloseNow()
|
||||
|
||||
services := make([]protocol.Service, 500)
|
||||
for i := range services {
|
||||
services[i] = protocol.Service{
|
||||
Kind: protocol.KindContainer, ContainerName: strings.Repeat("x", 20) + string(rune('a'+i%26)) + strings.Repeat("y", i%7),
|
||||
Image: "registry.example.com/some/long/image/name:v1.2.3@sha256:" + strings.Repeat("0", 64), Status: "running",
|
||||
}
|
||||
}
|
||||
send(t, c, protocol.TypeHello, protocol.Hello{Hostname: "node-2"})
|
||||
send(t, c, protocol.TypeSnapshot, protocol.Snapshot{Services: services})
|
||||
|
||||
require.Eventually(t, func() bool { return store.count(func(f *fakeStore) int { return len(f.snapshots) })() == 1 }, time.Second, 5*time.Millisecond)
|
||||
store.mu.Lock()
|
||||
defer store.mu.Unlock()
|
||||
assert.Len(t, store.snapshots[0], 500)
|
||||
}
|
||||
|
||||
func TestHub_UpsertAndRemoveCarryClusterID(t *testing.T) {
|
||||
store := newFakeStore(mgr)
|
||||
_, _, srv := newHub(t, store)
|
||||
c, _, err := dial(t, srv, "pua_swarm-mgr")
|
||||
require.NoError(t, err)
|
||||
defer c.CloseNow()
|
||||
|
||||
gitea := protocol.Service{Kind: protocol.KindSwarm, SwarmService: "gitea_gitea", Image: "gitea/gitea:1", Status: "running"}
|
||||
send(t, c, protocol.TypeHello, protocol.Hello{Hostname: "swarm-mgr", SwarmManager: true, SwarmClusterID: "c1"})
|
||||
send(t, c, protocol.TypeUpsert, protocol.Upsert{Service: gitea})
|
||||
send(t, c, protocol.TypeRemove, protocol.Remove{Key: gitea.Key()})
|
||||
|
||||
require.Eventually(t, func() bool { return store.count(func(f *fakeStore) int { return len(f.removes) })() == 1 }, time.Second, 5*time.Millisecond)
|
||||
store.mu.Lock()
|
||||
defer store.mu.Unlock()
|
||||
assert.Equal(t, []string{"c1|swarm|gitea_gitea"}, store.upserts)
|
||||
assert.Equal(t, []string{"c1|swarm|gitea_gitea"}, store.removes)
|
||||
}
|
||||
|
||||
func TestHub_IgnoresSwarmUpsertFromWorker(t *testing.T) {
|
||||
store := newFakeStore(node2)
|
||||
_, _, srv := newHub(t, store)
|
||||
c, _, err := dial(t, srv, "pua_node-2")
|
||||
require.NoError(t, err)
|
||||
defer c.CloseNow()
|
||||
|
||||
send(t, c, protocol.TypeHello, protocol.Hello{Hostname: "node-2"})
|
||||
send(t, c, protocol.TypeUpsert, protocol.Upsert{Service: protocol.Service{Kind: protocol.KindSwarm, SwarmService: "x"}})
|
||||
send(t, c, protocol.TypeUpsert, protocol.Upsert{Service: protocol.Service{Kind: protocol.KindContainer, ContainerName: "y"}})
|
||||
|
||||
require.Eventually(t, func() bool { return store.count(func(f *fakeStore) int { return len(f.upserts) })() == 1 }, time.Second, 5*time.Millisecond)
|
||||
store.mu.Lock()
|
||||
defer store.mu.Unlock()
|
||||
assert.Equal(t, []string{"|container|y"}, store.upserts)
|
||||
}
|
||||
|
||||
// Live swarm messages are gated exactly like snapshots: only a swarm manager
|
||||
// with a cluster ID may upsert or remove swarm services.
|
||||
func TestHub_SwarmLiveMessagesAreGatedOnManagerWithClusterID(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
hello protocol.Hello
|
||||
wantReaches bool
|
||||
}{
|
||||
{"non-manager", protocol.Hello{Hostname: "swarm-mgr"}, false},
|
||||
{"non-manager with cluster id", protocol.Hello{Hostname: "swarm-mgr", SwarmClusterID: "c1"}, false},
|
||||
{"manager with empty cluster id", protocol.Hello{Hostname: "swarm-mgr", SwarmManager: true}, false},
|
||||
{"manager with cluster id", protocol.Hello{Hostname: "swarm-mgr", SwarmManager: true, SwarmClusterID: "c1"}, true},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
store := newFakeStore(mgr)
|
||||
_, _, srv := newHub(t, store)
|
||||
c, _, err := dial(t, srv, "pua_swarm-mgr")
|
||||
require.NoError(t, err)
|
||||
defer c.CloseNow()
|
||||
|
||||
svc := protocol.Service{Kind: protocol.KindSwarm, SwarmService: "gitea_gitea"}
|
||||
sentinel := protocol.Service{Kind: protocol.KindContainer, ContainerName: "sentinel"}
|
||||
send(t, c, protocol.TypeHello, tt.hello)
|
||||
send(t, c, protocol.TypeUpsert, protocol.Upsert{Service: svc})
|
||||
send(t, c, protocol.TypeRemove, protocol.Remove{Key: svc.Key()})
|
||||
// Messages are handled in order, so once the sentinel's remove has
|
||||
// arrived, the swarm messages before it have been handled too.
|
||||
send(t, c, protocol.TypeRemove, protocol.Remove{Key: sentinel.Key()})
|
||||
|
||||
sentinelRemoved := func() bool {
|
||||
store.mu.Lock()
|
||||
defer store.mu.Unlock()
|
||||
for _, r := range store.removes {
|
||||
if strings.HasSuffix(r, "|container|sentinel") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
require.Eventually(t, sentinelRemoved, time.Second, 5*time.Millisecond)
|
||||
|
||||
store.mu.Lock()
|
||||
defer store.mu.Unlock()
|
||||
if tt.wantReaches {
|
||||
assert.Equal(t, []string{"c1|swarm|gitea_gitea"}, store.upserts)
|
||||
assert.Contains(t, store.removes, "c1|swarm|gitea_gitea")
|
||||
} else {
|
||||
assert.Empty(t, store.upserts)
|
||||
assert.Equal(t, []string{tt.hello.SwarmClusterID + "|container|sentinel"}, store.removes)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestHub_ClosesConnectionThatSkipsHello(t *testing.T) {
|
||||
store := newFakeStore(node2)
|
||||
_, _, srv := newHub(t, store)
|
||||
c, _, err := dial(t, srv, "pua_node-2")
|
||||
require.NoError(t, err)
|
||||
defer c.CloseNow()
|
||||
|
||||
send(t, c, protocol.TypeSnapshot, protocol.Snapshot{})
|
||||
_, _, err = c.Read(context.Background())
|
||||
assert.Equal(t, websocket.StatusPolicyViolation, websocket.CloseStatus(err))
|
||||
}
|
||||
|
||||
// lockedBuffer is a bytes.Buffer safe for the hub's handler goroutines to
|
||||
// write while the test reads it.
|
||||
type lockedBuffer struct {
|
||||
mu sync.Mutex
|
||||
buf bytes.Buffer
|
||||
}
|
||||
|
||||
func (b *lockedBuffer) Write(p []byte) (int, error) {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
return b.buf.Write(p)
|
||||
}
|
||||
|
||||
func (b *lockedBuffer) String() string {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
return b.buf.String()
|
||||
}
|
||||
|
||||
func TestHub_NewConnectionReplacesOld(t *testing.T) {
|
||||
store := newFakeStore(node2)
|
||||
logs := &lockedBuffer{}
|
||||
h := New(store, &fakePub{}, slog.New(slog.NewTextHandler(logs, nil)))
|
||||
srv := httptest.NewServer(h)
|
||||
t.Cleanup(srv.Close)
|
||||
first, _, err := dial(t, srv, "pua_node-2")
|
||||
require.NoError(t, err)
|
||||
defer first.CloseNow()
|
||||
send(t, first, protocol.TypeHello, protocol.Hello{Hostname: "node-2"})
|
||||
require.Eventually(t, func() bool { return h.Online(node2.ID) }, time.Second, 5*time.Millisecond)
|
||||
|
||||
second, _, err := dial(t, srv, "pua_node-2")
|
||||
require.NoError(t, err)
|
||||
defer second.CloseNow()
|
||||
|
||||
_, _, err = first.Read(context.Background())
|
||||
assert.Error(t, err, "the replaced connection must be closed")
|
||||
assert.True(t, h.Online(node2.ID), "the agent stays online on its new connection")
|
||||
assert.Contains(t, logs.String(), "msg=agent_replaced")
|
||||
assert.Contains(t, logs.String(), "agent=node-2")
|
||||
assert.NotContains(t, logs.String(), "pua_", "tokens are never logged")
|
||||
}
|
||||
|
||||
func TestHub_DisconnectCutsLiveConnection(t *testing.T) {
|
||||
store := newFakeStore(node2)
|
||||
h, pub, srv := newHub(t, store)
|
||||
c, _, err := dial(t, srv, "pua_node-2")
|
||||
require.NoError(t, err)
|
||||
defer c.CloseNow()
|
||||
send(t, c, protocol.TypeHello, protocol.Hello{Hostname: "node-2"})
|
||||
require.Eventually(t, func() bool { return h.Online(node2.ID) }, time.Second, 5*time.Millisecond)
|
||||
// Online turns true before the hello is handled, so wait for the hello's
|
||||
// own Agents signal, then forget it; the assertion below can then only be
|
||||
// satisfied by the disconnect.
|
||||
require.Eventually(t, func() bool { return pub.has(events.Agents) }, time.Second, 5*time.Millisecond)
|
||||
pub.reset()
|
||||
|
||||
h.Disconnect(node2.ID)
|
||||
|
||||
_, _, err = c.Read(context.Background())
|
||||
assert.Error(t, err)
|
||||
require.Eventually(t, func() bool { return !h.Online(node2.ID) }, time.Second, 5*time.Millisecond)
|
||||
require.Eventually(t, func() bool { return store.count(func(f *fakeStore) int { return len(f.seen) })() == 1 }, time.Second, 5*time.Millisecond)
|
||||
require.Eventually(t, func() bool { return pub.has(events.Agents) }, time.Second, 5*time.Millisecond)
|
||||
}
|
||||
|
||||
func TestHub_RevokeBetweenLookupAndRegisterCutsConnection(t *testing.T) {
|
||||
store := newFakeStore(node2)
|
||||
store.revokeFromLookup = 2 // first lookup passes, the re-check sees the revocation
|
||||
h, _, srv := newHub(t, store)
|
||||
c, _, err := dial(t, srv, "pua_node-2")
|
||||
require.NoError(t, err)
|
||||
defer c.CloseNow()
|
||||
|
||||
_, _, err = c.Read(context.Background())
|
||||
assert.Equal(t, websocket.StatusPolicyViolation, websocket.CloseStatus(err))
|
||||
require.Eventually(t, func() bool { return !h.Online(node2.ID) }, time.Second, 5*time.Millisecond)
|
||||
}
|
||||
|
||||
func TestHub_ClosesConnectionOnSecondHello(t *testing.T) {
|
||||
store := newFakeStore(mgr)
|
||||
_, _, srv := newHub(t, store)
|
||||
c, _, err := dial(t, srv, "pua_swarm-mgr")
|
||||
require.NoError(t, err)
|
||||
defer c.CloseNow()
|
||||
|
||||
send(t, c, protocol.TypeHello, protocol.Hello{Hostname: "swarm-mgr"})
|
||||
send(t, c, protocol.TypeHello, protocol.Hello{Hostname: "swarm-mgr", SwarmManager: true, SwarmClusterID: "c1"})
|
||||
|
||||
_, _, err = c.Read(context.Background())
|
||||
assert.Equal(t, websocket.StatusPolicyViolation, websocket.CloseStatus(err))
|
||||
assert.Equal(t, 1, store.count(func(f *fakeStore) int { return len(f.hellos) })())
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
// Package model holds the master's domain types shared by storage, the agent
|
||||
// hub and the web UI.
|
||||
package model
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/shcizo/package-updater/internal/protocol"
|
||||
)
|
||||
|
||||
// Sentinel errors returned by storage.
|
||||
var (
|
||||
ErrNotFound = errors.New("not found")
|
||||
ErrConflict = errors.New("already exists")
|
||||
ErrRevoked = errors.New("agent revoked")
|
||||
)
|
||||
|
||||
// Agent is a registered agent. Whether it is connected right now is held in
|
||||
// memory by the hub, not here.
|
||||
type Agent struct {
|
||||
ID int64
|
||||
Name string
|
||||
Hostname string
|
||||
AgentVersion string
|
||||
SwarmManager bool
|
||||
LastSeenAt *time.Time
|
||||
RevokedAt *time.Time
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
// Service is one service as last reported by an agent.
|
||||
type Service struct {
|
||||
ID int64
|
||||
Kind protocol.Kind
|
||||
LocationKey string
|
||||
AgentID *int64
|
||||
AgentName string
|
||||
ComposeProject string
|
||||
ComposeService string
|
||||
WorkingDir string
|
||||
SwarmClusterID string
|
||||
SwarmService string
|
||||
ContainerName string
|
||||
ImageRepo string
|
||||
ActualRef string
|
||||
ActualDigest string
|
||||
Status string
|
||||
Self bool
|
||||
UpdateMode string
|
||||
FirstSeenAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
// DisplayName is the human-facing name of the service.
|
||||
func (s Service) DisplayName() string {
|
||||
switch s.Kind {
|
||||
case protocol.KindCompose:
|
||||
return s.ComposeProject + "/" + s.ComposeService
|
||||
case protocol.KindSwarm:
|
||||
return s.SwarmService
|
||||
default:
|
||||
return s.ContainerName
|
||||
}
|
||||
}
|
||||
|
||||
// Session is a logged-in browser session.
|
||||
type Session struct {
|
||||
ID string
|
||||
Subject string
|
||||
Name string
|
||||
Email string
|
||||
CSRFToken string
|
||||
ExpiresAt time.Time
|
||||
}
|
||||
|
||||
// LocationKey turns an agent-relative protocol key into the fleet-wide key
|
||||
// stored in services.location_key. Compose services and containers belong to
|
||||
// the agent's machine; Swarm services belong to the cluster, so two manager
|
||||
// agents in one cluster report the same row.
|
||||
func LocationKey(agentID int64, clusterID, agentKey string) string {
|
||||
kind, rest, _ := strings.Cut(agentKey, "|")
|
||||
if kind == string(protocol.KindSwarm) {
|
||||
return kind + "|" + clusterID + "|" + rest
|
||||
}
|
||||
return kind + "|" + strconv.FormatInt(agentID, 10) + "|" + rest
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
||||
"github.com/shcizo/package-updater/internal/protocol"
|
||||
)
|
||||
|
||||
func TestLocationKey(t *testing.T) {
|
||||
cases := []struct {
|
||||
name, agentKey, want string
|
||||
}{
|
||||
{"compose is per agent", "compose|slusk|web", "compose|7|slusk|web"},
|
||||
{"container is per agent", "container|adhoc", "container|7|adhoc"},
|
||||
{"swarm is per cluster", "swarm|gitea_gitea", "swarm|cluster-1|gitea_gitea"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
assert.Equal(t, c.want, LocationKey(7, "cluster-1", c.agentKey))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDisplayName(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
svc Service
|
||||
want string
|
||||
}{
|
||||
{"compose", Service{Kind: protocol.KindCompose, ComposeProject: "slusk", ComposeService: "web"}, "slusk/web"},
|
||||
{"swarm", Service{Kind: protocol.KindSwarm, SwarmService: "gitea_gitea"}, "gitea_gitea"},
|
||||
{"container", Service{Kind: protocol.KindContainer, ContainerName: "adhoc"}, "adhoc"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
assert.Equal(t, c.want, c.svc.DisplayName())
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgconn"
|
||||
|
||||
"github.com/shcizo/package-updater/internal/master/model"
|
||||
"github.com/shcizo/package-updater/internal/protocol"
|
||||
)
|
||||
|
||||
const agentColumns = `id, name, hostname, agent_version, swarm_manager, last_seen_at, revoked_at, created_at`
|
||||
|
||||
func scanAgent(row pgx.Row) (model.Agent, error) {
|
||||
var a model.Agent
|
||||
err := row.Scan(&a.ID, &a.Name, &a.Hostname, &a.AgentVersion, &a.SwarmManager, &a.LastSeenAt, &a.RevokedAt, &a.CreatedAt)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return model.Agent{}, model.ErrNotFound
|
||||
}
|
||||
return a, err
|
||||
}
|
||||
|
||||
// CreateAgent registers an agent. Returns model.ErrConflict if an active
|
||||
// (non-revoked) agent already has the name.
|
||||
func (s *Store) CreateAgent(ctx context.Context, name, tokenHash string) (model.Agent, error) {
|
||||
a, err := scanAgent(s.pool.QueryRow(ctx,
|
||||
`INSERT INTO agents (name, token_hash) VALUES ($1, $2) RETURNING `+agentColumns, name, tokenHash))
|
||||
var pgErr *pgconn.PgError
|
||||
if errors.As(err, &pgErr) && pgErr.Code == "23505" {
|
||||
return model.Agent{}, model.ErrConflict
|
||||
}
|
||||
if err != nil {
|
||||
return model.Agent{}, fmt.Errorf("create agent: %w", err)
|
||||
}
|
||||
return a, nil
|
||||
}
|
||||
|
||||
// ListAgents returns all agents, including revoked ones, ordered by name.
|
||||
func (s *Store) ListAgents(ctx context.Context) ([]model.Agent, error) {
|
||||
rows, err := s.pool.Query(ctx, `SELECT `+agentColumns+` FROM agents ORDER BY name`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list agents: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []model.Agent
|
||||
for rows.Next() {
|
||||
a, err := scanAgent(rows)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("scan agent: %w", err)
|
||||
}
|
||||
out = append(out, a)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// AgentByTokenHash finds the agent a token belongs to. Revoked agents are
|
||||
// returned too; the caller decides. Returns model.ErrNotFound if none.
|
||||
func (s *Store) AgentByTokenHash(ctx context.Context, hash string) (model.Agent, error) {
|
||||
return scanAgent(s.pool.QueryRow(ctx, `SELECT `+agentColumns+` FROM agents WHERE token_hash = $1`, hash))
|
||||
}
|
||||
|
||||
// RevokeAgent marks the agent revoked and its Compose and plain-container
|
||||
// services removed, in one transaction. Swarm rows are shared with other
|
||||
// managers and left alone. Its token stops working immediately; closing a
|
||||
// live connection is the hub's job.
|
||||
func (s *Store) RevokeAgent(ctx context.Context, id int64, at time.Time) error {
|
||||
tx, err := s.pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("revoke agent: begin: %w", err)
|
||||
}
|
||||
defer func() { _ = tx.Rollback(ctx) }()
|
||||
|
||||
if _, err := tx.Exec(ctx,
|
||||
`UPDATE agents SET revoked_at = $2 WHERE id = $1 AND revoked_at IS NULL`, id, at); err != nil {
|
||||
return fmt.Errorf("revoke agent: %w", err)
|
||||
}
|
||||
if _, err := tx.Exec(ctx,
|
||||
`UPDATE services SET removed_at = $2
|
||||
WHERE agent_id = $1 AND kind IN ('compose', 'container') AND removed_at IS NULL`, id, at); err != nil {
|
||||
return fmt.Errorf("revoke agent: remove services: %w", err)
|
||||
}
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return fmt.Errorf("revoke agent: commit: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// RecordHello stores what the agent reported about itself on connect.
|
||||
func (s *Store) RecordHello(ctx context.Context, id int64, h protocol.Hello, at time.Time) error {
|
||||
if _, err := s.pool.Exec(ctx,
|
||||
`UPDATE agents SET hostname = $2, agent_version = $3, swarm_manager = $4, last_seen_at = $5 WHERE id = $1`,
|
||||
id, h.Hostname, h.AgentVersion, h.SwarmManager, at); err != nil {
|
||||
return fmt.Errorf("record hello: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// MarkSeen updates last_seen_at, called when an agent disconnects.
|
||||
func (s *Store) MarkSeen(ctx context.Context, id int64, at time.Time) error {
|
||||
if _, err := s.pool.Exec(ctx, `UPDATE agents SET last_seen_at = $2 WHERE id = $1`, id, at); err != nil {
|
||||
return fmt.Errorf("mark agent seen: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
//go:build integration
|
||||
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/shcizo/package-updater/internal/master/model"
|
||||
"github.com/shcizo/package-updater/internal/pin"
|
||||
"github.com/shcizo/package-updater/internal/protocol"
|
||||
)
|
||||
|
||||
func TestIdentity_CreatedOnceThenReused(t *testing.T) {
|
||||
s := openTestStore(t)
|
||||
ctx := context.Background()
|
||||
k1, err := s.LoadOrCreateIdentity(ctx, pin.GenerateKey)
|
||||
require.NoError(t, err)
|
||||
k2, err := s.LoadOrCreateIdentity(ctx, pin.GenerateKey)
|
||||
require.NoError(t, err)
|
||||
assert.True(t, k1.Equal(k2), "a restarted master must keep its key or every agent breaks")
|
||||
}
|
||||
|
||||
func TestMigrationsAreIdempotent(t *testing.T) {
|
||||
s := openTestStore(t)
|
||||
require.NoError(t, migrate(context.Background(), s.pool))
|
||||
}
|
||||
|
||||
func TestAgents_CreateLookupRevoke(t *testing.T) {
|
||||
s := openTestStore(t)
|
||||
ctx := context.Background()
|
||||
|
||||
created, err := s.CreateAgent(ctx, "node-2", "hash-2")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "node-2", created.Name)
|
||||
assert.Nil(t, created.RevokedAt)
|
||||
|
||||
found, err := s.AgentByTokenHash(ctx, "hash-2")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, created.ID, found.ID)
|
||||
|
||||
_, err = s.AgentByTokenHash(ctx, "nope")
|
||||
assert.ErrorIs(t, err, model.ErrNotFound)
|
||||
|
||||
require.NoError(t, s.RevokeAgent(ctx, created.ID, time.Now()))
|
||||
found, err = s.AgentByTokenHash(ctx, "hash-2")
|
||||
require.NoError(t, err)
|
||||
assert.NotNil(t, found.RevokedAt)
|
||||
}
|
||||
|
||||
func TestAgents_DuplicateNameIsConflict(t *testing.T) {
|
||||
s := openTestStore(t)
|
||||
ctx := context.Background()
|
||||
_, err := s.CreateAgent(ctx, "node-2", "h1")
|
||||
require.NoError(t, err)
|
||||
_, err = s.CreateAgent(ctx, "node-2", "h2")
|
||||
assert.ErrorIs(t, err, model.ErrConflict)
|
||||
}
|
||||
|
||||
func TestAgents_RecordHelloAndMarkSeen(t *testing.T) {
|
||||
s := openTestStore(t)
|
||||
ctx := context.Background()
|
||||
a, err := s.CreateAgent(ctx, "swarm-mgr", "h")
|
||||
require.NoError(t, err)
|
||||
|
||||
at := time.Date(2026, 10, 12, 12, 0, 0, 0, time.UTC)
|
||||
require.NoError(t, s.RecordHello(ctx, a.ID, protocol.Hello{
|
||||
Hostname: "swarm-mgr", AgentVersion: "v1", SwarmManager: true, SwarmClusterID: "c1",
|
||||
}, at))
|
||||
require.NoError(t, s.MarkSeen(ctx, a.ID, at.Add(time.Minute)))
|
||||
|
||||
agents, err := s.ListAgents(ctx)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, agents, 1)
|
||||
assert.Equal(t, "swarm-mgr", agents[0].Hostname)
|
||||
assert.Equal(t, "v1", agents[0].AgentVersion)
|
||||
assert.True(t, agents[0].SwarmManager)
|
||||
require.NotNil(t, agents[0].LastSeenAt)
|
||||
assert.True(t, agents[0].LastSeenAt.Equal(at.Add(time.Minute)))
|
||||
}
|
||||
|
||||
func TestAgents_RevokedNameCanBeReused(t *testing.T) {
|
||||
s := openTestStore(t)
|
||||
ctx := context.Background()
|
||||
old, err := s.CreateAgent(ctx, "node-2", "h1")
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, s.RevokeAgent(ctx, old.ID, time.Now()))
|
||||
|
||||
fresh, err := s.CreateAgent(ctx, "node-2", "h2")
|
||||
require.NoError(t, err)
|
||||
assert.NotEqual(t, old.ID, fresh.ID)
|
||||
|
||||
_, err = s.CreateAgent(ctx, "node-2", "h3")
|
||||
assert.ErrorIs(t, err, model.ErrConflict, "a second active agent with the name is still a conflict")
|
||||
}
|
||||
|
||||
func TestAgents_RevokeRemovesOwnComposeAndContainerServices(t *testing.T) {
|
||||
s := openTestStore(t)
|
||||
ctx := context.Background()
|
||||
id := newAgent(t, s, "swarm-mgr")
|
||||
require.NoError(t, s.ApplySnapshot(ctx, id, manager, []protocol.Service{slusk, adhoc, gitea}, t0))
|
||||
|
||||
require.NoError(t, s.RevokeAgent(ctx, id, t0.Add(time.Hour)))
|
||||
|
||||
got, err := s.ListServices(ctx)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, []string{"gitea_gitea"}, names(got), "swarm rows are shared and must survive a revoke")
|
||||
}
|
||||
|
||||
func TestRevokedAgent_InventoryWritesRefused(t *testing.T) {
|
||||
s := openTestStore(t)
|
||||
ctx := context.Background()
|
||||
id := newAgent(t, s, "node-2")
|
||||
require.NoError(t, s.RevokeAgent(ctx, id, t0))
|
||||
|
||||
assert.ErrorIs(t, s.ApplySnapshot(ctx, id, worker, []protocol.Service{slusk}, t0.Add(time.Minute)), model.ErrRevoked)
|
||||
assert.ErrorIs(t, s.UpsertService(ctx, id, "", adhoc, t0.Add(time.Minute)), model.ErrRevoked)
|
||||
|
||||
got, err := s.ListServices(ctx)
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, got, "a revoked agent must not write inventory")
|
||||
}
|
||||
|
||||
func TestRevokedAgent_UpsertDoesNotResurrectRemovedRow(t *testing.T) {
|
||||
s := openTestStore(t)
|
||||
ctx := context.Background()
|
||||
id := newAgent(t, s, "node-2")
|
||||
require.NoError(t, s.ApplySnapshot(ctx, id, worker, []protocol.Service{slusk}, t0))
|
||||
require.NoError(t, s.RevokeAgent(ctx, id, t0.Add(time.Hour)))
|
||||
|
||||
assert.ErrorIs(t, s.UpsertService(ctx, id, "", slusk, t0.Add(2*time.Hour)), model.ErrRevoked)
|
||||
|
||||
got, err := s.ListServices(ctx)
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, got, "the revoked agent's removed row must stay removed")
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
//go:build integration
|
||||
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// openTestStore returns a Store migrated into a fresh schema that is dropped
|
||||
// when the test ends, so tests never see each other's rows.
|
||||
func openTestStore(t *testing.T) *Store {
|
||||
t.Helper()
|
||||
url := os.Getenv("DATABASE_URL")
|
||||
if url == "" {
|
||||
t.Skip("DATABASE_URL not set")
|
||||
}
|
||||
ctx := context.Background()
|
||||
admin, err := pgx.Connect(ctx, url)
|
||||
require.NoError(t, err)
|
||||
schema := fmt.Sprintf("test_%d", time.Now().UnixNano())
|
||||
_, err = admin.Exec(ctx, "CREATE SCHEMA "+schema)
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() {
|
||||
_, _ = admin.Exec(ctx, "DROP SCHEMA "+schema+" CASCADE")
|
||||
_ = admin.Close(ctx)
|
||||
})
|
||||
|
||||
cfg, err := pgxpool.ParseConfig(url)
|
||||
require.NoError(t, err)
|
||||
cfg.ConnConfig.RuntimeParams["search_path"] = schema
|
||||
s, err := openWithConfig(ctx, cfg)
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(s.Close)
|
||||
return s
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/ed25519"
|
||||
"crypto/x509"
|
||||
"errors"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// LoadOrCreateIdentity returns the master's private key, creating and
|
||||
// storing one with generate on first start. Keeping it in Postgres means it
|
||||
// survives container recreation and is covered by database backups; losing
|
||||
// it would force every agent to be reconfigured with a new MASTER_KEY.
|
||||
func (s *Store) LoadOrCreateIdentity(ctx context.Context, generate func() (ed25519.PrivateKey, error)) (ed25519.PrivateKey, error) {
|
||||
fresh, err := generate()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
der, err := x509.MarshalPKCS8PrivateKey(fresh)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("marshal master key: %w", err)
|
||||
}
|
||||
if _, err := s.pool.Exec(ctx,
|
||||
`INSERT INTO master_identity (id, private_key) VALUES (1, $1) ON CONFLICT (id) DO NOTHING`, der,
|
||||
); err != nil {
|
||||
return nil, fmt.Errorf("store master key: %w", err)
|
||||
}
|
||||
var stored []byte
|
||||
if err := s.pool.QueryRow(ctx, `SELECT private_key FROM master_identity WHERE id = 1`).Scan(&stored); err != nil {
|
||||
return nil, fmt.Errorf("load master key: %w", err)
|
||||
}
|
||||
parsed, err := x509.ParsePKCS8PrivateKey(stored)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse master key: %w", err)
|
||||
}
|
||||
key, ok := parsed.(ed25519.PrivateKey)
|
||||
if !ok {
|
||||
return nil, errors.New("stored master key is not ed25519")
|
||||
}
|
||||
return key, nil
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"embed"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
//go:embed migrations/*.sql
|
||||
var migrationsFS embed.FS
|
||||
|
||||
// migrate applies every embedded migration not yet recorded in
|
||||
// schema_migrations, in file-name order, each in its own transaction.
|
||||
func migrate(ctx context.Context, pool *pgxpool.Pool) error {
|
||||
if _, err := pool.Exec(ctx, `CREATE TABLE IF NOT EXISTS schema_migrations (
|
||||
version text PRIMARY KEY,
|
||||
applied_at timestamptz NOT NULL DEFAULT now()
|
||||
)`); err != nil {
|
||||
return fmt.Errorf("create schema_migrations: %w", err)
|
||||
}
|
||||
entries, err := fs.ReadDir(migrationsFS, "migrations")
|
||||
if err != nil {
|
||||
return fmt.Errorf("read migrations: %w", err)
|
||||
}
|
||||
for _, e := range entries {
|
||||
name := e.Name()
|
||||
var applied bool
|
||||
if err := pool.QueryRow(ctx,
|
||||
`SELECT EXISTS (SELECT 1 FROM schema_migrations WHERE version = $1)`, name,
|
||||
).Scan(&applied); err != nil {
|
||||
return fmt.Errorf("check migration %s: %w", name, err)
|
||||
}
|
||||
if applied {
|
||||
continue
|
||||
}
|
||||
sql, err := migrationsFS.ReadFile("migrations/" + name)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read migration %s: %w", name, err)
|
||||
}
|
||||
if err := applyMigration(ctx, pool, name, string(sql)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func applyMigration(ctx context.Context, pool *pgxpool.Pool, name, sql string) error {
|
||||
tx, err := pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("begin migration %s: %w", name, err)
|
||||
}
|
||||
defer func() { _ = tx.Rollback(ctx) }()
|
||||
// Exec without arguments uses the simple protocol, which allows the
|
||||
// several statements a migration file contains.
|
||||
if _, err := tx.Exec(ctx, sql); err != nil {
|
||||
return fmt.Errorf("migration %s: %w", name, err)
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `INSERT INTO schema_migrations (version) VALUES ($1)`, name); err != nil {
|
||||
return fmt.Errorf("record migration %s: %w", name, err)
|
||||
}
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return fmt.Errorf("commit migration %s: %w", name, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
CREATE TABLE master_identity (
|
||||
id int PRIMARY KEY CHECK (id = 1),
|
||||
private_key bytea NOT NULL,
|
||||
created_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE agents (
|
||||
id bigserial PRIMARY KEY,
|
||||
name text NOT NULL,
|
||||
token_hash text NOT NULL UNIQUE,
|
||||
hostname text NOT NULL DEFAULT '',
|
||||
agent_version text NOT NULL DEFAULT '',
|
||||
swarm_manager boolean NOT NULL DEFAULT false,
|
||||
last_seen_at timestamptz,
|
||||
revoked_at timestamptz,
|
||||
created_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
-- A revoked agent's name can be reused by a new agent.
|
||||
CREATE UNIQUE INDEX agents_name_active ON agents (name) WHERE revoked_at IS NULL;
|
||||
|
||||
CREATE TABLE services (
|
||||
id bigserial PRIMARY KEY,
|
||||
kind text NOT NULL CHECK (kind IN ('compose', 'swarm', 'container')),
|
||||
location_key text NOT NULL UNIQUE,
|
||||
agent_id bigint REFERENCES agents (id),
|
||||
compose_project text NOT NULL DEFAULT '',
|
||||
compose_service text NOT NULL DEFAULT '',
|
||||
working_dir text NOT NULL DEFAULT '',
|
||||
swarm_cluster_id text NOT NULL DEFAULT '',
|
||||
swarm_service text NOT NULL DEFAULT '',
|
||||
container_name text NOT NULL DEFAULT '',
|
||||
image_repo text NOT NULL,
|
||||
actual_ref text NOT NULL,
|
||||
actual_digest text NOT NULL DEFAULT '',
|
||||
status text NOT NULL,
|
||||
self boolean NOT NULL DEFAULT false,
|
||||
update_mode text NOT NULL DEFAULT 'manual' CHECK (update_mode IN ('manual', 'auto')),
|
||||
first_seen_at timestamptz NOT NULL,
|
||||
updated_at timestamptz NOT NULL,
|
||||
removed_at timestamptz
|
||||
);
|
||||
|
||||
CREATE TABLE sessions (
|
||||
id text PRIMARY KEY,
|
||||
subject text NOT NULL,
|
||||
name text NOT NULL DEFAULT '',
|
||||
email text NOT NULL DEFAULT '',
|
||||
csrf_token text NOT NULL,
|
||||
expires_at timestamptz NOT NULL
|
||||
);
|
||||
@@ -0,0 +1,176 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgconn"
|
||||
|
||||
"github.com/shcizo/package-updater/internal/imageref"
|
||||
"github.com/shcizo/package-updater/internal/master/model"
|
||||
"github.com/shcizo/package-updater/internal/protocol"
|
||||
)
|
||||
|
||||
type execer interface {
|
||||
Exec(ctx context.Context, sql string, args ...any) (pgconn.CommandTag, error)
|
||||
}
|
||||
|
||||
// lockActiveAgent takes a share lock on the agent's row inside tx and fails
|
||||
// with model.ErrRevoked if the agent is revoked or gone. RevokeAgent's
|
||||
// UPDATE of the row waits for the lock, so an inventory write either commits
|
||||
// before the revoke (whose service cleanup then sees and removes its rows)
|
||||
// or runs after it and is refused. Without this, an in-flight write could
|
||||
// bring a revoked agent's services back.
|
||||
func lockActiveAgent(ctx context.Context, tx pgx.Tx, agentID int64) error {
|
||||
var one int
|
||||
err := tx.QueryRow(ctx,
|
||||
`SELECT 1 FROM agents WHERE id = $1 AND revoked_at IS NULL FOR SHARE`, agentID).Scan(&one)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return model.ErrRevoked
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("lock agent: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
const upsertServiceSQL = `
|
||||
INSERT INTO services (
|
||||
kind, location_key, agent_id, compose_project, compose_service, working_dir,
|
||||
swarm_cluster_id, swarm_service, container_name, image_repo, actual_ref, actual_digest,
|
||||
status, self, first_seen_at, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $15)
|
||||
ON CONFLICT (location_key) DO UPDATE SET
|
||||
agent_id = EXCLUDED.agent_id,
|
||||
working_dir = EXCLUDED.working_dir,
|
||||
image_repo = EXCLUDED.image_repo,
|
||||
actual_ref = EXCLUDED.actual_ref,
|
||||
actual_digest = EXCLUDED.actual_digest,
|
||||
status = EXCLUDED.status,
|
||||
self = EXCLUDED.self,
|
||||
updated_at = EXCLUDED.updated_at,
|
||||
removed_at = NULL`
|
||||
|
||||
func upsertService(ctx context.Context, db execer, agentID int64, clusterID string, svc protocol.Service, at time.Time) error {
|
||||
clusterCol := ""
|
||||
if svc.Kind == protocol.KindSwarm {
|
||||
clusterCol = clusterID
|
||||
}
|
||||
_, err := db.Exec(ctx, upsertServiceSQL,
|
||||
string(svc.Kind), model.LocationKey(agentID, clusterID, svc.Key()), agentID,
|
||||
svc.ComposeProject, svc.ComposeService, svc.WorkingDir,
|
||||
clusterCol, svc.SwarmService, svc.ContainerName,
|
||||
imageref.Parse(svc.Image).Repo, svc.Image, svc.Digest,
|
||||
svc.Status, svc.Self, at)
|
||||
if err != nil {
|
||||
return fmt.Errorf("upsert service %s: %w", svc.Key(), err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ApplySnapshot makes the stored inventory for this agent's scope equal to
|
||||
// services: everything listed is upserted, everything in scope but not
|
||||
// listed is marked removed. Returns model.ErrRevoked without writing if the
|
||||
// agent is revoked. Scope is the agent's own Compose services and
|
||||
// containers, plus the cluster's Swarm services if the agent is a manager.
|
||||
// A manager without a cluster ID is treated as a non-manager, since its
|
||||
// Swarm rows could not be told apart from other agents' rows.
|
||||
func (s *Store) ApplySnapshot(ctx context.Context, agentID int64, h protocol.Hello, services []protocol.Service, at time.Time) error {
|
||||
tx, err := s.pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("begin snapshot: %w", err)
|
||||
}
|
||||
defer func() { _ = tx.Rollback(ctx) }()
|
||||
if err := lockActiveAgent(ctx, tx, agentID); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
isManager := h.SwarmManager && h.SwarmClusterID != ""
|
||||
keys := make([]string, 0, len(services))
|
||||
for _, svc := range services {
|
||||
if svc.Kind == protocol.KindSwarm && !isManager {
|
||||
continue
|
||||
}
|
||||
if err := upsertService(ctx, tx, agentID, h.SwarmClusterID, svc, at); err != nil {
|
||||
return err
|
||||
}
|
||||
keys = append(keys, model.LocationKey(agentID, h.SwarmClusterID, svc.Key()))
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `
|
||||
UPDATE services SET removed_at = $1
|
||||
WHERE removed_at IS NULL
|
||||
AND NOT (location_key = ANY($2))
|
||||
AND ((kind IN ('compose', 'container') AND agent_id = $3)
|
||||
OR (kind = 'swarm' AND $4 AND swarm_cluster_id = $5))`,
|
||||
at, keys, agentID, isManager, h.SwarmClusterID); err != nil {
|
||||
return fmt.Errorf("mark removed services: %w", err)
|
||||
}
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return fmt.Errorf("commit snapshot: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// UpsertService stores one changed or new service. Returns
|
||||
// model.ErrRevoked without writing if the agent is revoked.
|
||||
func (s *Store) UpsertService(ctx context.Context, agentID int64, clusterID string, svc protocol.Service, at time.Time) error {
|
||||
tx, err := s.pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("begin upsert: %w", err)
|
||||
}
|
||||
defer func() { _ = tx.Rollback(ctx) }()
|
||||
if err := lockActiveAgent(ctx, tx, agentID); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := upsertService(ctx, tx, agentID, clusterID, svc, at); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return fmt.Errorf("commit upsert: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// RemoveService marks the service with the agent-relative key as removed.
|
||||
func (s *Store) RemoveService(ctx context.Context, agentID int64, clusterID, agentKey string, at time.Time) error {
|
||||
if _, err := s.pool.Exec(ctx,
|
||||
`UPDATE services SET removed_at = $2 WHERE location_key = $1 AND removed_at IS NULL`,
|
||||
model.LocationKey(agentID, clusterID, agentKey), at); err != nil {
|
||||
return fmt.Errorf("remove service: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ListServices returns all services not marked removed: machine-bound
|
||||
// services grouped by agent name first, Swarm services last.
|
||||
func (s *Store) ListServices(ctx context.Context) ([]model.Service, error) {
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
SELECT s.id, s.kind, s.location_key, s.agent_id, COALESCE(a.name, ''),
|
||||
s.compose_project, s.compose_service, s.working_dir, s.swarm_cluster_id,
|
||||
s.swarm_service, s.container_name, s.image_repo, s.actual_ref, s.actual_digest,
|
||||
s.status, s.self, s.update_mode, s.first_seen_at, s.updated_at
|
||||
FROM services s LEFT JOIN agents a ON a.id = s.agent_id
|
||||
WHERE s.removed_at IS NULL
|
||||
ORDER BY s.kind = 'swarm', COALESCE(a.name, ''), s.location_key`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list services: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []model.Service
|
||||
for rows.Next() {
|
||||
var m model.Service
|
||||
var kind string
|
||||
if err := rows.Scan(&m.ID, &kind, &m.LocationKey, &m.AgentID, &m.AgentName,
|
||||
&m.ComposeProject, &m.ComposeService, &m.WorkingDir, &m.SwarmClusterID,
|
||||
&m.SwarmService, &m.ContainerName, &m.ImageRepo, &m.ActualRef, &m.ActualDigest,
|
||||
&m.Status, &m.Self, &m.UpdateMode, &m.FirstSeenAt, &m.UpdatedAt); err != nil {
|
||||
return nil, fmt.Errorf("scan service: %w", err)
|
||||
}
|
||||
m.Kind = protocol.Kind(kind)
|
||||
out = append(out, m)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
//go:build integration
|
||||
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strconv"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/shcizo/package-updater/internal/master/model"
|
||||
"github.com/shcizo/package-updater/internal/protocol"
|
||||
)
|
||||
|
||||
var (
|
||||
t0 = time.Date(2026, 10, 12, 12, 0, 0, 0, time.UTC)
|
||||
slusk = protocol.Service{Kind: protocol.KindCompose, ComposeProject: "slusk", ComposeService: "web", WorkingDir: "/srv/slusk", Image: "gitea.shcizo.se/shcizo/slusk:1.4.2", Digest: "sha256:d1", Status: "running"}
|
||||
adhoc = protocol.Service{Kind: protocol.KindContainer, ContainerName: "adhoc", Image: "redis:alpine", Status: "stopped"}
|
||||
gitea = protocol.Service{Kind: protocol.KindSwarm, SwarmService: "gitea_gitea", Image: "gitea/gitea:1.27.3@sha256:87a6", Digest: "sha256:87a6", Status: "running"}
|
||||
worker = protocol.Hello{Hostname: "node-2"}
|
||||
manager = protocol.Hello{Hostname: "swarm-mgr", SwarmManager: true, SwarmClusterID: "c1"}
|
||||
)
|
||||
|
||||
func names(svcs []model.Service) []string {
|
||||
var out []string
|
||||
for _, s := range svcs {
|
||||
out = append(out, s.DisplayName())
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func newAgent(t *testing.T, s *Store, name string) int64 {
|
||||
t.Helper()
|
||||
a, err := s.CreateAgent(context.Background(), name, "hash-"+name)
|
||||
require.NoError(t, err)
|
||||
return a.ID
|
||||
}
|
||||
|
||||
func TestApplySnapshot_InsertsAsManualWithAgentName(t *testing.T) {
|
||||
s := openTestStore(t)
|
||||
ctx := context.Background()
|
||||
id := newAgent(t, s, "node-2")
|
||||
|
||||
require.NoError(t, s.ApplySnapshot(ctx, id, worker, []protocol.Service{slusk}, t0))
|
||||
|
||||
got, err := s.ListServices(ctx)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, got, 1)
|
||||
assert.Equal(t, "manual", got[0].UpdateMode)
|
||||
assert.Equal(t, "node-2", got[0].AgentName)
|
||||
assert.Equal(t, "gitea.shcizo.se/shcizo/slusk", got[0].ImageRepo)
|
||||
assert.Equal(t, "gitea.shcizo.se/shcizo/slusk:1.4.2", got[0].ActualRef)
|
||||
assert.Equal(t, "sha256:d1", got[0].ActualDigest)
|
||||
assert.Equal(t, "compose|"+itoa(id)+"|slusk|web", got[0].LocationKey)
|
||||
}
|
||||
|
||||
func TestApplySnapshot_RemovedWhileOfflineThenBack(t *testing.T) {
|
||||
s := openTestStore(t)
|
||||
ctx := context.Background()
|
||||
id := newAgent(t, s, "node-2")
|
||||
|
||||
require.NoError(t, s.ApplySnapshot(ctx, id, worker, []protocol.Service{slusk, adhoc}, t0))
|
||||
before, err := s.ListServices(ctx)
|
||||
require.NoError(t, err)
|
||||
|
||||
require.NoError(t, s.ApplySnapshot(ctx, id, worker, []protocol.Service{slusk}, t0.Add(time.Hour)))
|
||||
got, err := s.ListServices(ctx)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, []string{"slusk/web"}, names(got))
|
||||
|
||||
require.NoError(t, s.ApplySnapshot(ctx, id, worker, []protocol.Service{slusk, adhoc}, t0.Add(2*time.Hour)))
|
||||
after, err := s.ListServices(ctx)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, after, 2)
|
||||
for i := range before {
|
||||
assert.Equal(t, before[i].ID, after[i].ID, "a returning service keeps its row")
|
||||
assert.True(t, before[i].FirstSeenAt.Equal(after[i].FirstSeenAt))
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplySnapshot_TwoManagersShareSwarmRowsAndKeepOwnCompose(t *testing.T) {
|
||||
s := openTestStore(t)
|
||||
ctx := context.Background()
|
||||
a := newAgent(t, s, "swarm-mgr")
|
||||
b := newAgent(t, s, "swarm-mgr-2")
|
||||
|
||||
require.NoError(t, s.ApplySnapshot(ctx, a, manager, []protocol.Service{gitea, slusk}, t0))
|
||||
require.NoError(t, s.ApplySnapshot(ctx, b, manager, []protocol.Service{gitea}, t0))
|
||||
|
||||
got, err := s.ListServices(ctx)
|
||||
require.NoError(t, err)
|
||||
assert.ElementsMatch(t, []string{"slusk/web", "gitea_gitea"}, names(got),
|
||||
"one swarm row, and b's snapshot must not remove a's compose service")
|
||||
}
|
||||
|
||||
func TestApplySnapshot_IgnoresSwarmFromWorker(t *testing.T) {
|
||||
s := openTestStore(t)
|
||||
ctx := context.Background()
|
||||
id := newAgent(t, s, "node-2")
|
||||
require.NoError(t, s.ApplySnapshot(ctx, id, worker, []protocol.Service{gitea}, t0))
|
||||
got, err := s.ListServices(ctx)
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, got)
|
||||
}
|
||||
|
||||
func TestApplySnapshot_IgnoresSwarmFromManagerWithoutClusterID(t *testing.T) {
|
||||
s := openTestStore(t)
|
||||
ctx := context.Background()
|
||||
a := newAgent(t, s, "mgr-a")
|
||||
b := newAgent(t, s, "mgr-b")
|
||||
noCluster := protocol.Hello{Hostname: "mgr", SwarmManager: true}
|
||||
|
||||
require.NoError(t, s.ApplySnapshot(ctx, a, manager, []protocol.Service{gitea}, t0))
|
||||
require.NoError(t, s.ApplySnapshot(ctx, b, noCluster, []protocol.Service{gitea}, t0))
|
||||
|
||||
got, err := s.ListServices(ctx)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, got, 1, "the cluster-less manager adds no swarm row")
|
||||
assert.Equal(t, "swarm|c1|gitea_gitea", got[0].LocationKey)
|
||||
|
||||
require.NoError(t, s.ApplySnapshot(ctx, b, noCluster, nil, t0.Add(time.Hour)))
|
||||
got, err = s.ListServices(ctx)
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, got, 1, "the cluster-less manager removes no swarm rows")
|
||||
}
|
||||
|
||||
func TestUpsertAndRemoveService(t *testing.T) {
|
||||
s := openTestStore(t)
|
||||
ctx := context.Background()
|
||||
id := newAgent(t, s, "swarm-mgr")
|
||||
|
||||
require.NoError(t, s.UpsertService(ctx, id, "c1", gitea, t0))
|
||||
changed := gitea
|
||||
changed.Status = "degraded"
|
||||
require.NoError(t, s.UpsertService(ctx, id, "c1", changed, t0.Add(time.Minute)))
|
||||
|
||||
got, err := s.ListServices(ctx)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, got, 1)
|
||||
assert.Equal(t, "degraded", got[0].Status)
|
||||
assert.Equal(t, "swarm|c1|gitea_gitea", got[0].LocationKey)
|
||||
|
||||
require.NoError(t, s.RemoveService(ctx, id, "c1", gitea.Key(), t0.Add(2*time.Minute)))
|
||||
got, err = s.ListServices(ctx)
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, got)
|
||||
}
|
||||
|
||||
func TestListServices_SwarmLast(t *testing.T) {
|
||||
s := openTestStore(t)
|
||||
ctx := context.Background()
|
||||
id := newAgent(t, s, "swarm-mgr")
|
||||
require.NoError(t, s.ApplySnapshot(ctx, id, manager, []protocol.Service{gitea, slusk}, t0))
|
||||
got, err := s.ListServices(ctx)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, []string{"slusk/web", "gitea_gitea"}, names(got))
|
||||
}
|
||||
|
||||
func itoa(n int64) string { return strconv.FormatInt(n, 10) }
|
||||
@@ -0,0 +1,50 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
|
||||
"github.com/shcizo/package-updater/internal/master/model"
|
||||
)
|
||||
|
||||
// CreateSession stores a new session and deletes expired ones, so the table
|
||||
// never grows without bound.
|
||||
func (s *Store) CreateSession(ctx context.Context, sess model.Session) error {
|
||||
if _, err := s.pool.Exec(ctx, `DELETE FROM sessions WHERE expires_at < now()`); err != nil {
|
||||
return fmt.Errorf("delete expired sessions: %w", err)
|
||||
}
|
||||
if _, err := s.pool.Exec(ctx,
|
||||
`INSERT INTO sessions (id, subject, name, email, csrf_token, expires_at) VALUES ($1, $2, $3, $4, $5, $6)`,
|
||||
sess.ID, sess.Subject, sess.Name, sess.Email, sess.CSRFToken, sess.ExpiresAt); err != nil {
|
||||
return fmt.Errorf("create session: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetSession returns the session unless it is missing or expired at now,
|
||||
// in which case it returns model.ErrNotFound.
|
||||
func (s *Store) GetSession(ctx context.Context, id string, now time.Time) (model.Session, error) {
|
||||
var m model.Session
|
||||
err := s.pool.QueryRow(ctx,
|
||||
`SELECT id, subject, name, email, csrf_token, expires_at FROM sessions WHERE id = $1 AND expires_at > $2`,
|
||||
id, now).Scan(&m.ID, &m.Subject, &m.Name, &m.Email, &m.CSRFToken, &m.ExpiresAt)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return model.Session{}, model.ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return model.Session{}, fmt.Errorf("get session: %w", err)
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// DeleteSession removes a session (logout).
|
||||
func (s *Store) DeleteSession(ctx context.Context, id string) error {
|
||||
if _, err := s.pool.Exec(ctx, `DELETE FROM sessions WHERE id = $1`, id); err != nil {
|
||||
return fmt.Errorf("delete session: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
//go:build integration
|
||||
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/shcizo/package-updater/internal/master/model"
|
||||
)
|
||||
|
||||
func TestSessions(t *testing.T) {
|
||||
s := openTestStore(t)
|
||||
ctx := context.Background()
|
||||
now := time.Date(2026, 10, 12, 12, 0, 0, 0, time.UTC)
|
||||
sess := model.Session{ID: "s1", Subject: "sub", Name: "Samuel", Email: "s@example.com", CSRFToken: "c1", ExpiresAt: now.Add(time.Hour)}
|
||||
|
||||
require.NoError(t, s.CreateSession(ctx, sess))
|
||||
got, err := s.GetSession(ctx, "s1", now)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "Samuel", got.Name)
|
||||
assert.Equal(t, "c1", got.CSRFToken)
|
||||
|
||||
_, err = s.GetSession(ctx, "s1", now.Add(2*time.Hour))
|
||||
assert.ErrorIs(t, err, model.ErrNotFound, "expired sessions are not returned")
|
||||
|
||||
require.NoError(t, s.DeleteSession(ctx, "s1"))
|
||||
_, err = s.GetSession(ctx, "s1", now)
|
||||
assert.ErrorIs(t, err, model.ErrNotFound)
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
// Package store persists the master's state in Postgres.
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
// Store is the Postgres-backed storage for the master.
|
||||
type Store struct {
|
||||
pool *pgxpool.Pool
|
||||
}
|
||||
|
||||
// Open connects to Postgres and applies pending migrations.
|
||||
func Open(ctx context.Context, url string) (*Store, error) {
|
||||
cfg, err := pgxpool.ParseConfig(url)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse DATABASE_URL: %w", err)
|
||||
}
|
||||
return openWithConfig(ctx, cfg)
|
||||
}
|
||||
|
||||
func openWithConfig(ctx context.Context, cfg *pgxpool.Config) (*Store, error) {
|
||||
pool, err := pgxpool.NewWithConfig(ctx, cfg)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("connect postgres: %w", err)
|
||||
}
|
||||
if err := migrate(ctx, pool); err != nil {
|
||||
pool.Close()
|
||||
return nil, err
|
||||
}
|
||||
return &Store{pool: pool}, nil
|
||||
}
|
||||
|
||||
// Close releases the connection pool.
|
||||
func (s *Store) Close() { s.pool.Close() }
|
||||
|
||||
// Ping checks that Postgres answers. Used by /healthz.
|
||||
func (s *Store) Ping(ctx context.Context) error { return s.pool.Ping(ctx) }
|
||||
@@ -0,0 +1,288 @@
|
||||
// Package web serves the master's htmx UI.
|
||||
package web
|
||||
|
||||
import (
|
||||
"context"
|
||||
"embed"
|
||||
"errors"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/shcizo/package-updater/internal/agenttoken"
|
||||
"github.com/shcizo/package-updater/internal/master/events"
|
||||
"github.com/shcizo/package-updater/internal/master/model"
|
||||
"github.com/shcizo/package-updater/internal/master/webauth"
|
||||
)
|
||||
|
||||
//go:embed templates/*.html static/*
|
||||
var assets embed.FS
|
||||
|
||||
var agentNamePattern = regexp.MustCompile(`^[a-z0-9][a-z0-9-]{0,62}$`)
|
||||
|
||||
// Store is the storage the UI reads and writes. *store.Store satisfies it.
|
||||
type Store interface {
|
||||
ListServices(ctx context.Context) ([]model.Service, error)
|
||||
ListAgents(ctx context.Context) ([]model.Agent, error)
|
||||
CreateAgent(ctx context.Context, name, tokenHash string) (model.Agent, error)
|
||||
RevokeAgent(ctx context.Context, id int64, at time.Time) error
|
||||
}
|
||||
|
||||
// Hub reports and controls live agent connections. *hub.Hub satisfies it.
|
||||
type Hub interface {
|
||||
Online(agentID int64) bool
|
||||
Disconnect(agentID int64)
|
||||
}
|
||||
|
||||
// Broker delivers change signals to open streams and accepts signals from
|
||||
// UI actions. *events.Broker satisfies it.
|
||||
type Broker interface {
|
||||
Subscribe() (<-chan events.Topic, func())
|
||||
Publish(t events.Topic)
|
||||
}
|
||||
|
||||
// Config holds values shown in the "add agent" snippet.
|
||||
type Config struct {
|
||||
AgentURL string // what agents dial, e.g. wss://10.0.0.5:8090/agent
|
||||
MasterKey string // the master's fingerprint
|
||||
AgentImage string
|
||||
}
|
||||
|
||||
// Server renders the UI.
|
||||
type Server struct {
|
||||
store Store
|
||||
hub Hub
|
||||
broker Broker
|
||||
cfg Config
|
||||
log *slog.Logger
|
||||
now func() time.Time
|
||||
pages map[string]*template.Template
|
||||
}
|
||||
|
||||
// New parses the templates. Each page is its own template set so every page
|
||||
// can define its own "content" block.
|
||||
func New(store Store, hub Hub, broker Broker, cfg Config, log *slog.Logger) (*Server, error) {
|
||||
funcs := template.FuncMap{"shortRef": shortRef}
|
||||
pages := map[string]*template.Template{}
|
||||
for _, page := range []string{"overview.html", "agents.html", "agent_created.html"} {
|
||||
t, err := template.New(page).Funcs(funcs).ParseFS(assets, "templates/layout.html", "templates/"+page)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse %s: %w", page, err)
|
||||
}
|
||||
pages[page] = t
|
||||
}
|
||||
return &Server{store: store, hub: hub, broker: broker, cfg: cfg, log: log, now: time.Now, pages: pages}, nil
|
||||
}
|
||||
|
||||
// Register adds the UI routes to mux. protect wraps every route except
|
||||
// static assets, normally with webauth's Require.
|
||||
func (s *Server) Register(mux *http.ServeMux, protect func(http.Handler) http.Handler) {
|
||||
mux.Handle("GET /static/", http.FileServerFS(assets))
|
||||
route := func(pattern string, h http.HandlerFunc) { mux.Handle(pattern, protect(h)) }
|
||||
route("GET /{$}", s.overview)
|
||||
route("GET /fragments/services", s.servicesFragment)
|
||||
route("GET /agents", s.agents)
|
||||
route("GET /fragments/agents", s.agentsFragment)
|
||||
route("POST /agents", s.createAgent)
|
||||
route("POST /agents/{id}/revoke", s.revokeAgent)
|
||||
route("GET /events", s.events)
|
||||
}
|
||||
|
||||
type pageData struct {
|
||||
Title string
|
||||
CSRF string
|
||||
Groups []serviceGroup
|
||||
Agents []agentRow
|
||||
Error string
|
||||
Created model.Agent
|
||||
Snippet string
|
||||
}
|
||||
|
||||
func (s *Server) base(r *http.Request, title string) pageData {
|
||||
sess, _ := webauth.SessionFrom(r.Context())
|
||||
return pageData{Title: title, CSRF: sess.CSRFToken}
|
||||
}
|
||||
|
||||
func (s *Server) render(w http.ResponseWriter, status int, page, block string, data pageData) {
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.WriteHeader(status)
|
||||
if err := s.pages[page].ExecuteTemplate(w, block, data); err != nil {
|
||||
s.log.Error("render_failed", "page", page, "block", block, "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) serviceData(r *http.Request) (pageData, error) {
|
||||
data := s.base(r, "Services")
|
||||
services, err := s.store.ListServices(r.Context())
|
||||
if err != nil {
|
||||
return data, err
|
||||
}
|
||||
data.Groups = groupServices(services)
|
||||
return data, nil
|
||||
}
|
||||
|
||||
func (s *Server) overview(w http.ResponseWriter, r *http.Request) {
|
||||
data, err := s.serviceData(r)
|
||||
if err != nil {
|
||||
s.fail(w, err)
|
||||
return
|
||||
}
|
||||
s.render(w, http.StatusOK, "overview.html", "layout", data)
|
||||
}
|
||||
|
||||
func (s *Server) servicesFragment(w http.ResponseWriter, r *http.Request) {
|
||||
data, err := s.serviceData(r)
|
||||
if err != nil {
|
||||
s.fail(w, err)
|
||||
return
|
||||
}
|
||||
s.render(w, http.StatusOK, "overview.html", "services", data)
|
||||
}
|
||||
|
||||
func (s *Server) agentData(r *http.Request) (pageData, error) {
|
||||
data := s.base(r, "Agents")
|
||||
agents, err := s.store.ListAgents(r.Context())
|
||||
if err != nil {
|
||||
return data, err
|
||||
}
|
||||
for _, a := range agents {
|
||||
data.Agents = append(data.Agents, agentRow{Agent: a, Online: s.hub.Online(a.ID)})
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
|
||||
func (s *Server) agents(w http.ResponseWriter, r *http.Request) {
|
||||
data, err := s.agentData(r)
|
||||
if err != nil {
|
||||
s.fail(w, err)
|
||||
return
|
||||
}
|
||||
s.render(w, http.StatusOK, "agents.html", "layout", data)
|
||||
}
|
||||
|
||||
func (s *Server) agentsFragment(w http.ResponseWriter, r *http.Request) {
|
||||
data, err := s.agentData(r)
|
||||
if err != nil {
|
||||
s.fail(w, err)
|
||||
return
|
||||
}
|
||||
s.render(w, http.StatusOK, "agents.html", "agents", data)
|
||||
}
|
||||
|
||||
func (s *Server) createAgent(w http.ResponseWriter, r *http.Request) {
|
||||
name := strings.TrimSpace(r.FormValue("name"))
|
||||
if !agentNamePattern.MatchString(name) {
|
||||
s.agentsWithError(w, r, http.StatusBadRequest, "Agent names use lowercase letters, digits and dashes.")
|
||||
return
|
||||
}
|
||||
token, err := agenttoken.Generate()
|
||||
if err != nil {
|
||||
s.fail(w, err)
|
||||
return
|
||||
}
|
||||
agent, err := s.store.CreateAgent(r.Context(), name, agenttoken.Hash(token))
|
||||
if errors.Is(err, model.ErrConflict) {
|
||||
s.agentsWithError(w, r, http.StatusConflict, fmt.Sprintf("An agent named %q already exists.", name))
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
s.fail(w, err)
|
||||
return
|
||||
}
|
||||
data := s.base(r, "Agent created")
|
||||
data.Created = agent
|
||||
data.Snippet = agentSnippet(s.cfg, token)
|
||||
w.Header().Set("Cache-Control", "no-store") // the page shows the one-time token
|
||||
s.render(w, http.StatusOK, "agent_created.html", "layout", data)
|
||||
}
|
||||
|
||||
func (s *Server) agentsWithError(w http.ResponseWriter, r *http.Request, status int, msg string) {
|
||||
data, err := s.agentData(r)
|
||||
if err != nil {
|
||||
s.fail(w, err)
|
||||
return
|
||||
}
|
||||
data.Error = msg
|
||||
s.render(w, status, "agents.html", "layout", data)
|
||||
}
|
||||
|
||||
func (s *Server) revokeAgent(w http.ResponseWriter, r *http.Request) {
|
||||
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
|
||||
if err != nil {
|
||||
http.Error(w, "invalid agent id", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if err := s.store.RevokeAgent(r.Context(), id, s.now()); err != nil {
|
||||
s.fail(w, err)
|
||||
return
|
||||
}
|
||||
s.hub.Disconnect(id)
|
||||
s.broker.Publish(events.Services) // the agent's services were marked removed
|
||||
http.Redirect(w, r, "/agents", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
// events streams change signals as Server-Sent Events. htmx's SSE extension
|
||||
// turns "event: services" into a refetch of the services fragment. The event
|
||||
// carries no state, so a dropped signal is recovered by the next one.
|
||||
func (s *Server) events(w http.ResponseWriter, r *http.Request) {
|
||||
rc := http.NewResponseController(w)
|
||||
w.Header().Set("Content-Type", "text/event-stream")
|
||||
w.Header().Set("Cache-Control", "no-cache")
|
||||
w.Header().Set("X-Accel-Buffering", "no") // stop nginx from buffering the stream
|
||||
|
||||
ch, cancel := s.broker.Subscribe()
|
||||
defer cancel()
|
||||
|
||||
if _, err := fmt.Fprint(w, ": connected\n\n"); err != nil {
|
||||
return
|
||||
}
|
||||
if err := rc.Flush(); err != nil {
|
||||
s.log.Error("sse_flush_unsupported", "error", err)
|
||||
return
|
||||
}
|
||||
keepalive := time.NewTicker(25 * time.Second)
|
||||
defer keepalive.Stop()
|
||||
for {
|
||||
var msg string
|
||||
select {
|
||||
case <-r.Context().Done():
|
||||
return
|
||||
case t := <-ch:
|
||||
msg = fmt.Sprintf("event: %s\ndata: changed\n\n", t)
|
||||
case <-keepalive.C:
|
||||
msg = ": keepalive\n\n"
|
||||
}
|
||||
if _, err := fmt.Fprint(w, msg); err != nil {
|
||||
return
|
||||
}
|
||||
if err := rc.Flush(); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) fail(w http.ResponseWriter, err error) {
|
||||
s.log.Error("request_failed", "error", err)
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
}
|
||||
|
||||
// agentSnippet is the compose file shown once when an agent is added.
|
||||
func agentSnippet(cfg Config, token string) string {
|
||||
return fmt.Sprintf(`services:
|
||||
updater-agent:
|
||||
image: %s
|
||||
entrypoint: ["/usr/local/bin/updater-agent"]
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
MASTER_URL: %s
|
||||
MASTER_KEY: %s
|
||||
AGENT_TOKEN: %s
|
||||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
`, cfg.AgentImage, cfg.AgentURL, cfg.MasterKey, token)
|
||||
}
|
||||
@@ -0,0 +1,265 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/shcizo/package-updater/internal/agenttoken"
|
||||
"github.com/shcizo/package-updater/internal/master/events"
|
||||
"github.com/shcizo/package-updater/internal/master/model"
|
||||
"github.com/shcizo/package-updater/internal/protocol"
|
||||
)
|
||||
|
||||
// fakeStore is written from the HTTP handler goroutine and read by the test
|
||||
// goroutine, so every access goes through mu.
|
||||
type fakeStore struct {
|
||||
mu sync.Mutex
|
||||
services []model.Service
|
||||
agents []model.Agent
|
||||
created map[string]string // name → token hash
|
||||
revokedID int64
|
||||
}
|
||||
|
||||
func (f *fakeStore) ListServices(context.Context) ([]model.Service, error) { return f.services, nil }
|
||||
func (f *fakeStore) ListAgents(context.Context) ([]model.Agent, error) { return f.agents, nil }
|
||||
|
||||
func (f *fakeStore) CreateAgent(_ context.Context, name, hash string) (model.Agent, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
if _, taken := f.created[name]; taken {
|
||||
return model.Agent{}, model.ErrConflict
|
||||
}
|
||||
f.created[name] = hash
|
||||
return model.Agent{ID: 9, Name: name}, nil
|
||||
}
|
||||
|
||||
func (f *fakeStore) RevokeAgent(_ context.Context, id int64, _ time.Time) error {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
f.revokedID = id
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeStore) createdHash(name string) string {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
return f.created[name]
|
||||
}
|
||||
|
||||
func (f *fakeStore) revoked() int64 {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
return f.revokedID
|
||||
}
|
||||
|
||||
type fakeHub struct {
|
||||
mu sync.Mutex
|
||||
online map[int64]bool
|
||||
disconnected int64
|
||||
}
|
||||
|
||||
func (h *fakeHub) Online(id int64) bool {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
return h.online[id]
|
||||
}
|
||||
|
||||
func (h *fakeHub) Disconnect(id int64) {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
h.disconnected = id
|
||||
}
|
||||
|
||||
func (h *fakeHub) lastDisconnected() int64 {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
return h.disconnected
|
||||
}
|
||||
|
||||
func newTestServer(t *testing.T) (*httptest.Server, *fakeStore, *fakeHub, *events.Broker) {
|
||||
t.Helper()
|
||||
agentID := int64(2)
|
||||
store := &fakeStore{
|
||||
created: map[string]string{"node-2": "x"},
|
||||
services: []model.Service{
|
||||
{Kind: protocol.KindCompose, ComposeProject: "slusk", ComposeService: "web", AgentID: &agentID, AgentName: "node-2",
|
||||
ActualRef: "gitea.shcizo.se/shcizo/slusk:1.4.2", Status: "running", UpdateMode: "manual"},
|
||||
{Kind: protocol.KindSwarm, SwarmService: "jellyfin_jellyfin",
|
||||
ActualRef: "lscr.io/linuxserver/jellyfin@sha256:0f42497a69fa0441bfd5f9d6bba8694f2a656ec984e571d0ac04c6dd91250039", Status: "degraded", UpdateMode: "manual"},
|
||||
{Kind: protocol.KindContainer, ContainerName: "updater-agent", AgentID: &agentID, AgentName: "node-2",
|
||||
ActualRef: "pu:1", Status: "running", UpdateMode: "manual", Self: true},
|
||||
},
|
||||
agents: []model.Agent{
|
||||
{ID: 2, Name: "node-2", Hostname: "node-2"},
|
||||
{ID: 3, Name: "node-3"},
|
||||
{ID: 4, Name: "old", RevokedAt: ptr(time.Now())},
|
||||
},
|
||||
}
|
||||
hub := &fakeHub{online: map[int64]bool{2: true}}
|
||||
broker := events.NewBroker()
|
||||
s, err := New(store, hub, broker, Config{
|
||||
AgentURL: "wss://10.0.0.5:8090/agent", MasterKey: "sha256:abc", AgentImage: "gitea.shcizo.se/shcizo/package-updater:latest",
|
||||
}, slog.New(slog.NewTextHandler(io.Discard, nil)))
|
||||
require.NoError(t, err)
|
||||
mux := http.NewServeMux()
|
||||
s.Register(mux, func(h http.Handler) http.Handler { return h })
|
||||
srv := httptest.NewServer(mux)
|
||||
t.Cleanup(srv.Close)
|
||||
return srv, store, hub, broker
|
||||
}
|
||||
|
||||
func ptr[T any](v T) *T { return &v }
|
||||
|
||||
func get(t *testing.T, srv *httptest.Server, path string) (int, string) {
|
||||
t.Helper()
|
||||
resp, err := http.Get(srv.URL + path)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
require.NoError(t, err)
|
||||
return resp.StatusCode, string(body)
|
||||
}
|
||||
|
||||
func noRedirectClient() *http.Client {
|
||||
return &http.Client{CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }}
|
||||
}
|
||||
|
||||
func TestOverview_GroupsServicesPerMachineAndSwarm(t *testing.T) {
|
||||
srv, _, _, _ := newTestServer(t)
|
||||
code, body := get(t, srv, "/")
|
||||
assert.Equal(t, http.StatusOK, code)
|
||||
assert.Contains(t, body, "<html")
|
||||
assert.Contains(t, body, "node-2")
|
||||
assert.Contains(t, body, "Swarm")
|
||||
assert.Contains(t, body, "slusk/web")
|
||||
assert.Contains(t, body, "jellyfin_jellyfin")
|
||||
assert.Contains(t, body, "sha256:0f42497a69fa…", "digests are shortened")
|
||||
assert.Contains(t, body, "managed outside")
|
||||
assert.Less(t, strings.Index(body, "node-2"), strings.Index(body, "Swarm"), "machines before Swarm")
|
||||
}
|
||||
|
||||
func TestServicesFragment_IsNotAFullPage(t *testing.T) {
|
||||
srv, _, _, _ := newTestServer(t)
|
||||
code, body := get(t, srv, "/fragments/services")
|
||||
assert.Equal(t, http.StatusOK, code)
|
||||
assert.NotContains(t, body, "<html")
|
||||
assert.Contains(t, body, "slusk/web")
|
||||
}
|
||||
|
||||
func TestAgentsPage_ShowsConnectionState(t *testing.T) {
|
||||
srv, _, _, _ := newTestServer(t)
|
||||
_, body := get(t, srv, "/agents")
|
||||
assert.Regexp(t, regexp.MustCompile(`node-2[\s\S]*online`), body)
|
||||
assert.Regexp(t, regexp.MustCompile(`node-3[\s\S]*offline`), body)
|
||||
assert.Regexp(t, regexp.MustCompile(`old[\s\S]*revoked`), body)
|
||||
}
|
||||
|
||||
func TestCreateAgent_ShowsSnippetOnceAndStoresHash(t *testing.T) {
|
||||
srv, store, _, _ := newTestServer(t)
|
||||
resp, err := http.PostForm(srv.URL+"/agents", url.Values{"name": {"node-4"}})
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
|
||||
require.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
assert.Equal(t, "no-store", resp.Header.Get("Cache-Control"), "the one-time token must not be cached")
|
||||
token := regexp.MustCompile(`pua_[A-Za-z0-9_-]{43}`).FindString(string(body))
|
||||
require.NotEmpty(t, token)
|
||||
assert.Equal(t, agenttoken.Hash(token), store.createdHash("node-4"), "only the hash is stored")
|
||||
assert.Contains(t, string(body), "MASTER_KEY: sha256:abc")
|
||||
assert.Contains(t, string(body), "MASTER_URL: wss://10.0.0.5:8090/agent")
|
||||
assert.Contains(t, string(body), "shown only once")
|
||||
}
|
||||
|
||||
func TestCreateAgent_Validation(t *testing.T) {
|
||||
srv, _, _, _ := newTestServer(t)
|
||||
cases := []struct {
|
||||
name, input string
|
||||
wantCode int
|
||||
wantText string
|
||||
}{
|
||||
{"duplicate", "node-2", http.StatusConflict, "already exists"},
|
||||
{"invalid characters", "Node 2!", http.StatusBadRequest, "lowercase letters, digits and dashes"},
|
||||
{"empty", " ", http.StatusBadRequest, "lowercase letters, digits and dashes"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
resp, err := http.PostForm(srv.URL+"/agents", url.Values{"name": {c.input}})
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
assert.Equal(t, c.wantCode, resp.StatusCode)
|
||||
assert.Contains(t, string(body), c.wantText)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRevokeAgent_RevokesAndDisconnects(t *testing.T) {
|
||||
srv, store, hub, broker := newTestServer(t)
|
||||
ch, cancel := broker.Subscribe()
|
||||
defer cancel()
|
||||
resp, err := noRedirectClient().Post(srv.URL+"/agents/2/revoke", "application/x-www-form-urlencoded", nil)
|
||||
require.NoError(t, err)
|
||||
_ = resp.Body.Close()
|
||||
|
||||
assert.Equal(t, http.StatusSeeOther, resp.StatusCode)
|
||||
assert.Equal(t, "/agents", resp.Header.Get("Location"))
|
||||
assert.Equal(t, int64(2), store.revoked())
|
||||
assert.Equal(t, int64(2), hub.lastDisconnected(), "a revoked agent must be cut off immediately")
|
||||
select {
|
||||
case topic := <-ch:
|
||||
assert.Equal(t, events.Services, topic, "the overview must drop the revoked agent's services")
|
||||
default:
|
||||
t.Fatal("revoke published no services event")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRevokeAgent_BadID(t *testing.T) {
|
||||
srv, _, _, _ := newTestServer(t)
|
||||
resp, err := noRedirectClient().Post(srv.URL+"/agents/abc/revoke", "application/x-www-form-urlencoded", nil)
|
||||
require.NoError(t, err)
|
||||
_ = resp.Body.Close()
|
||||
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
||||
}
|
||||
|
||||
func TestEvents_StreamsTopics(t *testing.T) {
|
||||
srv, _, _, broker := newTestServer(t)
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, srv.URL+"/events", nil)
|
||||
require.NoError(t, err)
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
|
||||
assert.Equal(t, "text/event-stream", resp.Header.Get("Content-Type"))
|
||||
assert.Equal(t, "no", resp.Header.Get("X-Accel-Buffering"), "nginx must not buffer the stream")
|
||||
|
||||
reader := bufio.NewReader(resp.Body)
|
||||
line, err := reader.ReadString('\n') // ": connected"
|
||||
require.NoError(t, err)
|
||||
require.True(t, strings.HasPrefix(line, ":"))
|
||||
|
||||
broker.Publish(events.Services)
|
||||
for {
|
||||
line, err = reader.ReadString('\n')
|
||||
require.NoError(t, err)
|
||||
if strings.HasPrefix(line, "event:") {
|
||||
break
|
||||
}
|
||||
}
|
||||
assert.Equal(t, "event: services\n", line)
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
:root { --bg: #111418; --fg: #e6e8eb; --muted: #8a929c; --line: #262b31; --ok: #3fb950; --warn: #d29922; --bad: #f85149; --accent: #58a6ff; }
|
||||
@media (prefers-color-scheme: light) {
|
||||
:root { --bg: #ffffff; --fg: #1f2328; --muted: #59636e; --line: #d1d9e0; --ok: #1a7f37; --warn: #9a6700; --bad: #cf222e; --accent: #0969da; }
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
body { margin: 0; background: var(--bg); color: var(--fg); font: 14px/1.5 system-ui, sans-serif; }
|
||||
nav { display: flex; gap: 16px; align-items: center; padding: 12px 16px; border-bottom: 1px solid var(--line); }
|
||||
nav form { margin-left: auto; }
|
||||
main { padding: 16px; max-width: 1200px; margin: 0 auto; }
|
||||
a { color: var(--accent); }
|
||||
table { width: 100%; border-collapse: collapse; margin-bottom: 24px; }
|
||||
th, td { text-align: left; padding: 6px 8px; border-bottom: 1px solid var(--line); }
|
||||
th { color: var(--muted); font-weight: 500; }
|
||||
code, pre { font: 13px ui-monospace, monospace; }
|
||||
pre { padding: 12px; border: 1px solid var(--line); overflow-x: auto; }
|
||||
.badge { font-size: 12px; color: var(--muted); border: 1px solid var(--line); padding: 0 6px; border-radius: 8px; }
|
||||
.status-running { color: var(--ok); }
|
||||
.status-degraded, .status-unknown { color: var(--warn); }
|
||||
.status-stopped { color: var(--bad); }
|
||||
.error { color: var(--bad); }
|
||||
.warning { color: var(--warn); }
|
||||
form.inline { display: flex; gap: 8px; margin-bottom: 16px; }
|
||||
@media (max-width: 600px) { table { display: block; overflow-x: auto; } }
|
||||
@@ -0,0 +1,290 @@
|
||||
/*
|
||||
Server Sent Events Extension
|
||||
============================
|
||||
This extension adds support for Server Sent Events to htmx. See /www/extensions/sse.md for usage instructions.
|
||||
|
||||
*/
|
||||
|
||||
(function() {
|
||||
/** @type {import("../htmx").HtmxInternalApi} */
|
||||
var api
|
||||
|
||||
htmx.defineExtension('sse', {
|
||||
|
||||
/**
|
||||
* Init saves the provided reference to the internal HTMX API.
|
||||
*
|
||||
* @param {import("../htmx").HtmxInternalApi} api
|
||||
* @returns void
|
||||
*/
|
||||
init: function(apiRef) {
|
||||
// store a reference to the internal API.
|
||||
api = apiRef
|
||||
|
||||
// set a function in the public API for creating new EventSource objects
|
||||
if (htmx.createEventSource == undefined) {
|
||||
htmx.createEventSource = createEventSource
|
||||
}
|
||||
},
|
||||
|
||||
getSelectors: function() {
|
||||
return ['[sse-connect]', '[data-sse-connect]', '[sse-swap]', '[data-sse-swap]']
|
||||
},
|
||||
|
||||
/**
|
||||
* onEvent handles all events passed to this extension.
|
||||
*
|
||||
* @param {string} name
|
||||
* @param {Event} evt
|
||||
* @returns void
|
||||
*/
|
||||
onEvent: function(name, evt) {
|
||||
var parent = evt.target || evt.detail.elt
|
||||
switch (name) {
|
||||
case 'htmx:beforeCleanupElement':
|
||||
var internalData = api.getInternalData(parent)
|
||||
// Try to remove remove an EventSource when elements are removed
|
||||
var source = internalData.sseEventSource
|
||||
if (source) {
|
||||
api.triggerEvent(parent, 'htmx:sseClose', {
|
||||
source,
|
||||
type: 'nodeReplaced',
|
||||
})
|
||||
internalData.sseEventSource.close()
|
||||
}
|
||||
|
||||
return
|
||||
|
||||
// Try to create EventSources when elements are processed
|
||||
case 'htmx:afterProcessNode':
|
||||
ensureEventSourceOnElement(parent)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
/// ////////////////////////////////////////////
|
||||
// HELPER FUNCTIONS
|
||||
/// ////////////////////////////////////////////
|
||||
|
||||
/**
|
||||
* createEventSource is the default method for creating new EventSource objects.
|
||||
* it is hoisted into htmx.config.createEventSource to be overridden by the user, if needed.
|
||||
*
|
||||
* @param {string} url
|
||||
* @returns EventSource
|
||||
*/
|
||||
function createEventSource(url) {
|
||||
return new EventSource(url, { withCredentials: true })
|
||||
}
|
||||
|
||||
/**
|
||||
* registerSSE looks for attributes that can contain sse events, right
|
||||
* now hx-trigger and sse-swap and adds listeners based on these attributes too
|
||||
* the closest event source
|
||||
*
|
||||
* @param {HTMLElement} elt
|
||||
*/
|
||||
function registerSSE(elt) {
|
||||
// Add message handlers for every `sse-swap` attribute
|
||||
if (api.getAttributeValue(elt, 'sse-swap')) {
|
||||
// Find closest existing event source
|
||||
var sourceElement = api.getClosestMatch(elt, hasEventSource)
|
||||
if (sourceElement == null) {
|
||||
// api.triggerErrorEvent(elt, "htmx:noSSESourceError")
|
||||
return null // no eventsource in parentage, orphaned element
|
||||
}
|
||||
|
||||
// Set internalData and source
|
||||
var internalData = api.getInternalData(sourceElement)
|
||||
var source = internalData.sseEventSource
|
||||
|
||||
var sseSwapAttr = api.getAttributeValue(elt, 'sse-swap')
|
||||
var sseEventNames = sseSwapAttr.split(',')
|
||||
|
||||
for (var i = 0; i < sseEventNames.length; i++) {
|
||||
const sseEventName = sseEventNames[i].trim()
|
||||
const listener = function(event) {
|
||||
// If the source is missing then close SSE
|
||||
if (maybeCloseSSESource(sourceElement)) {
|
||||
return
|
||||
}
|
||||
|
||||
// If the body no longer contains the element, remove the listener
|
||||
if (!api.bodyContains(elt)) {
|
||||
source.removeEventListener(sseEventName, listener)
|
||||
return
|
||||
}
|
||||
|
||||
// swap the response into the DOM and trigger a notification
|
||||
if (!api.triggerEvent(elt, 'htmx:sseBeforeMessage', event)) {
|
||||
return
|
||||
}
|
||||
swap(elt, event.data)
|
||||
api.triggerEvent(elt, 'htmx:sseMessage', event)
|
||||
}
|
||||
|
||||
// Register the new listener
|
||||
api.getInternalData(elt).sseEventListener = listener
|
||||
source.addEventListener(sseEventName, listener)
|
||||
}
|
||||
}
|
||||
|
||||
// Add message handlers for every `hx-trigger="sse:*"` attribute
|
||||
if (api.getAttributeValue(elt, 'hx-trigger')) {
|
||||
// Find closest existing event source
|
||||
var sourceElement = api.getClosestMatch(elt, hasEventSource)
|
||||
if (sourceElement == null) {
|
||||
// api.triggerErrorEvent(elt, "htmx:noSSESourceError")
|
||||
return null // no eventsource in parentage, orphaned element
|
||||
}
|
||||
|
||||
// Set internalData and source
|
||||
var internalData = api.getInternalData(sourceElement)
|
||||
var source = internalData.sseEventSource
|
||||
|
||||
var triggerSpecs = api.getTriggerSpecs(elt)
|
||||
triggerSpecs.forEach(function(ts) {
|
||||
if (ts.trigger.slice(0, 4) !== 'sse:') {
|
||||
return
|
||||
}
|
||||
|
||||
var listener = function (event) {
|
||||
if (maybeCloseSSESource(sourceElement)) {
|
||||
return
|
||||
}
|
||||
if (!api.bodyContains(elt)) {
|
||||
source.removeEventListener(ts.trigger.slice(4), listener)
|
||||
}
|
||||
// Trigger events to be handled by the rest of htmx
|
||||
htmx.trigger(elt, ts.trigger, event)
|
||||
htmx.trigger(elt, 'htmx:sseMessage', event)
|
||||
}
|
||||
|
||||
// Register the new listener
|
||||
api.getInternalData(elt).sseEventListener = listener
|
||||
source.addEventListener(ts.trigger.slice(4), listener)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* ensureEventSourceOnElement creates a new EventSource connection on the provided element.
|
||||
* If a usable EventSource already exists, then it is returned. If not, then a new EventSource
|
||||
* is created and stored in the element's internalData.
|
||||
* @param {HTMLElement} elt
|
||||
* @param {number} retryCount
|
||||
* @returns {EventSource | null}
|
||||
*/
|
||||
function ensureEventSourceOnElement(elt, retryCount) {
|
||||
if (elt == null) {
|
||||
return null
|
||||
}
|
||||
|
||||
// handle extension source creation attribute
|
||||
if (api.getAttributeValue(elt, 'sse-connect')) {
|
||||
var sseURL = api.getAttributeValue(elt, 'sse-connect')
|
||||
if (sseURL == null) {
|
||||
return
|
||||
}
|
||||
|
||||
ensureEventSource(elt, sseURL, retryCount)
|
||||
}
|
||||
|
||||
registerSSE(elt)
|
||||
}
|
||||
|
||||
function ensureEventSource(elt, url, retryCount) {
|
||||
var source = htmx.createEventSource(url)
|
||||
|
||||
source.onerror = function(err) {
|
||||
// Log an error event
|
||||
api.triggerErrorEvent(elt, 'htmx:sseError', { error: err, source })
|
||||
|
||||
// If parent no longer exists in the document, then clean up this EventSource
|
||||
if (maybeCloseSSESource(elt)) {
|
||||
return
|
||||
}
|
||||
|
||||
// Otherwise, try to reconnect the EventSource
|
||||
if (source.readyState === EventSource.CLOSED) {
|
||||
retryCount = retryCount || 0
|
||||
retryCount = Math.max(Math.min(retryCount * 2, 128), 1)
|
||||
var timeout = retryCount * 500
|
||||
window.setTimeout(function() {
|
||||
ensureEventSourceOnElement(elt, retryCount)
|
||||
}, timeout)
|
||||
}
|
||||
}
|
||||
|
||||
source.onopen = function(evt) {
|
||||
api.triggerEvent(elt, 'htmx:sseOpen', { source })
|
||||
|
||||
if (retryCount && retryCount > 0) {
|
||||
const childrenToFix = elt.querySelectorAll("[sse-swap], [data-sse-swap], [hx-trigger], [data-hx-trigger]")
|
||||
for (let i = 0; i < childrenToFix.length; i++) {
|
||||
registerSSE(childrenToFix[i])
|
||||
}
|
||||
// We want to increase the reconnection delay for consecutive failed attempts only
|
||||
retryCount = 0
|
||||
}
|
||||
}
|
||||
|
||||
api.getInternalData(elt).sseEventSource = source
|
||||
|
||||
|
||||
var closeAttribute = api.getAttributeValue(elt, "sse-close");
|
||||
if (closeAttribute) {
|
||||
// close eventsource when this message is received
|
||||
source.addEventListener(closeAttribute, function() {
|
||||
api.triggerEvent(elt, 'htmx:sseClose', {
|
||||
source,
|
||||
type: 'message',
|
||||
})
|
||||
source.close()
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* maybeCloseSSESource confirms that the parent element still exists.
|
||||
* If not, then any associated SSE source is closed and the function returns true.
|
||||
*
|
||||
* @param {HTMLElement} elt
|
||||
* @returns boolean
|
||||
*/
|
||||
function maybeCloseSSESource(elt) {
|
||||
if (!api.bodyContains(elt)) {
|
||||
var source = api.getInternalData(elt).sseEventSource
|
||||
if (source != undefined) {
|
||||
api.triggerEvent(elt, 'htmx:sseClose', {
|
||||
source,
|
||||
type: 'nodeMissing',
|
||||
})
|
||||
source.close()
|
||||
// source = null
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @param {HTMLElement} elt
|
||||
* @param {string} content
|
||||
*/
|
||||
function swap(elt, content) {
|
||||
api.withExtensions(elt, function(extension) {
|
||||
content = extension.transformResponse(content, null, elt)
|
||||
})
|
||||
|
||||
var swapSpec = api.getSwapSpecification(elt)
|
||||
var target = api.getTarget(elt)
|
||||
api.swap(target, content, swapSpec)
|
||||
}
|
||||
|
||||
|
||||
function hasEventSource(node) {
|
||||
return api.getInternalData(node).sseEventSource != null
|
||||
}
|
||||
})()
|
||||
+1
File diff suppressed because one or more lines are too long
@@ -0,0 +1,6 @@
|
||||
{{define "content"}}
|
||||
<h1>Agent "{{.Created.Name}}" created</h1>
|
||||
<p class="warning">The token is shown only once. Save this as <code>docker-compose.yml</code> on the machine and run <code>docker compose up -d</code>.</p>
|
||||
<pre>{{.Snippet}}</pre>
|
||||
<p><a href="/agents">Back to agents</a></p>
|
||||
{{end}}
|
||||
@@ -0,0 +1,38 @@
|
||||
{{define "content"}}
|
||||
<h1>Agents</h1>
|
||||
<form method="post" action="/agents" class="inline">
|
||||
<input type="hidden" name="csrf_token" value="{{.CSRF}}">
|
||||
<label>Name <input name="name" required placeholder="node-2"></label>
|
||||
<button type="submit">Add agent</button>
|
||||
</form>
|
||||
{{if .Error}}<p class="error">{{.Error}}</p>{{end}}
|
||||
<div id="agents" hx-get="/fragments/agents" hx-trigger="sse:agents" hx-swap="innerHTML">
|
||||
{{template "agents" .}}
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{define "agents"}}
|
||||
<table>
|
||||
<thead><tr><th>Name</th><th>Hostname</th><th>State</th><th>Version</th><th>Swarm manager</th><th>Last seen</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
{{range .Agents}}
|
||||
<tr>
|
||||
<td>{{.Name}}</td>
|
||||
<td>{{.Hostname}}</td>
|
||||
<td>{{if .RevokedAt}}<span class="status status-stopped">revoked</span>{{else if .Online}}<span class="status status-running">online</span>{{else}}<span class="status status-degraded">offline</span>{{end}}</td>
|
||||
<td>{{.AgentVersion}}</td>
|
||||
<td>{{if .SwarmManager}}yes{{end}}</td>
|
||||
<td>{{with .LastSeenAt}}{{.Format "2006-01-02 15:04"}}{{end}}</td>
|
||||
<td>{{if not .RevokedAt}}
|
||||
<form method="post" action="/agents/{{.ID}}/revoke" onsubmit="return confirm('Revoke {{.Name}}? It is disconnected immediately and must be added again to come back.')">
|
||||
<input type="hidden" name="csrf_token" value="{{$.CSRF}}">
|
||||
<button type="submit">Revoke</button>
|
||||
</form>
|
||||
{{end}}</td>
|
||||
</tr>
|
||||
{{else}}
|
||||
<tr><td colspan="7">No agents yet.</td></tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
{{end}}
|
||||
@@ -0,0 +1,22 @@
|
||||
{{define "layout"}}<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>{{.Title}} · package-updater</title>
|
||||
<link rel="stylesheet" href="/static/app.css">
|
||||
<script src="/static/htmx.min.js"></script>
|
||||
<script src="/static/htmx-ext-sse.js"></script>
|
||||
</head>
|
||||
<body hx-headers='{"X-CSRF-Token": "{{.CSRF}}"}' hx-ext="sse" sse-connect="/events">
|
||||
<nav>
|
||||
<a href="/">Services</a>
|
||||
<a href="/agents">Agents</a>
|
||||
<form method="post" action="/auth/logout">
|
||||
<input type="hidden" name="csrf_token" value="{{.CSRF}}">
|
||||
<button type="submit">Log out</button>
|
||||
</form>
|
||||
</nav>
|
||||
<main>{{template "content" .}}</main>
|
||||
</body>
|
||||
</html>{{end}}
|
||||
@@ -0,0 +1,29 @@
|
||||
{{define "content"}}
|
||||
<h1>Services</h1>
|
||||
<div id="services" hx-get="/fragments/services" hx-trigger="sse:services" hx-swap="innerHTML">
|
||||
{{template "services" .}}
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{define "services"}}
|
||||
{{range .Groups}}
|
||||
<section>
|
||||
<h2>{{.Title}}</h2>
|
||||
<table>
|
||||
<thead><tr><th>Service</th><th>Image</th><th>Status</th><th>Mode</th></tr></thead>
|
||||
<tbody>
|
||||
{{range .Services}}
|
||||
<tr>
|
||||
<td>{{.DisplayName}}{{if .Self}} <span class="badge">managed outside</span>{{end}}</td>
|
||||
<td><code>{{shortRef .ActualRef}}</code></td>
|
||||
<td class="status status-{{.Status}}">{{.Status}}</td>
|
||||
<td>{{.UpdateMode}}</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
{{else}}
|
||||
<p>No services reported yet. <a href="/agents">Add an agent</a> to get started.</p>
|
||||
{{end}}
|
||||
{{end}}
|
||||
@@ -0,0 +1,51 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/shcizo/package-updater/internal/imageref"
|
||||
"github.com/shcizo/package-updater/internal/master/model"
|
||||
"github.com/shcizo/package-updater/internal/protocol"
|
||||
)
|
||||
|
||||
type serviceGroup struct {
|
||||
Title string
|
||||
Services []model.Service
|
||||
}
|
||||
|
||||
// groupServices groups machine-bound services per agent and Swarm services
|
||||
// in one group, keeping the store's order (machines first, Swarm last).
|
||||
func groupServices(services []model.Service) []serviceGroup {
|
||||
var groups []serviceGroup
|
||||
index := map[string]int{}
|
||||
for _, s := range services {
|
||||
title := s.AgentName
|
||||
if s.Kind == protocol.KindSwarm {
|
||||
title = "Swarm"
|
||||
} else if title == "" {
|
||||
title = "Unknown agent"
|
||||
}
|
||||
i, ok := index[title]
|
||||
if !ok {
|
||||
i = len(groups)
|
||||
index[title] = i
|
||||
groups = append(groups, serviceGroup{Title: title})
|
||||
}
|
||||
groups[i].Services = append(groups[i].Services, s)
|
||||
}
|
||||
return groups
|
||||
}
|
||||
|
||||
// shortRef shortens a digest in an image reference to 12 hex characters.
|
||||
func shortRef(ref string) string {
|
||||
r := imageref.Parse(ref)
|
||||
if hex, ok := strings.CutPrefix(r.Digest, "sha256:"); ok && len(hex) > 12 {
|
||||
r.Digest = "sha256:" + hex[:12] + "…"
|
||||
}
|
||||
return r.String()
|
||||
}
|
||||
|
||||
type agentRow struct {
|
||||
model.Agent
|
||||
Online bool
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
// Package webauth logs people into the master UI through OIDC (Authentik),
|
||||
// keeps their sessions, and protects state-changing requests with a CSRF
|
||||
// token.
|
||||
package webauth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/subtle"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"golang.org/x/oauth2"
|
||||
|
||||
"github.com/shcizo/package-updater/internal/master/model"
|
||||
)
|
||||
|
||||
const (
|
||||
sessionCookieName = "pu_session"
|
||||
flowCookieName = "pu_oidc_flow"
|
||||
sessionTTL = 12 * time.Hour
|
||||
)
|
||||
|
||||
// Store keeps sessions. *store.Store satisfies it.
|
||||
type Store interface {
|
||||
CreateSession(ctx context.Context, s model.Session) error
|
||||
GetSession(ctx context.Context, id string, now time.Time) (model.Session, error)
|
||||
DeleteSession(ctx context.Context, id string) error
|
||||
}
|
||||
|
||||
// Auth holds the login handlers and the session middleware.
|
||||
type Auth struct {
|
||||
provider Provider
|
||||
store Store
|
||||
allowedGroup string
|
||||
secure bool
|
||||
log *slog.Logger
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
// New returns an Auth. Only members of allowedGroup may log in. secureCookies
|
||||
// should be true whenever the UI is served over HTTPS.
|
||||
func New(p Provider, s Store, allowedGroup string, secureCookies bool, log *slog.Logger) *Auth {
|
||||
return &Auth{provider: p, store: s, allowedGroup: allowedGroup, secure: secureCookies, log: log, now: time.Now}
|
||||
}
|
||||
|
||||
// Login starts the OIDC flow. State and PKCE verifier travel in a short-lived
|
||||
// cookie scoped to /auth/; tampering with it can only break the user's own
|
||||
// login.
|
||||
func (a *Auth) Login(w http.ResponseWriter, r *http.Request) {
|
||||
state := randomString()
|
||||
verifier := oauth2.GenerateVerifier()
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: flowCookieName, Value: state + "." + verifier, Path: "/auth/",
|
||||
MaxAge: 600, HttpOnly: true, Secure: a.secure, SameSite: http.SameSiteLaxMode,
|
||||
})
|
||||
http.Redirect(w, r, a.provider.AuthCodeURL(state, verifier), http.StatusFound)
|
||||
}
|
||||
|
||||
// Callback finishes the OIDC flow, checks group membership and creates the
|
||||
// session.
|
||||
func (a *Auth) Callback(w http.ResponseWriter, r *http.Request) {
|
||||
flow, err := r.Cookie(flowCookieName)
|
||||
if err != nil {
|
||||
http.Error(w, "login flow expired, start again", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
state, verifier, ok := strings.Cut(flow.Value, ".")
|
||||
if !ok || state == "" || subtle.ConstantTimeCompare([]byte(r.URL.Query().Get("state")), []byte(state)) != 1 {
|
||||
http.Error(w, "invalid login state", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
http.SetCookie(w, &http.Cookie{Name: flowCookieName, Path: "/auth/", MaxAge: -1})
|
||||
|
||||
claims, err := a.provider.Exchange(r.Context(), r.URL.Query().Get("code"), verifier)
|
||||
if err != nil {
|
||||
a.log.Warn("login_failed", "error", err)
|
||||
http.Error(w, "login failed", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
if !slices.Contains(claims.Groups, a.allowedGroup) {
|
||||
a.log.Warn("login_forbidden", "subject", claims.Subject, "required_group", a.allowedGroup)
|
||||
http.Error(w, "forbidden: you are not a member of the group allowed to use package-updater", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
sess := model.Session{
|
||||
ID: randomString(), Subject: claims.Subject, Name: claims.Name, Email: claims.Email,
|
||||
CSRFToken: randomString(), ExpiresAt: a.now().Add(sessionTTL),
|
||||
}
|
||||
if err := a.store.CreateSession(r.Context(), sess); err != nil {
|
||||
a.log.Error("create_session_failed", "error", err)
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: sessionCookieName, Value: sess.ID, Path: "/", Expires: sess.ExpiresAt,
|
||||
HttpOnly: true, Secure: a.secure, SameSite: http.SameSiteLaxMode,
|
||||
})
|
||||
a.log.Info("login", "subject", claims.Subject, "name", claims.Name)
|
||||
http.Redirect(w, r, "/", http.StatusFound)
|
||||
}
|
||||
|
||||
// Logout ends the session. Mount behind Require so it is CSRF-protected.
|
||||
func (a *Auth) Logout(w http.ResponseWriter, r *http.Request) {
|
||||
if s, ok := SessionFrom(r.Context()); ok {
|
||||
if err := a.store.DeleteSession(r.Context(), s.ID); err != nil {
|
||||
a.log.Error("delete_session_failed", "error", err)
|
||||
}
|
||||
}
|
||||
http.SetCookie(w, &http.Cookie{Name: sessionCookieName, Path: "/", MaxAge: -1})
|
||||
_, _ = w.Write([]byte("Logged out."))
|
||||
}
|
||||
|
||||
// Require lets a request through only with a valid session, and for
|
||||
// anything but GET/HEAD only with the session's CSRF token in the
|
||||
// X-CSRF-Token header or the csrf_token form field. htmx requests without a
|
||||
// session get 401 + HX-Redirect so the whole page navigates to the login,
|
||||
// instead of the login page being swapped into a fragment. Event-stream
|
||||
// (SSE) requests without a session get a plain 401: a redirect would make
|
||||
// the browser's EventSource retry /auth/login repeatedly and overwrite the
|
||||
// login flow cookie of a login in progress in another tab.
|
||||
func (a *Auth) Require(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
sess, err := a.session(r)
|
||||
if err != nil && !errors.Is(err, model.ErrNotFound) {
|
||||
a.log.Error("session_lookup_failed", "error", err)
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
switch {
|
||||
case r.Header.Get("HX-Request") == "true":
|
||||
w.Header().Set("HX-Redirect", "/auth/login")
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
case strings.Contains(r.Header.Get("Accept"), "text/event-stream"):
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
case r.Method == http.MethodGet:
|
||||
http.Redirect(w, r, "/auth/login", http.StatusFound)
|
||||
default:
|
||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||
}
|
||||
return
|
||||
}
|
||||
if r.Method != http.MethodGet && r.Method != http.MethodHead {
|
||||
token := r.Header.Get("X-CSRF-Token")
|
||||
if token == "" {
|
||||
token = r.PostFormValue("csrf_token") // body only: a token in the URL leaks via logs and Referer
|
||||
}
|
||||
if subtle.ConstantTimeCompare([]byte(token), []byte(sess.CSRFToken)) != 1 {
|
||||
http.Error(w, "invalid CSRF token", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
}
|
||||
next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), sessionKey{}, sess)))
|
||||
})
|
||||
}
|
||||
|
||||
func (a *Auth) session(r *http.Request) (model.Session, error) {
|
||||
c, err := r.Cookie(sessionCookieName)
|
||||
if err != nil {
|
||||
return model.Session{}, model.ErrNotFound
|
||||
}
|
||||
return a.store.GetSession(r.Context(), c.Value, a.now())
|
||||
}
|
||||
|
||||
type sessionKey struct{}
|
||||
|
||||
// SessionFrom returns the session Require stored in ctx.
|
||||
func SessionFrom(ctx context.Context) (model.Session, bool) {
|
||||
s, ok := ctx.Value(sessionKey{}).(model.Session)
|
||||
return s, ok
|
||||
}
|
||||
|
||||
func randomString() string {
|
||||
var b [32]byte
|
||||
_, _ = rand.Read(b[:])
|
||||
return base64.RawURLEncoding.EncodeToString(b[:])
|
||||
}
|
||||
@@ -0,0 +1,249 @@
|
||||
package webauth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/shcizo/package-updater/internal/master/model"
|
||||
)
|
||||
|
||||
type fakeProvider struct {
|
||||
claims Claims
|
||||
err error
|
||||
gotVer string
|
||||
}
|
||||
|
||||
func (p *fakeProvider) AuthCodeURL(state, _ string) string {
|
||||
return "https://auth.example/authorize?state=" + url.QueryEscape(state)
|
||||
}
|
||||
|
||||
func (p *fakeProvider) Exchange(_ context.Context, _ string, verifier string) (Claims, error) {
|
||||
p.gotVer = verifier
|
||||
return p.claims, p.err
|
||||
}
|
||||
|
||||
type fakeStore struct {
|
||||
mu sync.Mutex
|
||||
sessions map[string]model.Session
|
||||
}
|
||||
|
||||
func newFakeStore() *fakeStore { return &fakeStore{sessions: map[string]model.Session{}} }
|
||||
|
||||
func (f *fakeStore) CreateSession(_ context.Context, s model.Session) error {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
f.sessions[s.ID] = s
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeStore) GetSession(_ context.Context, id string, now time.Time) (model.Session, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
s, ok := f.sessions[id]
|
||||
if !ok || !s.ExpiresAt.After(now) {
|
||||
return model.Session{}, model.ErrNotFound
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func (f *fakeStore) DeleteSession(_ context.Context, id string) error {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
delete(f.sessions, id)
|
||||
return nil
|
||||
}
|
||||
|
||||
func newAuth(p *fakeProvider, s *fakeStore) *Auth {
|
||||
return New(p, s, "updater-admins", true, slog.New(slog.NewTextHandler(io.Discard, nil)))
|
||||
}
|
||||
|
||||
// startLogin runs /auth/login and returns the flow cookie and the state.
|
||||
func startLogin(t *testing.T, a *Auth) (*http.Cookie, string) {
|
||||
t.Helper()
|
||||
rec := httptest.NewRecorder()
|
||||
a.Login(rec, httptest.NewRequest(http.MethodGet, "/auth/login", nil))
|
||||
require.Equal(t, http.StatusFound, rec.Code)
|
||||
loc, err := url.Parse(rec.Header().Get("Location"))
|
||||
require.NoError(t, err)
|
||||
cookies := rec.Result().Cookies()
|
||||
require.Len(t, cookies, 1)
|
||||
return cookies[0], loc.Query().Get("state")
|
||||
}
|
||||
|
||||
func callback(a *Auth, flow *http.Cookie, state string) *httptest.ResponseRecorder {
|
||||
req := httptest.NewRequest(http.MethodGet, "/auth/callback?code=c&state="+url.QueryEscape(state), nil)
|
||||
if flow != nil {
|
||||
req.AddCookie(flow)
|
||||
}
|
||||
rec := httptest.NewRecorder()
|
||||
a.Callback(rec, req)
|
||||
return rec
|
||||
}
|
||||
|
||||
func TestLogin_SetsFlowCookieAndRedirects(t *testing.T) {
|
||||
a := newAuth(&fakeProvider{}, newFakeStore())
|
||||
flow, state := startLogin(t, a)
|
||||
assert.True(t, flow.HttpOnly)
|
||||
assert.True(t, flow.Secure)
|
||||
assert.NotEmpty(t, state)
|
||||
}
|
||||
|
||||
func TestCallback_InvalidStateRejected(t *testing.T) {
|
||||
a := newAuth(&fakeProvider{claims: Claims{Subject: "u", Groups: []string{"updater-admins"}}}, newFakeStore())
|
||||
flow, _ := startLogin(t, a)
|
||||
emptyStateFlow := &http.Cookie{Name: flowCookieName, Value: ".verifier"}
|
||||
cases := []struct {
|
||||
name string
|
||||
flow *http.Cookie
|
||||
state string
|
||||
}{
|
||||
{"state mismatch", flow, "forged"},
|
||||
{"empty state in cookie and query", emptyStateFlow, ""},
|
||||
{"missing flow cookie", nil, ""},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
rec := callback(a, c.flow, c.state)
|
||||
assert.Equal(t, http.StatusBadRequest, rec.Code)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCallback_UserOutsideGroupForbidden(t *testing.T) {
|
||||
store := newFakeStore()
|
||||
a := newAuth(&fakeProvider{claims: Claims{Subject: "u", Groups: []string{"family"}}}, store)
|
||||
flow, state := startLogin(t, a)
|
||||
rec := callback(a, flow, state)
|
||||
assert.Equal(t, http.StatusForbidden, rec.Code)
|
||||
assert.Empty(t, store.sessions)
|
||||
}
|
||||
|
||||
func TestCallback_ExchangeFailure(t *testing.T) {
|
||||
a := newAuth(&fakeProvider{err: errors.New("bad code")}, newFakeStore())
|
||||
flow, state := startLogin(t, a)
|
||||
assert.Equal(t, http.StatusUnauthorized, callback(a, flow, state).Code)
|
||||
}
|
||||
|
||||
func TestCallback_CreatesSession(t *testing.T) {
|
||||
store := newFakeStore()
|
||||
p := &fakeProvider{claims: Claims{Subject: "u1", Name: "Samuel", Groups: []string{"updater-admins"}}}
|
||||
a := newAuth(p, store)
|
||||
flow, state := startLogin(t, a)
|
||||
rec := callback(a, flow, state)
|
||||
|
||||
require.Equal(t, http.StatusFound, rec.Code)
|
||||
assert.Equal(t, "/", rec.Header().Get("Location"))
|
||||
assert.NotEmpty(t, p.gotVer, "the PKCE verifier from the flow cookie must reach the exchange")
|
||||
var sessionCookie *http.Cookie
|
||||
for _, c := range rec.Result().Cookies() {
|
||||
if c.Name == sessionCookieName {
|
||||
sessionCookie = c
|
||||
}
|
||||
}
|
||||
require.NotNil(t, sessionCookie)
|
||||
assert.True(t, sessionCookie.HttpOnly)
|
||||
assert.Equal(t, http.SameSiteLaxMode, sessionCookie.SameSite)
|
||||
s, ok := store.sessions[sessionCookie.Value]
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, "Samuel", s.Name)
|
||||
assert.NotEmpty(t, s.CSRFToken)
|
||||
}
|
||||
|
||||
func protectedRequest(method, target string, sessionID string) *http.Request {
|
||||
req := httptest.NewRequest(method, target, nil)
|
||||
if sessionID != "" {
|
||||
req.AddCookie(&http.Cookie{Name: sessionCookieName, Value: sessionID})
|
||||
}
|
||||
return req
|
||||
}
|
||||
|
||||
func TestRequire(t *testing.T) {
|
||||
store := newFakeStore()
|
||||
store.sessions["live"] = model.Session{ID: "live", Name: "Samuel", CSRFToken: "tok", ExpiresAt: time.Now().Add(time.Hour)}
|
||||
store.sessions["old"] = model.Session{ID: "old", CSRFToken: "tok", ExpiresAt: time.Now().Add(-time.Hour)}
|
||||
a := newAuth(&fakeProvider{}, store)
|
||||
|
||||
var gotName string
|
||||
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
s, _ := SessionFrom(r.Context())
|
||||
gotName = s.Name
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
})
|
||||
h := a.Require(next)
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
req *http.Request
|
||||
wantCode int
|
||||
wantHeader [2]string
|
||||
}{
|
||||
{"no session GET redirects to login", protectedRequest(http.MethodGet, "/", ""), http.StatusFound, [2]string{"Location", "/auth/login"}},
|
||||
{"expired session on htmx fragment", func() *http.Request {
|
||||
r := protectedRequest(http.MethodGet, "/fragments/services", "old")
|
||||
r.Header.Set("HX-Request", "true")
|
||||
return r
|
||||
}(), http.StatusUnauthorized, [2]string{"HX-Redirect", "/auth/login"}},
|
||||
{"event-stream without session gets plain 401", func() *http.Request {
|
||||
r := protectedRequest(http.MethodGet, "/events", "")
|
||||
r.Header.Set("Accept", "text/event-stream")
|
||||
return r
|
||||
}(), http.StatusUnauthorized, [2]string{"Location", ""}},
|
||||
{"no session POST", protectedRequest(http.MethodPost, "/agents", ""), http.StatusUnauthorized, [2]string{}},
|
||||
{"POST without CSRF", protectedRequest(http.MethodPost, "/agents", "live"), http.StatusForbidden, [2]string{}},
|
||||
{"POST with CSRF header", func() *http.Request {
|
||||
r := protectedRequest(http.MethodPost, "/agents", "live")
|
||||
r.Header.Set("X-CSRF-Token", "tok")
|
||||
return r
|
||||
}(), http.StatusNoContent, [2]string{}},
|
||||
{"POST with CSRF form field", func() *http.Request {
|
||||
r := httptest.NewRequest(http.MethodPost, "/agents", strings.NewReader("csrf_token=tok"))
|
||||
r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
r.AddCookie(&http.Cookie{Name: sessionCookieName, Value: "live"})
|
||||
return r
|
||||
}(), http.StatusNoContent, [2]string{}},
|
||||
{"POST with CSRF only in query string", func() *http.Request {
|
||||
r := httptest.NewRequest(http.MethodPost, "/agents?csrf_token=tok", strings.NewReader("name=x"))
|
||||
r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
r.AddCookie(&http.Cookie{Name: sessionCookieName, Value: "live"})
|
||||
return r
|
||||
}(), http.StatusForbidden, [2]string{}},
|
||||
{"GET with session", protectedRequest(http.MethodGet, "/", "live"), http.StatusNoContent, [2]string{}},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, c.req)
|
||||
assert.Equal(t, c.wantCode, rec.Code)
|
||||
if c.wantHeader[0] != "" {
|
||||
assert.Equal(t, c.wantHeader[1], rec.Header().Get(c.wantHeader[0]))
|
||||
}
|
||||
})
|
||||
}
|
||||
assert.Equal(t, "Samuel", gotName)
|
||||
}
|
||||
|
||||
func TestLogout_DeletesSession(t *testing.T) {
|
||||
store := newFakeStore()
|
||||
store.sessions["live"] = model.Session{ID: "live", CSRFToken: "tok", ExpiresAt: time.Now().Add(time.Hour)}
|
||||
a := newAuth(&fakeProvider{}, store)
|
||||
|
||||
req := protectedRequest(http.MethodPost, "/auth/logout", "live")
|
||||
req.Header.Set("X-CSRF-Token", "tok")
|
||||
rec := httptest.NewRecorder()
|
||||
a.Require(http.HandlerFunc(a.Logout)).ServeHTTP(rec, req)
|
||||
|
||||
assert.Equal(t, http.StatusOK, rec.Code)
|
||||
assert.Empty(t, store.sessions)
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
package webauth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/coreos/go-oidc/v3/oidc"
|
||||
"golang.org/x/oauth2"
|
||||
)
|
||||
|
||||
// Claims is what the master needs from the identity provider.
|
||||
type Claims struct {
|
||||
Subject string
|
||||
Name string
|
||||
Email string
|
||||
Groups []string
|
||||
}
|
||||
|
||||
// Provider performs the OIDC authorization code flow with PKCE. Declared as
|
||||
// an interface so handlers can be tested without a real identity provider.
|
||||
type Provider interface {
|
||||
AuthCodeURL(state, verifier string) string
|
||||
Exchange(ctx context.Context, code, verifier string) (Claims, error)
|
||||
}
|
||||
|
||||
// OIDCConfig configures the real provider.
|
||||
type OIDCConfig struct {
|
||||
Issuer string // e.g. https://auth.example.com/application/o/package-updater/
|
||||
ClientID string
|
||||
ClientSecret string
|
||||
RedirectURL string // PUBLIC_URL + /auth/callback
|
||||
}
|
||||
|
||||
type oidcProvider struct {
|
||||
oauth *oauth2.Config
|
||||
verifier *oidc.IDTokenVerifier
|
||||
}
|
||||
|
||||
// NewOIDCProvider discovers the issuer's endpoints. It makes a network call,
|
||||
// so the master fails at startup if Authentik is unreachable.
|
||||
func NewOIDCProvider(ctx context.Context, cfg OIDCConfig) (Provider, error) {
|
||||
p, err := oidc.NewProvider(ctx, cfg.Issuer)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("oidc discovery: %w", err)
|
||||
}
|
||||
return &oidcProvider{
|
||||
oauth: &oauth2.Config{
|
||||
ClientID: cfg.ClientID,
|
||||
ClientSecret: cfg.ClientSecret,
|
||||
Endpoint: p.Endpoint(),
|
||||
RedirectURL: cfg.RedirectURL,
|
||||
Scopes: []string{oidc.ScopeOpenID, "profile", "email"},
|
||||
},
|
||||
verifier: p.Verifier(&oidc.Config{ClientID: cfg.ClientID}),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (p *oidcProvider) AuthCodeURL(state, verifier string) string {
|
||||
return p.oauth.AuthCodeURL(state, oauth2.S256ChallengeOption(verifier))
|
||||
}
|
||||
|
||||
func (p *oidcProvider) Exchange(ctx context.Context, code, verifier string) (Claims, error) {
|
||||
tok, err := p.oauth.Exchange(ctx, code, oauth2.VerifierOption(verifier))
|
||||
if err != nil {
|
||||
return Claims{}, fmt.Errorf("exchange code: %w", err)
|
||||
}
|
||||
raw, ok := tok.Extra("id_token").(string)
|
||||
if !ok {
|
||||
return Claims{}, errors.New("token response has no id_token")
|
||||
}
|
||||
idt, err := p.verifier.Verify(ctx, raw)
|
||||
if err != nil {
|
||||
return Claims{}, fmt.Errorf("verify id_token: %w", err)
|
||||
}
|
||||
var c struct {
|
||||
Name string `json:"name"`
|
||||
Email string `json:"email"`
|
||||
Groups []string `json:"groups"`
|
||||
}
|
||||
if err := idt.Claims(&c); err != nil {
|
||||
return Claims{}, fmt.Errorf("read claims: %w", err)
|
||||
}
|
||||
return Claims{Subject: idt.Subject, Name: c.Name, Email: c.Email, Groups: c.Groups}, nil
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
// Package pin lets agents trust the master without a certificate authority.
|
||||
// The master serves a self-signed certificate for its own key pair; agents
|
||||
// are configured with the fingerprint of that public key and accept the
|
||||
// connection only if it matches. The TLS handshake proves the master holds
|
||||
// the private key, the same way SSH host keys work.
|
||||
package pin
|
||||
|
||||
import (
|
||||
"crypto"
|
||||
"crypto/ed25519"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"crypto/subtle"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"crypto/x509/pkix"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math/big"
|
||||
"time"
|
||||
)
|
||||
|
||||
// GenerateKey creates a new master key pair.
|
||||
func GenerateKey() (ed25519.PrivateKey, error) {
|
||||
_, priv, err := ed25519.GenerateKey(rand.Reader)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("generate key: %w", err)
|
||||
}
|
||||
return priv, nil
|
||||
}
|
||||
|
||||
// Certificate returns a self-signed TLS certificate for key. Its validity is
|
||||
// effectively unlimited because trust comes from the pinned fingerprint, not
|
||||
// from the certificate's dates or issuer.
|
||||
func Certificate(key ed25519.PrivateKey) (tls.Certificate, error) {
|
||||
tmpl := &x509.Certificate{
|
||||
SerialNumber: big.NewInt(1),
|
||||
Subject: pkix.Name{CommonName: "package-updater-master"},
|
||||
NotBefore: time.Now().Add(-time.Hour),
|
||||
NotAfter: time.Now().AddDate(100, 0, 0),
|
||||
KeyUsage: x509.KeyUsageDigitalSignature,
|
||||
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
|
||||
}
|
||||
der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, key.Public(), key)
|
||||
if err != nil {
|
||||
return tls.Certificate{}, fmt.Errorf("create certificate: %w", err)
|
||||
}
|
||||
return tls.Certificate{Certificate: [][]byte{der}, PrivateKey: key}, nil
|
||||
}
|
||||
|
||||
// Fingerprint returns "sha256:" + hex SHA-256 of pub's PKIX DER encoding.
|
||||
// This is the MASTER_KEY value agents are configured with.
|
||||
func Fingerprint(pub crypto.PublicKey) (string, error) {
|
||||
der, err := x509.MarshalPKIXPublicKey(pub)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("marshal public key: %w", err)
|
||||
}
|
||||
sum := sha256.Sum256(der)
|
||||
return "sha256:" + hex.EncodeToString(sum[:]), nil
|
||||
}
|
||||
|
||||
// ClientConfig returns a TLS config that accepts only a server whose
|
||||
// certificate carries the public key with the given fingerprint.
|
||||
func ClientConfig(fingerprint string) *tls.Config {
|
||||
return &tls.Config{
|
||||
MinVersion: tls.VersionTLS13,
|
||||
// Chain and hostname verification are replaced by the fingerprint
|
||||
// check below: the master's certificate is self-signed by design.
|
||||
// The handshake still verifies the server's signature with the
|
||||
// certificate's key, so presenting a copied certificate fails.
|
||||
InsecureSkipVerify: true, //nolint:gosec // see comment above
|
||||
VerifyPeerCertificate: func(raw [][]byte, _ [][]*x509.Certificate) error {
|
||||
if len(raw) == 0 {
|
||||
return errors.New("pin: server sent no certificate")
|
||||
}
|
||||
cert, err := x509.ParseCertificate(raw[0])
|
||||
if err != nil {
|
||||
return fmt.Errorf("pin: parse certificate: %w", err)
|
||||
}
|
||||
got, err := Fingerprint(cert.PublicKey)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if subtle.ConstantTimeCompare([]byte(got), []byte(fingerprint)) != 1 {
|
||||
return fmt.Errorf("pin: fingerprint mismatch: server presented %s", got)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
package pin
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"regexp"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func newPinnedServer(t *testing.T) (*httptest.Server, string) {
|
||||
t.Helper()
|
||||
key, err := GenerateKey()
|
||||
require.NoError(t, err)
|
||||
cert, err := Certificate(key)
|
||||
require.NoError(t, err)
|
||||
fp, err := Fingerprint(key.Public())
|
||||
require.NoError(t, err)
|
||||
|
||||
srv := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
_, _ = w.Write([]byte("ok"))
|
||||
}))
|
||||
srv.TLS = &tls.Config{Certificates: []tls.Certificate{cert}}
|
||||
srv.StartTLS()
|
||||
t.Cleanup(srv.Close)
|
||||
return srv, fp
|
||||
}
|
||||
|
||||
func clientFor(fp string) *http.Client {
|
||||
return &http.Client{Transport: &http.Transport{TLSClientConfig: ClientConfig(fp)}}
|
||||
}
|
||||
|
||||
func TestFingerprintFormat(t *testing.T) {
|
||||
key, err := GenerateKey()
|
||||
require.NoError(t, err)
|
||||
fp, err := Fingerprint(key.Public())
|
||||
require.NoError(t, err)
|
||||
assert.Regexp(t, regexp.MustCompile(`^sha256:[0-9a-f]{64}$`), fp)
|
||||
}
|
||||
|
||||
func TestClientConfigAcceptsPinnedKey(t *testing.T) {
|
||||
srv, fp := newPinnedServer(t)
|
||||
resp, err := clientFor(fp).Get(srv.URL)
|
||||
require.NoError(t, err)
|
||||
_ = resp.Body.Close()
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
}
|
||||
|
||||
func TestClientConfigRejectsOtherKey(t *testing.T) {
|
||||
srv, _ := newPinnedServer(t)
|
||||
other, err := GenerateKey()
|
||||
require.NoError(t, err)
|
||||
otherFP, err := Fingerprint(other.Public())
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = clientFor(otherFP).Get(srv.URL)
|
||||
assert.ErrorContains(t, err, "fingerprint mismatch")
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
// Package protocol defines the messages agents and the master exchange over
|
||||
// their WebSocket connection. Every message is an Envelope whose Payload is
|
||||
// one of the payload types below, selected by Type.
|
||||
package protocol
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// Message types sent from agent to master.
|
||||
const (
|
||||
TypeHello = "hello"
|
||||
TypeSnapshot = "snapshot"
|
||||
TypeUpsert = "upsert"
|
||||
TypeRemove = "remove"
|
||||
)
|
||||
|
||||
// Envelope wraps every message on the wire.
|
||||
type Envelope struct {
|
||||
Type string `json:"type"`
|
||||
Payload json.RawMessage `json:"payload,omitempty"`
|
||||
}
|
||||
|
||||
// Hello is the first message an agent sends on every connection.
|
||||
type Hello struct {
|
||||
AgentVersion string `json:"agent_version"`
|
||||
Hostname string `json:"hostname"`
|
||||
SwarmManager bool `json:"swarm_manager"`
|
||||
SwarmClusterID string `json:"swarm_cluster_id,omitempty"`
|
||||
}
|
||||
|
||||
// Kind says how a service is deployed.
|
||||
type Kind string
|
||||
|
||||
// Service kinds.
|
||||
const (
|
||||
KindCompose Kind = "compose"
|
||||
KindSwarm Kind = "swarm"
|
||||
KindContainer Kind = "container"
|
||||
)
|
||||
|
||||
// Service is one deployable unit as an agent sees it. Which location fields
|
||||
// are set depends on Kind.
|
||||
type Service struct {
|
||||
Kind Kind `json:"kind"`
|
||||
ComposeProject string `json:"compose_project,omitempty"`
|
||||
ComposeService string `json:"compose_service,omitempty"`
|
||||
WorkingDir string `json:"working_dir,omitempty"`
|
||||
SwarmService string `json:"swarm_service,omitempty"`
|
||||
ContainerName string `json:"container_name,omitempty"`
|
||||
Image string `json:"image"`
|
||||
Digest string `json:"digest,omitempty"`
|
||||
Status string `json:"status"`
|
||||
Self bool `json:"self,omitempty"`
|
||||
}
|
||||
|
||||
// Key identifies the service within one agent's report. It is stable across
|
||||
// restarts and is what Remove refers to.
|
||||
func (s Service) Key() string {
|
||||
switch s.Kind {
|
||||
case KindCompose:
|
||||
return "compose|" + s.ComposeProject + "|" + s.ComposeService
|
||||
case KindSwarm:
|
||||
return "swarm|" + s.SwarmService
|
||||
default:
|
||||
return "container|" + s.ContainerName
|
||||
}
|
||||
}
|
||||
|
||||
// Snapshot is the agent's complete inventory. Sent after every Hello.
|
||||
type Snapshot struct {
|
||||
Services []Service `json:"services"`
|
||||
}
|
||||
|
||||
// Upsert reports a service that appeared or changed.
|
||||
type Upsert struct {
|
||||
Service Service `json:"service"`
|
||||
}
|
||||
|
||||
// Remove reports that the service with Key is gone.
|
||||
type Remove struct {
|
||||
Key string `json:"key"`
|
||||
}
|
||||
|
||||
// Encode wraps payload in an Envelope of the given type.
|
||||
func Encode(typ string, payload any) (Envelope, error) {
|
||||
raw, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return Envelope{}, fmt.Errorf("encode %s: %w", typ, err)
|
||||
}
|
||||
return Envelope{Type: typ, Payload: raw}, nil
|
||||
}
|
||||
|
||||
// Decode unpacks env's payload into T.
|
||||
func Decode[T any](env Envelope) (T, error) {
|
||||
var v T
|
||||
if err := json.Unmarshal(env.Payload, &v); err != nil {
|
||||
return v, fmt.Errorf("decode %s: %w", env.Type, err)
|
||||
}
|
||||
return v, nil
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package protocol
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestServiceKey(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
svc Service
|
||||
want string
|
||||
}{
|
||||
{"compose", Service{Kind: KindCompose, ComposeProject: "slusk", ComposeService: "web"}, "compose|slusk|web"},
|
||||
{"swarm", Service{Kind: KindSwarm, SwarmService: "gitea_gitea"}, "swarm|gitea_gitea"},
|
||||
{"container", Service{Kind: KindContainer, ContainerName: "adhoc"}, "container|adhoc"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
assert.Equal(t, c.want, c.svc.Key())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestEncodeDecodeRoundTrip(t *testing.T) {
|
||||
in := Snapshot{Services: []Service{
|
||||
{Kind: KindCompose, ComposeProject: "slusk", ComposeService: "web", Image: "slusk:1.4.2", Status: "running"},
|
||||
}}
|
||||
env, err := Encode(TypeSnapshot, in)
|
||||
require.NoError(t, err)
|
||||
|
||||
raw, err := json.Marshal(env)
|
||||
require.NoError(t, err)
|
||||
var back Envelope
|
||||
require.NoError(t, json.Unmarshal(raw, &back))
|
||||
|
||||
assert.Equal(t, TypeSnapshot, back.Type)
|
||||
out, err := Decode[Snapshot](back)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, in, out)
|
||||
}
|
||||
|
||||
func TestDecodeRejectsMalformedPayload(t *testing.T) {
|
||||
_, err := Decode[Hello](Envelope{Type: TypeHello, Payload: json.RawMessage(`{"hostname": 5}`)})
|
||||
assert.ErrorContains(t, err, "decode hello")
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package updater
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/docker/docker/api/types/swarm"
|
||||
"github.com/shcizo/package-updater/internal/discovery"
|
||||
)
|
||||
|
||||
// SwarmDockerClient is the subset of the Docker SDK SwarmExecutor depends
|
||||
// on. Defined as an interface so tests can supply a fake.
|
||||
type SwarmDockerClient interface {
|
||||
ServiceInspectWithRaw(ctx context.Context, serviceID string, opts swarm.ServiceInspectOptions) (swarm.Service, []byte, error)
|
||||
ServiceUpdate(ctx context.Context, serviceID string, version swarm.Version, service swarm.ServiceSpec, opts swarm.ServiceUpdateOptions) (swarm.ServiceUpdateResponse, error)
|
||||
}
|
||||
|
||||
// SwarmExecutor updates a Swarm service's image via the Docker API,
|
||||
// equivalent to `docker service update --image`.
|
||||
type SwarmExecutor struct {
|
||||
cli SwarmDockerClient
|
||||
}
|
||||
|
||||
// NewSwarmExecutor returns an executor that drives Swarm service updates
|
||||
// through the Docker API.
|
||||
func NewSwarmExecutor(cli SwarmDockerClient) *SwarmExecutor {
|
||||
return &SwarmExecutor{cli: cli}
|
||||
}
|
||||
|
||||
// Execute inspects the service to get its current spec and version (required
|
||||
// by the Docker API as an optimistic-concurrency token), sets the new image
|
||||
// on the container spec, and calls ServiceUpdate. QueryRegistry is set so a
|
||||
// floating tag (e.g. ":latest") resolves to a fresh digest and actually
|
||||
// triggers a rolling update instead of being treated as unchanged.
|
||||
func (e *SwarmExecutor) Execute(ctx context.Context, job discovery.Job) error {
|
||||
if job.Refused {
|
||||
return fmt.Errorf("refused: %s", job.RefusedReason)
|
||||
}
|
||||
|
||||
svc, _, err := e.cli.ServiceInspectWithRaw(ctx, job.ServiceID, swarm.ServiceInspectOptions{})
|
||||
if err != nil {
|
||||
return fmt.Errorf("inspect service %s: %w", job.ServiceID, err)
|
||||
}
|
||||
|
||||
spec := svc.Spec
|
||||
spec.TaskTemplate.ContainerSpec.Image = job.Image
|
||||
|
||||
if _, err := e.cli.ServiceUpdate(ctx, job.ServiceID, svc.Version, spec, swarm.ServiceUpdateOptions{QueryRegistry: true}); err != nil {
|
||||
return fmt.Errorf("update service %s: %w", job.ServiceID, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
package updater_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/docker/docker/api/types/swarm"
|
||||
"github.com/shcizo/package-updater/internal/discovery"
|
||||
"github.com/shcizo/package-updater/internal/updater"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
type fakeSwarmDockerClient struct {
|
||||
inspectService swarm.Service
|
||||
inspectErr error
|
||||
updateErr error
|
||||
|
||||
inspectCalled bool
|
||||
updateCalled bool
|
||||
updateCalledServiceID string
|
||||
updateCalledVersion swarm.Version
|
||||
updateCalledSpec swarm.ServiceSpec
|
||||
updateCalledOpts swarm.ServiceUpdateOptions
|
||||
}
|
||||
|
||||
func (f *fakeSwarmDockerClient) ServiceInspectWithRaw(_ context.Context, _ string, _ swarm.ServiceInspectOptions) (swarm.Service, []byte, error) {
|
||||
f.inspectCalled = true
|
||||
return f.inspectService, nil, f.inspectErr
|
||||
}
|
||||
|
||||
func (f *fakeSwarmDockerClient) ServiceUpdate(_ context.Context, serviceID string, version swarm.Version, spec swarm.ServiceSpec, opts swarm.ServiceUpdateOptions) (swarm.ServiceUpdateResponse, error) {
|
||||
f.updateCalled = true
|
||||
f.updateCalledServiceID = serviceID
|
||||
f.updateCalledVersion = version
|
||||
f.updateCalledSpec = spec
|
||||
f.updateCalledOpts = opts
|
||||
return swarm.ServiceUpdateResponse{}, f.updateErr
|
||||
}
|
||||
|
||||
func mkInspectService(version uint64, image string) swarm.Service {
|
||||
return swarm.Service{
|
||||
ID: "svc-myapp",
|
||||
Meta: swarm.Meta{Version: swarm.Version{Index: version}},
|
||||
Spec: swarm.ServiceSpec{
|
||||
Annotations: swarm.Annotations{Name: "myapp_web"},
|
||||
TaskTemplate: swarm.TaskSpec{ContainerSpec: &swarm.ContainerSpec{Image: image}},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func TestSwarmExecutor_UpdatesImageAndVersion(t *testing.T) {
|
||||
fake := &fakeSwarmDockerClient{inspectService: mkInspectService(7, "registry.example.com/myapp:v1")}
|
||||
e := updater.NewSwarmExecutor(fake)
|
||||
|
||||
job := discovery.Job{Service: "myapp_web", ServiceID: "svc-myapp", Image: "registry.example.com/myapp:v2"}
|
||||
err := e.Execute(context.Background(), job)
|
||||
|
||||
require.NoError(t, err)
|
||||
require.True(t, fake.updateCalled)
|
||||
require.Equal(t, "svc-myapp", fake.updateCalledServiceID)
|
||||
require.Equal(t, uint64(7), fake.updateCalledVersion.Index)
|
||||
require.Equal(t, "registry.example.com/myapp:v2", fake.updateCalledSpec.TaskTemplate.ContainerSpec.Image)
|
||||
require.True(t, fake.updateCalledOpts.QueryRegistry)
|
||||
}
|
||||
|
||||
func TestSwarmExecutor_InspectError(t *testing.T) {
|
||||
fake := &fakeSwarmDockerClient{inspectErr: errors.New("service not found")}
|
||||
e := updater.NewSwarmExecutor(fake)
|
||||
|
||||
err := e.Execute(context.Background(), discovery.Job{ServiceID: "svc-missing", Image: "x:v2"})
|
||||
|
||||
require.Error(t, err)
|
||||
require.False(t, fake.updateCalled)
|
||||
}
|
||||
|
||||
func TestSwarmExecutor_UpdateError(t *testing.T) {
|
||||
fake := &fakeSwarmDockerClient{
|
||||
inspectService: mkInspectService(1, "registry.example.com/myapp:v1"),
|
||||
updateErr: errors.New("update rejected"),
|
||||
}
|
||||
e := updater.NewSwarmExecutor(fake)
|
||||
|
||||
err := e.Execute(context.Background(), discovery.Job{ServiceID: "svc-myapp", Image: "registry.example.com/myapp:v2"})
|
||||
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
func TestSwarmExecutor_RefusedJobIsNotExecuted(t *testing.T) {
|
||||
fake := &fakeSwarmDockerClient{}
|
||||
e := updater.NewSwarmExecutor(fake)
|
||||
job := discovery.Job{ServiceID: "svc-myapp", Image: "x:v2", Refused: true, RefusedReason: "not opted in"}
|
||||
|
||||
err := e.Execute(context.Background(), job)
|
||||
|
||||
require.Error(t, err)
|
||||
require.False(t, fake.inspectCalled)
|
||||
require.False(t, fake.updateCalled)
|
||||
}
|
||||
Reference in New Issue
Block a user