Compare commits

..
Author SHA1 Message Date
shcizo 2fe9b5ba0d fix(master): block inventory writes from revoked agents (#6)
ApplySnapshot and UpsertService now take a FOR SHARE lock on the agent's
row and refuse with model.ErrRevoked when it is revoked. RevokeAgent's
UPDATE waits on that lock, so an in-flight snapshot or upsert can no
longer bring a revoked agent's services back after the revoke cleanup.
2026-10-12 15:18:51 +02:00
shcizo 0e9e34db06 fix: address final review findings for master/agent phase 1 (#6)
- inventory: resolve the image reference via ContainerInspect when a moved
  tag makes ContainerList report the image ID
- store: allow reusing a revoked agent's name (partial unique index) and
  mark the revoked agent's compose/container services removed
- web: publish a services event on revoke; no-store on the one-time token page
- webauth: reject empty OIDC state; accept the CSRF form token from the body only
- agent conn: bound the WebSocket handshake to 30s
- hub: log agent_replaced when a connection replaces an older one
2026-10-12 15:16:29 +02:00
shcizo 4f092eaf79 docs: document master/agent phase 1 and add master stack example 2026-10-12 15:04:43 +02:00
shcizo 05610391e3 test: agent to master inventory over pinned TLS end to end 2026-10-12 15:02:50 +02:00
shcizo e8f7cb3378 feat(master): add updater-master binary wiring store, hub, auth and UI 2026-10-12 15:01:11 +02:00
shcizo a596d67728 feat(master): htmx UI with live service overview and agent management
Vendored static assets (sha256):
e209dda5c8235479f3166defc7750e1dbcd5a5c1808b7792fc2e6733768fb447  htmx.min.js (htmx.org@2.0.4)
83eca6fa0611fe2b0bf1700b424b88b5eced38ef448ef9760a2ea08fbc875611  htmx-ext-sse.js (htmx-ext-sse@2.2.2)
2026-10-12 14:57:23 +02:00
shcizo b4894db6d7 feat(master): OIDC login with group check, sessions and CSRF protection 2026-10-12 14:54:11 +02:00
shcizo 049324b2f4 fix(master): recheck revocation after register, reject duplicate hello 2026-10-12 14:52:24 +02:00
shcizo 75e28262f8 feat(master): agent hub that authenticates agents and stores their inventory 2026-10-12 14:50:22 +02:00
shcizo 5d75f9a7da feat(master): in-process broker for live UI change signals 2026-10-12 14:48:03 +02:00
shcizo 8a1fb45971 feat(master): store service inventory snapshots and web sessions 2026-10-12 14:45:46 +02:00
shcizo 99c553b7c6 feat(master): postgres store with migrations, master identity and agents 2026-10-12 14:43:34 +02:00
shcizo 9b74286c9b feat(agent): add updater-agent binary and ship it in the image 2026-10-12 14:41:05 +02:00
shcizo ad6ff1e066 feat(agent): connect to master over pinned TLS and stream inventory 2026-10-12 14:38:14 +02:00
shcizo 3edaf4e602 feat(agent): diff inventories and debounce docker events 2026-10-12 14:36:11 +02:00
shcizo abee9abb1b feat(agent): collect compose, swarm and container inventory from docker 2026-10-12 14:34:13 +02:00
shcizo 61e738e39d feat: pinned master key TLS and per-agent tokens 2026-10-12 14:32:40 +02:00
shcizo b482c2e29c feat(protocol): define agent-to-master wire messages 2026-10-12 14:31:18 +02:00
shcizo adeddf0254 feat(imageref): parse image references into repo, tag and digest 2026-10-12 14:29:52 +02:00
shcizo a57df51532 Merge pull request 'docs: master/agent design spec and phase 1 plan' (#16) from feat/master-agent into main 2026-10-12 12:15:16 +00:00
shcizo d2a8b548c9 docs: implementation plan for master/agent phase 1 (inventory) 2026-10-12 13:42:27 +02:00
shcizo 1c833f9fbe docs: design for master/agent architecture with live inventory, update modes and polling 2026-10-12 13:20:50 +02:00
shcizo 9ea082de82 Merge branch 'worktree-docker-swarm-support'
Adds MODE=compose|swarm. Each instance owns one Docker daemon and runs
one paradigm; see docs/superpowers/specs/2026-08-04-multi-server-fanout-design.md
for why this exclusivity is the intended design.
2026-08-04 19:25:08 +02:00
shcizo 50ef1a9a07 Merge pull request 'feat: add Docker Swarm support (MODE=swarm)' (#4) from worktree-docker-swarm-support into main
Docker Build / docker (push) Successful in 1m7s
Reviewed-on: #4
2026-07-04 18:54:14 +00:00
61 changed files with 11525 additions and 8 deletions
+16
View File
@@ -11,6 +11,8 @@ See `README.md` for user-facing docs. This file is for working ON the code.
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):
@@ -32,6 +34,13 @@ curl -sH "Authorization: Bearer $UPDATER_API_KEY" \
- `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)
@@ -73,12 +82,19 @@ curl -sH "Authorization: Bearer $UPDATER_API_KEY" \
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
+5 -3
View File
@@ -8,15 +8,17 @@ COPY . .
ARG VERSION=dev
ARG COMMIT=unknown
ARG BUILD_TIME=unknown
RUN CGO_ENABLED=0 go build \
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/package-updater ./cmd/server
-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"]
+15
View File
@@ -46,6 +46,21 @@ Two things to get right when running in Swarm mode:
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).
+89
View File
@@ -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()
}
}
+137
View File
@@ -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
}
+41
View File
@@ -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
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.
+11 -1
View File
@@ -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
+23
View File
@@ -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=
+51
View File
@@ -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
}
+50
View File
@@ -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)
})
}
}
+179
View File
@@ -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
}
+200
View File
@@ -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")
}
+216
View File
@@ -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"
}
}
+202
View File
@@ -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)
}
+25
View File
@@ -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
}
+37
View File
@@ -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)
})
}
}
+52
View File
@@ -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()
}
}
}
+52
View File
@@ -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")
}
+31
View File
@@ -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[:])
}
+25
View File
@@ -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"))
}
+230
View File
@@ -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)
}
+43
View File
@@ -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
}
+29
View File
@@ -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")
})
}
}
+68
View File
@@ -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
}
+64
View File
@@ -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)
})
}
}
+56
View File
@@ -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:
}
}
}
+48
View File
@@ -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")
}
}
+231
View File
@@ -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
}
+416
View File
@@ -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) })())
}
+89
View File
@@ -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
}
+41
View File
@@ -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())
})
}
}
+108
View File
@@ -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
}
+42
View File
@@ -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
}
+68
View File
@@ -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
);
+176
View File
@@ -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) }
+50
View File
@@ -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)
}
+41
View File
@@ -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) }
+288
View File
@@ -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)
}
+265
View File
@@ -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)
}
+23
View File
@@ -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; } }
+290
View File
@@ -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
}
})()
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}}
+38
View File
@@ -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}}
+22
View File
@@ -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}}
+51
View File
@@ -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
}
+184
View File
@@ -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[:])
}
+249
View File
@@ -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)
}
+85
View File
@@ -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
}
+91
View File
@@ -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
},
}
}
+61
View File
@@ -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")
}
+102
View File
@@ -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
}
+49
View File
@@ -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")
}