feat: master/agent phase 2 — jobs and manual update (#7) #20
@@ -20,6 +20,8 @@
|
||||
- WebSocket read limit stays `1 << 20` on both sides. Every websocket write goes through one writer goroutine per connection (both sides) — no other goroutine calls `wsjson.Write`.
|
||||
- Never edit operator files: the agent writes exactly one file, `<stack dir>/compose.override.yaml`, and only if it is absent or starts with the header line.
|
||||
- Agent tokens and `AGENT_TOKEN` values never appear in logs (unchanged).
|
||||
- **One shared worktree, one Go module, implementers in parallel.** While W2–W4 run, each implementer runs `go vet`/`go test` only on the packages they own (listed per workstream below), because another workstream's package may be mid-edit. Stage only your own paths with `git add <paths>` — never `git add -A` or `git add .`. If `git` reports `index.lock` exists, wait a few seconds and retry; never delete the lock file. Full `go vet ./... && go test ./...` runs in W5 and in the final verification.
|
||||
- The agent may only touch Compose stacks in directories that are bind-mounted into its own container read-write **at the same path as on the host**. There is no `STACKS_ROOT` setting.
|
||||
|
||||
## Deliberate deviations from the spec in this phase
|
||||
|
||||
@@ -28,6 +30,7 @@
|
||||
- **`internal/updater` moves to `internal/agent/updater`** (`git mv`, history kept). Its blocking `Queue.Submit` (built for the synchronous webhook) is replaced by `Runner` (async, outbox, live output). The executors are rewritten against `protocol.JobStart`. `discovery.IsInsideRoot` moves into the same package; image matching is dropped (`imageref.Parse(x).Repo` is equivalent to `NormaliseImage`).
|
||||
- **Override file: one name, and refuse when any other override exists.** Verified empirically against Docker Compose v5.1.2: Compose auto-loads `compose.override.{yml,yaml}` / `docker-compose.override.{yml,yaml}` next to *any* default base file, but loads **only one** (first of `compose.override.yml`, `compose.override.yaml`, `docker-compose.override.yml`, `docker-compose.override.yaml`) and warns about the rest. So the agent writes `compose.override.yaml` and refuses the job when any of the other three exists — writing ours next to an operator's `docker-compose.override.yml` would silently disable theirs on the next manual `docker compose up`. Stacks started with explicit `-f` files do not load the override on a manual `up`; the agent's own jobs always pass it explicitly.
|
||||
- **The override file is never pruned.** If a service is deleted from the stack file, its `image:` entry stays in `compose.override.yaml` and a manual `docker compose up -d` would recreate it. Documented in the README; pruning needs the base file parsed and is deferred.
|
||||
- **`STACKS_ROOT` is replaced by the agent's own mounts.** The spec keeps "the `STACKS_ROOT` path check on the agent"; this phase keeps the check but changes where the allowed directories come from. The agent inspects its own container (`SELF_ID`, default the hostname) and treats every bind mount that is read-write and has `Source == Destination` as an allowed root. Every config file and the override path must be inside one of them (`filepath.Rel` check, also after resolving symlinks). One less setting to keep in sync with the volume list, and a read-only or path-translated mount — which could not work anyway, since labels hold host paths — is refused with a clear message. Mounts are read on every Compose job (one `ContainerInspect`), not cached: it costs one local API call per job and never acts on stale data. An agent not running in a container, or whose `SELF_ID` does not resolve, has no roots, and Compose jobs fail; the agent logs a warning at startup. On Docker Desktop/OrbStack, bind sources are translated host paths and may not equal the destination; that is a development-only limitation.
|
||||
- **`job.start` carries no paths.** The agent reads `working_dir` and `config_files` from its own Docker daemon (container labels) at execution time. The inventory still reports `ConfigFiles` (shown on the service page), but a stale or forged path from the master never reaches `docker compose`.
|
||||
- **`protocol.Service.ConfigFiles` is the raw comma-joined label string**, not a slice, so `Service` stays comparable with `==` (`inventory.Diff` depends on it).
|
||||
- **Swarm "succeeded" means the daemon accepted the new spec**, not that tasks converged. The inventory shows `degraded` until they do.
|
||||
@@ -51,7 +54,7 @@
|
||||
| Two clicks on Update at once create one job; the second gets the first back. | store `TestCreateJob_OneActivePerService` (concurrent, W3-1); jobs `TestTrigger` "active job" case (W3-3) |
|
||||
| The master refuses jobs for itself and the agents (same image repo as `AGENT_IMAGE`) and for rows flagged `self`. | jobs `TestUpdatable` (W3-3); web `TestServicePage_DisabledReason` (W4-1) |
|
||||
| The agent never overwrites an operator's override file, and never writes next to another auto-loaded override name. | updater `TestWriteOverride` (W2-4) |
|
||||
| Every config file and the override path are inside `STACKS_ROOT`, also after resolving symlinks. | updater `TestComposeFiles` (W2-4) |
|
||||
| Every config file and the override path are inside a read-write, same-path bind mount of the agent's own container, also after resolving symlinks; read-only and path-translated mounts are refused. | updater `TestMountRoots`, `TestComposeFiles` (W2-4) |
|
||||
| Concurrent writers on one websocket (job progress, acks, inventory) are serialised. | `go test -race` on conn and hub tests that start a job while inventory flows (W2-6, W3-2) |
|
||||
| A phase-1 agent (protocol 0) never receives `job.start`; the job fails "agent too old for updates". | e2e `TestPhaseOneAgentGetsNoJobs` (W5-1); hub `TestRoute` (W3-2) |
|
||||
| A Swarm job routed to an agent whose daemon is in a different cluster is refused before any update. | updater `TestSwarmExecutor` "cluster mismatch" (W2-5) |
|
||||
@@ -73,7 +76,7 @@
|
||||
|
||||
## Workstreams and file ownership
|
||||
|
||||
W1 lands first (one small commit set). W2, W3, W4 then run in parallel. W5 runs after W2–W4 are merged into the branch.
|
||||
W1 lands first (one small commit set). W2, W3, W4 then run in parallel in the same worktree. W5 runs after W2–W4 have committed everything.
|
||||
|
||||
| Workstream | Owner | Owns (only this workstream edits these) |
|
||||
|---|---|---|
|
||||
@@ -84,7 +87,7 @@ W1 lands first (one small commit set). W2, W3, W4 then run in parallel. W5 runs
|
||||
| W5 integration | implementer-master | `internal/e2e/**`, `CLAUDE.md`, `README.md`, `deploy/master.stack.yml` |
|
||||
|
||||
Shared touch points, resolved by contract rather than co-editing:
|
||||
- `cmd/master/main.go` (W3) calls `web.New` with W4's new signature — frozen below. W3 makes that call compile; if W4 is not merged yet, W3's branch fails to build at that one call, which is expected until both land.
|
||||
- `cmd/master/main.go` (W3) calls `web.New` with W4's new signature (C7). W4's **first** commit (Task W4-0) is that signature change plus the matching one-line change to the call in `cmd/master` (the only line W4 touches there), so the module builds at every commit. W3 does W3-4 only after W4-0 is committed.
|
||||
- `go.mod`/`go.sum`: only W2 runs `go mod tidy`. W3/W4 add no dependencies.
|
||||
- `internal/master/model` is W1's. If W3 or W4 needs a model change, they ask the W1 owner (lead) rather than editing it.
|
||||
|
||||
@@ -622,11 +625,10 @@ Environment (added to `internal/agent/config.Config`):
|
||||
|
||||
| Variable | Field | Default | Validation |
|
||||
|---|---|---|---|
|
||||
| `STACKS_ROOT` | `StacksRoot string` | `""` (Compose jobs then fail "refused: STACKS_ROOT is not set on this agent") | if set: absolute and an existing directory, else fail fast |
|
||||
| `UPDATE_TIMEOUT` | `UpdateTimeout time.Duration` | `5m` | `time.ParseDuration`, must be > 0 |
|
||||
| `DOCKER_CONFIG` | `DockerConfig string` | `""` (anonymous registry access) | if set: `$DOCKER_CONFIG/config.json` must exist and parse, else fail fast. Same variable `docker compose` reads, so `pull` uses the same credentials. |
|
||||
|
||||
`STACKS_ROOT` must be mounted read-write **at the same path as on the host**, because the labels contain host paths.
|
||||
There is no `STACKS_ROOT`. The allowed stack directories are the agent container's bind mounts that are read-write with `Source == Destination` (read on every Compose job via `ContainerInspect(SELF_ID)`). Each stacks directory must be mounted as `/path:/path` (no `:ro`), because Compose labels hold host paths.
|
||||
|
||||
```go
|
||||
// package conn
|
||||
@@ -687,20 +689,22 @@ func (r *Runner) Run(ctx context.Context)
|
||||
- Delete: `cmd/server/`, `internal/api/`, `internal/config/`, the rest of `internal/discovery/`, `internal/selfupdate/`, `internal/metrics/`.
|
||||
|
||||
- [ ] **Step 1:** Do the moves and deletions. The moved executors no longer compile (they reference `discovery.Job`) until W2-4/W2-5 rewrite them, so W2-1, W2-3, W2-4 and W2-5 land as **one commit** once `go build ./...` is green. Never commit a tree that does not build.
|
||||
- [ ] **Step 2:** `go mod tidy` after everything compiles; confirm `github.com/prometheus/*` left `go.mod` and `gopkg.in/yaml.v3`, `github.com/distribution/reference` are direct.
|
||||
- [ ] **Step 2:** Promote the two dependencies with `go get gopkg.in/yaml.v3 github.com/distribution/reference`. Run `go mod tidy` only when `go build ./...` is green for the whole module (other workstreams may be mid-edit); if it is not, leave tidy to W5. Confirm `github.com/prometheus/*` left `go.mod` and `gopkg.in/yaml.v3`, `github.com/distribution/reference` are direct.
|
||||
- [ ] **Step 3:** `grep -rn "internal/\(api\|config\|discovery\|selfupdate\|metrics\|updater\)\"" --include='*.go' .` returns nothing.
|
||||
- [ ] **Step 4: Commit** (with W2-3..W2-5) — `refactor(agent)!: move updater under the agent and remove cmd/server (#7)` with a `BREAKING CHANGE:` footer naming the removed `/update` endpoint and the new default entrypoint.
|
||||
|
||||
Packages W2 tests while W2–W4 run in parallel: `go vet ./internal/agent/... ./cmd/agent/ && go test -race ./internal/agent/...`.
|
||||
|
||||
### Task W2-2: Agent config and inventory `ConfigFiles`
|
||||
|
||||
**Files:** `internal/agent/config/config.go`, `config_test.go`; `internal/agent/inventory/collector.go`, `collector_test.go`
|
||||
|
||||
- [ ] **Step 1: Failing tests**
|
||||
- `TestLoad_JobSettings` (table): defaults (`StacksRoot ""`, `UpdateTimeout 5m`, `DockerConfig ""`); `STACKS_ROOT=relative/path` → error; `STACKS_ROOT=<t.TempDir()>/missing` → error; `STACKS_ROOT=<t.TempDir()>` → ok; `UPDATE_TIMEOUT=90s` → 90s; `UPDATE_TIMEOUT=0`/`abc` → error; `DOCKER_CONFIG=<dir without config.json>` → error; with a valid file → ok.
|
||||
- `TestLoad_JobSettings` (table): defaults (`UpdateTimeout 5m`, `DockerConfig ""`); `UPDATE_TIMEOUT=90s` → 90s; `UPDATE_TIMEOUT=0`/`abc` → error; `DOCKER_CONFIG=<dir without config.json>` → error; with a valid file → ok.
|
||||
- Collector: a Compose container with label `com.docker.compose.project.config_files=/s/a/compose.yaml,/s/a/compose.override.yaml` yields `ConfigFiles` equal to the raw label value.
|
||||
- [ ] **Step 2:** Run `go test ./internal/agent/config ./internal/agent/inventory` — FAIL.
|
||||
- [ ] **Step 3:** Implement. `config.Load` only validates `DOCKER_CONFIG`'s file exists and is JSON; parsing into credentials is W2-5's `LoadRegistryAuth`.
|
||||
- [ ] **Step 4:** PASS. **Commit:** `feat(agent): STACKS_ROOT, UPDATE_TIMEOUT and DOCKER_CONFIG settings (#7)`
|
||||
- [ ] **Step 4:** PASS. **Commit:** `feat(agent): UPDATE_TIMEOUT and DOCKER_CONFIG settings (#7)`
|
||||
|
||||
### Task W2-3: Job runner (queue, outbox, timeout)
|
||||
|
||||
@@ -791,16 +795,25 @@ type CommandRunner func(ctx context.Context, dir string, out io.Writer, args ...
|
||||
// ExecDocker is the real CommandRunner (exec.CommandContext, WaitDelay 10s).
|
||||
func ExecDocker(ctx context.Context, dir string, out io.Writer, args ...string) error
|
||||
|
||||
type ContainerLister interface {
|
||||
// SelfInspector is the subset of the Docker SDK MountRoots needs.
|
||||
type SelfInspector interface {
|
||||
ContainerInspect(ctx context.Context, containerID string) (container.InspectResponse, error)
|
||||
}
|
||||
// MountRoots returns the agent container's read-write bind mounts whose
|
||||
// host path equals the path inside the container.
|
||||
func MountRoots(ctx context.Context, cli SelfInspector, selfID string) ([]string, error)
|
||||
|
||||
type ComposeDocker interface {
|
||||
SelfInspector
|
||||
ContainerList(ctx context.Context, opts container.ListOptions) ([]container.Summary, error)
|
||||
}
|
||||
func NewComposeExecutor(cli ContainerLister, resolve *Resolver, stacksRoot string, run CommandRunner) *ComposeExecutor
|
||||
func NewComposeExecutor(cli ComposeDocker, resolve *Resolver, selfID string, run CommandRunner) *ComposeExecutor
|
||||
func (e *ComposeExecutor) Execute(ctx context.Context, job protocol.JobStart, out io.Writer) (string, error)
|
||||
```
|
||||
|
||||
`ComposeExecutor.Execute` steps, in this order (each failure stops the job before the next step):
|
||||
1. List containers (`All: true`) filtered by labels `com.docker.compose.project=<p>` and `com.docker.compose.service=<s>`; none → error `no container for compose service p/s on this machine`. Take `working_dir` and `config_files` labels from the first.
|
||||
2. `composeFiles(stacksRoot, configFiles)` → `-f` list ending with the override, and the stack dir (code below).
|
||||
2. `MountRoots(ctx, cli, selfID)` (inspect error → job fails with it), then `composeFiles(roots, configFiles)` → `-f` list ending with the override, and the stack dir (code below).
|
||||
3. `Resolve(job.Ref)` → `pinned`. Write `resolved <pinned>\n` to `out`.
|
||||
4. `WriteOverride(dir, job.ComposeService, pinned)`.
|
||||
5. `run(ctx, workingDir, out, "compose", "-p", p, "-f", f1, …, "-f", override, "pull", s)`, then the same with `"up", "-d", s`. Return `pinned`.
|
||||
@@ -895,13 +908,38 @@ func WriteOverride(dir, service, ref string) (string, error) {
|
||||
return path, nil
|
||||
}
|
||||
|
||||
// errNoRoots is returned when the agent has no directory it may write stacks in.
|
||||
var errNoRoots = errors.New("refused: no stack directory is mounted into the agent at the same path (read-write); " +
|
||||
"mount each stacks directory as /path:/path and check that SELF_ID (default: the hostname) names the agent's container")
|
||||
|
||||
// MountRoots returns the directories the agent may update Compose stacks in:
|
||||
// its own container's read-write bind mounts whose host path equals the
|
||||
// path inside the container. Compose labels hold host paths, so only those
|
||||
// mounts make a label path usable as is.
|
||||
func MountRoots(ctx context.Context, cli SelfInspector, selfID string) ([]string, error) {
|
||||
if selfID == "" {
|
||||
return nil, errNoRoots
|
||||
}
|
||||
insp, err := cli.ContainerInspect(ctx, selfID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("inspect own container %s: %w", selfID, err)
|
||||
}
|
||||
var roots []string
|
||||
for _, m := range insp.Mounts {
|
||||
if m.Type == mount.TypeBind && m.RW && filepath.Clean(m.Source) == filepath.Clean(m.Destination) {
|
||||
roots = append(roots, filepath.Clean(m.Destination))
|
||||
}
|
||||
}
|
||||
return roots, nil
|
||||
}
|
||||
|
||||
// composeFiles returns the -f arguments for a job: the stack's config files
|
||||
// from the container label with any copy of the override removed, then the
|
||||
// override last so it wins. Every path, and the real location of the
|
||||
// stack directory, must be inside root.
|
||||
func composeFiles(root, configFilesLabel string) (files []string, dir string, err error) {
|
||||
if root == "" {
|
||||
return nil, "", errors.New("refused: STACKS_ROOT is not set on this agent")
|
||||
// override last so it wins. Every file, and the real location of the stack
|
||||
// directory after resolving symlinks, must be inside one of roots.
|
||||
func composeFiles(roots []string, configFilesLabel string) (files []string, dir string, err error) {
|
||||
if len(roots) == 0 {
|
||||
return nil, "", errNoRoots
|
||||
}
|
||||
for _, f := range strings.Split(configFilesLabel, ",") {
|
||||
if f = strings.TrimSpace(f); f != "" {
|
||||
@@ -915,8 +953,8 @@ func composeFiles(root, configFilesLabel string) (files []string, dir string, er
|
||||
override := filepath.Join(dir, OverrideName)
|
||||
out := files[:0]
|
||||
for _, f := range files {
|
||||
if !filepath.IsAbs(f) || !IsInsideRoot(root, f) {
|
||||
return nil, "", fmt.Errorf("refused: %s is outside STACKS_ROOT", f)
|
||||
if !filepath.IsAbs(f) || !insideAny(roots, f) {
|
||||
return nil, "", notMounted(filepath.Dir(f))
|
||||
}
|
||||
if filepath.Clean(f) != override {
|
||||
out = append(out, f)
|
||||
@@ -926,22 +964,42 @@ func composeFiles(root, configFilesLabel string) (files []string, dir string, er
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("resolve %s: %w", dir, err)
|
||||
}
|
||||
realRoot, err := filepath.EvalSymlinks(root)
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("resolve STACKS_ROOT: %w", err)
|
||||
// Only directories count once resolved: this drops a same-path mount of
|
||||
// a single file such as /var/run/docker.sock.
|
||||
var realRoots []string
|
||||
for _, r := range roots {
|
||||
if rr, err := filepath.EvalSymlinks(r); err == nil {
|
||||
if fi, err := os.Stat(rr); err == nil && fi.IsDir() {
|
||||
realRoots = append(realRoots, rr)
|
||||
}
|
||||
if !IsInsideRoot(realRoot, real) {
|
||||
return nil, "", fmt.Errorf("refused: %s resolves to %s, outside STACKS_ROOT", dir, real)
|
||||
}
|
||||
}
|
||||
if !insideAny(realRoots, real) {
|
||||
return nil, "", notMounted(dir)
|
||||
}
|
||||
return append(out, override), dir, nil
|
||||
}
|
||||
|
||||
func insideAny(roots []string, path string) bool {
|
||||
for _, r := range roots {
|
||||
if IsInsideRoot(r, path) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func notMounted(dir string) error {
|
||||
return fmt.Errorf("stack directory %s is not mounted into the agent at the same path (read-write)", dir)
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 1: Failing tests**
|
||||
- `TestWriteOverride` (table, each case on its own `t.TempDir()`): new file → starts with header, contains `services.web.image`; existing ours with `db` entry → `db` kept, `web` set; existing ours with `web` → replaced; `compose.override.yaml` without header → refused, file unchanged; each of `compose.override.yml`, `docker-compose.override.yml`, `docker-compose.override.yaml` present → refused, ours not created; after success the directory holds no temp files.
|
||||
- `TestComposeFiles` (table): label `a/compose.yaml` → `[a/compose.yaml, a/compose.override.yaml]`; label already containing the override → listed once, last; empty label → error; `STACKS_ROOT ""` → refused; a file outside root → refused; root/`evil` symlinked to a dir outside root → refused; `/foo-evil` vs root `/foo` → refused (lexical guard still applies).
|
||||
- `TestMountRoots` (table, fake inspector): rw bind `/srv/stacks:/srv/stacks` → allowed; ro same-path bind → excluded; rw bind `/home/x/stacks:/stacks` (different path) → excluded; named volume → excluded; inspect error → error; `selfID ""` → the no-roots error.
|
||||
- `TestComposeFiles` (table, roots are `t.TempDir()` paths): label `<root>/a/compose.yaml` → `[…/compose.yaml, …/compose.override.yaml]`; label already containing the override → listed once, last; nested path `<root>/apps/x/y/compose.yaml` → allowed; second root also allowed; empty label → error; no roots → error mentioning "no stack directory is mounted"; a file outside every root → exactly `stack directory <dir> is not mounted into the agent at the same path (read-write)`; `<root>/evil` symlinked to a dir outside → refused with the same message; `/foo-evil` vs root `/foo` → refused (lexical guard still applies); a root that is a file (socket stand-in) does not allow paths under it.
|
||||
- `TestResolver` (fake `DistributionClient` recording args): `"gitea.shcizo.se/shcizo/slusk:1.4"` → called with that ref and the auth string for `gitea.shcizo.se`; returns `"gitea.shcizo.se/shcizo/slusk:1.4@sha256:d1"`; `"repo@sha256:x"` and `"repo"` → error without calling the daemon; daemon error is wrapped.
|
||||
- `TestComposeExecutor` (fake lister, fake resolver client, recording `CommandRunner`, temp `STACKS_ROOT`): happy path runs exactly `compose -p slusk -f <root>/slusk/compose.yaml -f <root>/slusk/compose.override.yaml pull web` then `… up -d web` in `working_dir`, override written before the first command, returns pinned ref; no matching container → error, no commands; resolve fails → no override written, no commands; pull fails → `up` not run, error contains the command output; override refused → no commands.
|
||||
- `TestComposeExecutor` (fake Docker client returning containers and the agent's own mounts, fake resolver client, recording `CommandRunner`, temp dir as the mounted root): happy path runs exactly `compose -p slusk -f <root>/slusk/compose.yaml -f <root>/slusk/compose.override.yaml pull web` then `… up -d web` in `working_dir`, override written before the first command, returns pinned ref; no matching container → error, no commands; resolve fails → no override written, no commands; pull fails → `up` not run, error contains the command output; override refused → no commands; agent container mounts the root read-only → `not mounted` error, no override written, no commands.
|
||||
- [ ] **Step 2:** FAIL. **Step 3:** Implement. **Step 4:** `go test -race ./internal/agent/updater` PASS.
|
||||
|
||||
### Task W2-5: Swarm executor, registry auth, dispatch
|
||||
@@ -1014,7 +1072,7 @@ Registry host mapping: `reference.ParseNormalizedNamed(repo)` → `reference.Dom
|
||||
auth, err := updater.LoadRegistryAuth(cfg.DockerConfig, logger)
|
||||
resolver := updater.NewResolver(dockerCli, auth)
|
||||
exec := updater.NewDispatch(
|
||||
updater.NewComposeExecutor(dockerCli, resolver, cfg.StacksRoot, updater.ExecDocker),
|
||||
updater.NewComposeExecutor(dockerCli, resolver, cfg.SelfID, updater.ExecDocker),
|
||||
updater.NewSwarmExecutor(dockerCli, resolver, auth),
|
||||
)
|
||||
runner := updater.NewRunner(exec, cfg.UpdateTimeout)
|
||||
@@ -1022,7 +1080,7 @@ Registry host mapping: `reference.ParseNormalizedNamed(repo)` → `reference.Dom
|
||||
agent := conn.New(conn.Config{...}, collector, runner, logger)
|
||||
```
|
||||
|
||||
Log `agent_starting` with `stacks_root`, `update_timeout`, `registry_auth` (bool) — never the token.
|
||||
Log `agent_starting` with `update_timeout`, `registry_auth` (bool) — never the token. At startup call `updater.MountRoots(ctx, dockerCli, cfg.SelfID)` once, only to log: on error or zero roots log `no_stack_mounts` at warn ("Compose updates will fail; mount each stacks directory at the same path, read-write"), else log `stack_mounts` with the roots. Jobs read the mounts again themselves.
|
||||
- [ ] **Step 2: Dockerfile:** build only `master:updater-master agent:updater-agent`; `EXPOSE 8080 8090`; `ENTRYPOINT ["/usr/local/bin/updater-agent"]`. Why the agent: it is the image's many-copies deployment (one per machine, pasted from the UI), the master stack already sets its own entrypoint, and an old container that accidentally picks up the new image fails fast on missing `MASTER_URL` instead of serving anything.
|
||||
- [ ] **Step 3:** `deploy/agent.compose.yml` = the UI snippet (W4-4) with placeholders and comments.
|
||||
- [ ] **Step 4:** `go build ./cmd/agent && docker build -t package-updater:dev .` then `docker run --rm package-updater:dev` exits with `fatal: MASTER_URL is required`. Remove the built `./agent` binary.
|
||||
@@ -1032,6 +1090,8 @@ Log `agent_starting` with `stacks_root`, `update_timeout`, `registry_auth` (bool
|
||||
|
||||
## W3 — Master
|
||||
|
||||
Packages W3 tests while W2–W4 run in parallel: `go vet ./internal/master/store/ ./internal/master/hub/ ./internal/master/jobs/ ./cmd/master/ && go test -race ./internal/master/store/ ./internal/master/hub/ ./internal/master/jobs/` (plus the `integration` tag for the store when `DATABASE_URL` is set).
|
||||
|
||||
### Task W3-1: Store — jobs, config files, desired ref
|
||||
|
||||
**Files:** Create `internal/master/store/jobs.go`, `jobs_integration_test.go`; modify `services.go`, `agents.go`, `services_integration_test.go`, `agents_integration_test.go`.
|
||||
@@ -1180,8 +1240,8 @@ func (s *Service) Updatable(svc model.Service) error {
|
||||
|
||||
**Files:** `cmd/master/main.go`.
|
||||
|
||||
- [ ] **Step 1:** Wire per C7. No new env vars (`AGENT_IMAGE` exists).
|
||||
- [ ] **Step 2:** `go build ./cmd/master` (requires W4 merged for the `web.New` signature). Remove the built binary.
|
||||
- [ ] **Step 1:** Wire per C7, replacing W4-0's temporary `nil` with `jobService`. No new env vars (`AGENT_IMAGE` exists).
|
||||
- [ ] **Step 2:** `go build ./cmd/master` (W4-0 is committed before this task starts, so `web.New` already has the C7 signature). Remove the built binary.
|
||||
- [ ] **Step 3: Commit** — `feat(master): wire jobs into the master (#7)`
|
||||
|
||||
---
|
||||
@@ -1190,6 +1250,17 @@ func (s *Service) Updatable(svc model.Service) error {
|
||||
|
||||
W4 works against C5/C6/C7 with fakes; it does not need W3's code to compile its own package. Integration point: `cmd/master` (W3-4) passes `*jobs.Service` and `*store.Store`.
|
||||
|
||||
Packages W4 tests while W2–W4 run in parallel: `go vet ./internal/master/web/ ./internal/master/webauth/ && go test ./internal/master/web/ ./internal/master/webauth/`.
|
||||
|
||||
### Task W4-0: Constructor signature first
|
||||
|
||||
**Files:** `internal/master/web/server.go`, `server_test.go`, `cmd/master/main.go` (this one call only — the single exception to W3 owning `cmd/master`; tell the W3 owner when it is committed).
|
||||
|
||||
- [ ] **Step 1:** Add the `Jobs` interface and the three `Store` methods from C7, change `New` to `New(store, hub, jobs, broker, cfg, log)` and store `jobs` on `Server`. No routes use it yet. Extend `fakeStore` with the three methods and add a `fakeJobs` in `server_test.go`.
|
||||
- [ ] **Step 2:** In `cmd/master/main.go`, change only the `web.New` call: pass `nil` as `jobs`, with the comment `// wired in W3-4`. This is the one temporary value in the plan, and it is there only so the shared module builds at every commit: the real `*jobs.Service` cannot be built until W3-1..W3-3 exist (it needs `*store.Store` and `*hub.Hub` to satisfy their interfaces). No handler calls `s.jobs` before W4-1, and W3-4 replaces the `nil` before W5 starts; the final verification greps that the `// wired in W3-4` comment is gone.
|
||||
- [ ] **Step 3:** `go build ./cmd/master ./internal/master/web/ && go test ./internal/master/web/` PASS.
|
||||
- [ ] **Step 4: Commit** — `refactor(web): web.New takes the jobs service (#7)`
|
||||
|
||||
### Task W4-1: Service page and Update action
|
||||
|
||||
**Files:** `internal/master/web/server.go`, `views.go`, new `templates/service.html`, `templates/overview.html`, `templates/layout.html`, `static/app.css`, `server_test.go`; `internal/master/webauth/auth.go` + `auth_test.go`.
|
||||
@@ -1250,19 +1321,16 @@ services:
|
||||
MASTER_URL: <AgentURL>
|
||||
MASTER_KEY: <MasterKey>
|
||||
AGENT_TOKEN: <token>
|
||||
# Compose stacks this agent may update. Mount it read-write at the
|
||||
# same path as on the host: Compose labels hold host paths.
|
||||
STACKS_ROOT: /home/shcizo/self-hosted
|
||||
UPDATE_TIMEOUT: 5m
|
||||
# Optional registry credentials (a config.json written by docker login):
|
||||
# DOCKER_CONFIG: /docker-config
|
||||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
- /home/shcizo/self-hosted:/home/shcizo/self-hosted
|
||||
# - /path/to/stacks:/path/to/stacks # mount each stacks directory at the same path, read-write
|
||||
# - /root/.docker/config.json:/docker-config/config.json:ro
|
||||
```
|
||||
|
||||
- [ ] Test: snippet contains `STACKS_ROOT`, the same-path rw mount line (no `:ro`), `UPDATE_TIMEOUT`, the commented `DOCKER_CONFIG`. **Commit** — `feat(web): agent snippet mounts stacks read-write for updates (#7)`
|
||||
- [ ] Test: snippet does **not** contain `STACKS_ROOT`; contains the commented same-path mount example line exactly as above, `UPDATE_TIMEOUT`, the commented `DOCKER_CONFIG`. **Commit** — `feat(web): agent snippet mounts stacks read-write for updates (#7)`
|
||||
|
||||
---
|
||||
|
||||
@@ -1289,10 +1357,16 @@ services:
|
||||
- [ ] `CLAUDE.md`:
|
||||
- Intro: master/agent fleet updater (no longer "webhook-driven, single binary").
|
||||
- Commands: drop `go build ./cmd/server` and the local smoke test (`docker-compose.local.yml`, `env.sample` are gone); keep `go build ./cmd/master ./cmd/agent`, integration tests, docker build.
|
||||
- Architecture: remove `cmd/server`, `internal/api`, `internal/config`, `internal/discovery`, `internal/updater`, `internal/selfupdate`, `internal/metrics`; add `internal/agent/updater` (runner + Compose/Swarm executors + override file + STACKS_ROOT check + registry auth) and `internal/master/jobs`.
|
||||
- Design intents: remove "Defense in depth: token AND opt-in label AND STACKS_ROOT", "Auth on /update only", "metrics may be nil", "Stateless", "MODE"; replace with: single FIFO worker **per agent** (`internal/agent/updater/runner.go`); one active job per service (unique index); the agent writes only `compose.override.yaml` with the header and refuses when any other override name exists; `STACKS_ROOT` check on every config file and the override path, after symlink resolution; self-protection by `AGENT_IMAGE` repo + `self` flag; unknown message types ignored, malformed messages close.
|
||||
- Gotchas: remove the selfupdate, `MODE`, `STACKS_ROOT` prod-default, "stateless/label gate describe cmd/server only" entries; update the `IsInsideRoot` path; add: Compose loads only one override file; stacks started with explicit `-f` don't load the override on a manual `up`; override entries are never pruned; `STACKS_ROOT` must be mounted rw at the host path; `job.start` carries no paths; default entrypoint is the agent.
|
||||
- [ ] `README.md`: replace the webhook-era sections (trigger flow, opt-in label, Swarm `MODE`, quick start with `UPDATER_API_KEY`) with: what master and agents do, deploying the master (`deploy/master.stack.yml`), adding agents (UI snippet / `deploy/agent.compose.yml`), the agent env table (C8), how Compose updates work and their limits (override file, explicit `-f`, operator overrides refused, no pruning), Swarm updates, and "the webhook returns in phase 3; until then the Gitea action has nothing to call".
|
||||
- Architecture: remove `cmd/server`, `internal/api`, `internal/config`, `internal/discovery`, `internal/updater`, `internal/selfupdate`, `internal/metrics`; add `internal/agent/updater` (runner + Compose/Swarm executors + override file + mount-root check + registry auth) and `internal/master/jobs`.
|
||||
- Design intents: remove "Defense in depth: token AND opt-in label AND STACKS_ROOT", "Auth on /update only", "metrics may be nil", "Stateless", "MODE"; replace with: single FIFO worker **per agent** (`internal/agent/updater/runner.go`); one active job per service (unique index); the agent writes only `compose.override.yaml` with the header and refuses when any other override name exists; every config file and the override path must be inside a read-write, same-path bind mount of the agent's own container (read per job, checked after symlink resolution) — replaces `STACKS_ROOT`; self-protection by `AGENT_IMAGE` repo + `self` flag; unknown message types ignored, malformed messages close.
|
||||
- Gotchas: remove the selfupdate, `MODE`, `STACKS_ROOT` prod-default, "stateless/label gate describe cmd/server only" entries; update the `IsInsideRoot` path; add: Compose loads only one override file; stacks started with explicit `-f` don't load the override on a manual `up`; override entries are never pruned; there is no `STACKS_ROOT`: stacks directories must be mounted rw at the host path, and `SELF_ID` must resolve to the agent's container (on Docker Desktop/OrbStack bind sources are translated, so Compose jobs fail there); `job.start` carries no paths; default entrypoint is the agent.
|
||||
- [ ] `README.md`: replace the webhook-era sections (trigger flow, opt-in label, Swarm `MODE`, quick start with `UPDATER_API_KEY`) with: what master and agents do, deploying the master (`deploy/master.stack.yml`), adding agents (UI snippet / `deploy/agent.compose.yml`), the agent env table (C8), mounting stacks directories (`/path:/path`, read-write, one line per directory; no `STACKS_ROOT`), how Compose updates work and their limits (override file, explicit `-f`, operator overrides refused, no pruning), a "check before enabling" snippet listing every stack's config files so the operator can spot existing override files and explicit `-f` stacks:
|
||||
|
||||
```bash
|
||||
docker ps --format '{{.Label "com.docker.compose.project.config_files"}}' | sort -u
|
||||
```
|
||||
|
||||
Swarm updates, and "the webhook returns in phase 3; until then the Gitea action has nothing to call".
|
||||
- [ ] `deploy/master.stack.yml`: header comment "phase 2: inventory and manual updates".
|
||||
- [ ] **Commit** — `docs: master/agent phase 2 — jobs and manual update (#7)`
|
||||
|
||||
@@ -1303,6 +1377,8 @@ services:
|
||||
Run from the worktree root:
|
||||
|
||||
```bash
|
||||
go mod tidy && git diff --exit-code go.mod go.sum # W2 may have left tidy to here; commit if it changed
|
||||
! grep -n "wired in W3-4" cmd/master/main.go # W4-0's temporary nil is gone
|
||||
go vet ./... && go test ./... && go test -race ./internal/agent/... ./internal/master/... ./internal/e2e/ \
|
||||
&& go build ./cmd/master ./cmd/agent && docker build -t package-updater:dev .
|
||||
rm -f master agent
|
||||
@@ -1310,4 +1386,4 @@ DATABASE_URL=postgres://... go test -tags integration ./internal/master/store/
|
||||
docker run --rm package-updater:dev # expect: fatal: MASTER_URL is required
|
||||
```
|
||||
|
||||
Manual smoke test (one machine with a throwaway Compose stack under `STACKS_ROOT`): deploy the master, add an agent, open the stack's service page, click *Update*, watch the log stream, confirm `compose.override.yaml` appeared with the header and `repo:tag@digest`, run `docker compose up -d` by hand in the stack dir and confirm the container keeps the pinned digest.
|
||||
Manual smoke test (Linux machine, a throwaway Compose stack in a directory mounted into the agent at the same path, read-write): deploy the master, add an agent, open the stack's service page, click *Update*, watch the log stream, confirm `compose.override.yaml` appeared with the header and `repo:tag@digest`, run `docker compose up -d` by hand in the stack dir and confirm the container keeps the pinned digest.
|
||||
|
||||
Reference in New Issue
Block a user