plan: named-unit config delivery, twelve tasks from the approved spec
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
1 file changed,
+2957
−0
docs/superpowers/plans/2026-09-07-named-unit-config-delivery.md+2957 −0
diff --git a/docs/superpowers/plans/2026-09-07-named-unit-config-delivery.md b/docs/superpowers/plans/2026-09-07-named-unit-config-delivery.md| new file mode 100644 |
| index 000000000..1d1d96459 |
| --- /dev/null |
| +++ b/docs/superpowers/plans/2026-09-07-named-unit-config-delivery.md |
| @@ -0,0 +1,2957 @@ |
| +# Named-Unit Config Delivery Implementation Plan |
| + |
| +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. |
| + |
| +**Goal:** A route stamped with a named systemd unit gets its config rendered, wired into the unit through a carlos-owned drop-in, and restarted by the root pass, and `env set` / `secrets set` say when a write reached nothing or is blocked. |
| + |
| +**Architecture:** `internal/appconfig` gains one predicate pair (`Stamped`, `unitDelivered`) and exported rails that the root pass, the console notice and the CLI doors all share. `Sync` grows a `UnitOps` seam (lock, probe, reload, restart, drop-in root) so the whole delivery protocol is unit-testable with fakes; `internal/host` supplies the real `systemctl` implementations. The notice gains `Undelivered` and `Blocked`, carried additively over both wire types. |
| + |
| +**Tech Stack:** Go, sqlite registry (`internal/registry`), systemd drop-ins, `go test ./... -count=1`. |
| + |
| +**Spec:** `docs/superpowers/specs/2026-09-07-named-unit-config-delivery-design.md` |
| + |
| +## Global Constraints |
| + |
| +- Build and test gate: `go build ./...`, `go vet ./...`, `go test ./... -count=1`, `gofmt -l .` clean. Sandbox: `GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off`; socket tests need `TMPDIR=/tmp/g` (mkdir it first). |
| +- Cross-compile gate: `internal/host` is linked into the CLI; anything Linux-only needs a build-tagged file with a stub (the existing `flock_unix.go` / `flock_windows.go` pair is the pattern). Nothing in this plan calls a Linux-only syscall directly; the flock helper is reused through a callback. |
| +- Every user-facing string added here (notice lines, refusal messages) goes through `copy-review` before the branch lands. Write them as the spec drafts them and flag them in the final commit message. |
| +- Wire shapes are additive: new JSON fields are `omitempty`; nothing existing is renamed or removed. |
| +- Drop-in location is fixed: `<DropInRoot>/<unit>.d/zz-carlos-env.conf`, marker file `<DropInRoot>/<unit>.d/carlos-env.pending`; production `DropInRoot` is `/etc/systemd/system`. |
| +- The drop-in's first line is exactly `# Managed by carlos: config delivery for the route naming this unit.` and that line is the ownership proof. |
| +- Commit after every task; commit messages end with `Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>`. |
| +- Never `git stash`; never push to `main`; the branch is `worktree-unit-route-config`. |
| + |
| +--- |
| + |
| +### Task 1: Predicates and exported rails |
| + |
| +**Files:** |
| +- Create: `internal/appconfig/stamped.go` |
| +- Create: `internal/appconfig/stamped_test.go` |
| +- Modify: `internal/appconfig/sync.go:284-320` (`syncable` stays; `UnitConfigApps` uses `unitDelivered`) |
| +- Modify: `internal/appconfig/syncexec.go:478-482` (`execRouteBase` excludes stamped routes) |
| +- Modify: `internal/appconfig/routefingerprint.go:58-76` (unit row includes `Unit`) |
| +- Test: `internal/appconfig/routefingerprint_test.go`, `internal/appconfig/syncexec_test.go` |
| + |
| +**Interfaces:** |
| +- Produces: `func Stamped(rt registry.Route) bool`, `func unitDelivered(rt registry.Route) bool`, `func StampShape(rt registry.Route) error`, `func DeliverableUnitName(unit string) error`, `func BareHostname(host string) bool`. All later tasks use these names exactly. |
| + |
| +- [ ] **Step 1: Write the failing predicate tests** |
| + |
| +Create `internal/appconfig/stamped_test.go`: |
| + |
| +```go |
| +package appconfig |
| + |
| +import ( |
| + "strings" |
| + "testing" |
| + |
| + "github.com/carlosframework/platform/internal/registry" |
| +) |
| + |
| +func stampedRoute(host, unit string) registry.Route { |
| + return registry.Route{Host: host, Kind: "instance", App: "relay", Account: "bab", |
| + Status: "ready", Addr: "127.0.0.1:8451", Unit: unit} |
| +} |
| + |
| +func TestStampedAdmitsInstanceServiceAndPlatformRows(t *testing.T) { |
| + inst := stampedRoute("relay.example.com", "relay.service") |
| + svc := inst |
| + svc.Kind = "service" |
| + plat := inst |
| + plat.Platform = true |
| + for name, rt := range map[string]registry.Route{"instance": inst, "service": svc, "platform": plat} { |
| + if !Stamped(rt) { |
| + t.Errorf("%s: Stamped = false, want true", name) |
| + } |
| + if !unitDelivered(rt) { |
| + t.Errorf("%s: unitDelivered = false, want true", name) |
| + } |
| + } |
| +} |
| + |
| +func TestStampedRejectsUnstampedNotReadyStaticAndAppless(t *testing.T) { |
| + base := stampedRoute("relay.example.com", "relay.service") |
| + cases := map[string]func(registry.Route) registry.Route{ |
| + "no unit": func(r registry.Route) registry.Route { r.Unit = ""; return r }, |
| + "pending": func(r registry.Route) registry.Route { r.Status = "pending"; return r }, |
| + "static": func(r registry.Route) registry.Route { r.Kind = "static"; return r }, |
| + "no app": func(r registry.Route) registry.Route { r.App = ""; return r }, |
| + } |
| + for name, mut := range cases { |
| + if Stamped(mut(base)) { |
| + t.Errorf("%s: Stamped = true, want false", name) |
| + } |
| + } |
| +} |
| + |
| +func TestExecRouteBaseExcludesStampedRoutes(t *testing.T) { |
| + rt := stampedRoute("relay.example.com", "relay.service") |
| + if execRouteBase(rt) { |
| + t.Fatal("execRouteBase admitted a stamped route; the root pass owns it") |
| + } |
| + if execAddrCandidate(rt) { |
| + t.Fatal("execAddrCandidate admitted a stamped route") |
| + } |
| + rt.Unit = "" |
| + if !execAddrCandidate(rt) { |
| + t.Fatal("the same route without a stamp must still be an exec addr candidate") |
| + } |
| +} |
| + |
| +func TestStampShape(t *testing.T) { |
| + ok := stampedRoute("relay.example.com", "relay.service") |
| + if err := StampShape(ok); err != nil { |
| + t.Fatalf("StampShape(addr route) = %v, want nil", err) |
| + } |
| + addrs := ok |
| + addrs.Addr, addrs.Addrs = "", "127.0.0.1:1,127.0.0.1:2" |
| + if err := StampShape(addrs); err != nil { |
| + t.Fatalf("StampShape(addrs route) = %v, want nil", err) |
| + } |
| + sock := ok |
| + sock.Socket = "/run/x.sock" |
| + if err := StampShape(sock); err == nil || !strings.Contains(err.Error(), "socket") { |
| + t.Fatalf("StampShape(socket route) = %v, want a socket refusal", err) |
| + } |
| + hib := ok |
| + hib.Hibernate = true |
| + if err := StampShape(hib); err == nil || !strings.Contains(err.Error(), "hibernat") { |
| + t.Fatalf("StampShape(hibernating route) = %v, want a hibernate refusal", err) |
| + } |
| + none := ok |
| + none.Addr = "" |
| + if err := StampShape(none); err == nil || !strings.Contains(err.Error(), "addr") { |
| + t.Fatalf("StampShape(no upstream) = %v, want an addr refusal", err) |
| + } |
| +} |
| + |
| +func TestDeliverableUnitName(t *testing.T) { |
| + for _, good := range []string{"relay.service", "woodstar-relay.service", "console.service", "a_b.c.service"} { |
| + if err := DeliverableUnitName(good); err != nil { |
| + t.Errorf("%s: %v, want accepted", good, err) |
| + } |
| + } |
| + for name, want := range map[string]string{ |
| + "carlos-app@.service": "template", |
| + "carlos-app@x.service": "template", |
| + "carlos-.service": "prefix", |
| + "": "unit name", |
| + "relay": "unit name", |
| + "a/b.service": "unit name", |
| + } { |
| + err := DeliverableUnitName(name) |
| + if err == nil || !strings.Contains(err.Error(), want) { |
| + t.Errorf("%q: %v, want a refusal mentioning %q", name, err, want) |
| + } |
| + } |
| +} |
| + |
| +func TestBareHostname(t *testing.T) { |
| + for _, good := range []string{"relay.example.com", "a", "woodstar-relay.bdm.oncarlos.com", "X1.y2"} { |
| + if !BareHostname(good) { |
| + t.Errorf("%q: want bare", good) |
| + } |
| + } |
| + for _, bad := range []string{"", ".", "..", "a..b", "-a.b", "a-.b", "a\nUser=root", "a b", "a/b", strings.Repeat("a", 254)} { |
| + if BareHostname(bad) { |
| + t.Errorf("%q: want refused", bad) |
| + } |
| + } |
| +} |
| +``` |
| + |
| +- [ ] **Step 2: Run to verify they fail** |
| + |
| +Run: `GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test ./internal/appconfig/ -run 'TestStamped|TestExecRouteBaseExcludes|TestStampShape|TestDeliverableUnitName|TestBareHostname' -count=1` |
| +Expected: FAIL with `undefined: Stamped` (compile error). |
| + |
| +- [ ] **Step 3: Create `internal/appconfig/stamped.go`** |
| + |
| +```go |
| +// The stamped shape: a route that names the systemd unit owning its |
| +// process (registry.Route.Unit, issue #110). Config delivery for it is the |
| +// ROOT pass's (spec 2026-09-07-named-unit-config-delivery-design.md §3.1): |
| +// the agent's exec sweep never touches it. The predicates and rails here |
| +// are shared by Sync (the pass), AwaitingRestart (the notice) and the CLI |
| +// doors (`carlos add`, `carlos route --unit/--hibernate`), so that the |
| +// three cannot disagree on which routes are stamped, or on why one is |
| +// refused. |
| +package appconfig |
| + |
| +import ( |
| + "fmt" |
| + "regexp" |
| + "strings" |
| + |
| + "github.com/carlosframework/platform/internal/registry" |
| +) |
| + |
| +// Stamped reports whether rt names its own unit and is a route the root |
| +// pass may deliver config to. NOT Adoptable narrowed: `carlos route --unit` |
| +// admits service and platform rows (cli.go unitRail), so delivery admits |
| +// them too, or a supported stamp would belong to neither delivery half. |
| +// A row that is not ready is left alone as everywhere else. |
| +func Stamped(rt registry.Route) bool { |
| + return rt.Unit != "" && rt.App != "" && |
| + (rt.Kind == "instance" || rt.Kind == "service") && |
| + (rt.Status == "" || rt.Status == "ready") |
| +} |
| + |
| +// unitDelivered is Sync's eligibility: the carlos-app@ shape (unit backing |
| +// on an adoptable row) or a stamped route. UnitConfigApps and the unit |
| +// fingerprint use the same test, for the reason each says beside itself. |
| +func unitDelivered(rt registry.Route) bool { |
| + return (syncable(rt) && rt.Backing == registry.BackingUnit) || Stamped(rt) |
| +} |
| + |
| +// StampShape is the supported shape of a stamped route (spec §3.1): an |
| +// addr upstream, no socket, no hibernation. A socket route is the |
| +// activator's to spawn, so no unit can own it; a hibernating route is woken |
| +// by the activator, and a config restart would start it behind the |
| +// activator's back. The error text is the refusal reason the pass logs and |
| +// the notice reports. |
| +func StampShape(rt registry.Route) error { |
| + switch { |
| + case rt.Socket != "": |
| + return fmt.Errorf("names unit %s but has a socket upstream: a socket route is the activator's to spawn, so no unit can own it", rt.Unit) |
| + case rt.Addr == "" && rt.Addrs == "": |
| + return fmt.Errorf("names unit %s but has no addr upstream", rt.Unit) |
| + case rt.Hibernate: |
| + return fmt.Errorf("names unit %s and hibernates: a config restart would start it behind the activator's back", rt.Unit) |
| + } |
| + return nil |
| +} |
| + |
| +// DeliverableUnitName is the rail at the root write (spec §3.2): a concrete |
| +// service name whose drop-in directory applies to that unit alone. A |
| +// template name (carlos-app@.service) has a directory systemd applies to |
| +// every instance; a prefix name (carlos-.service) has one systemd applies |
| +// to every unit sharing the prefix; an instance name (x@y.service) is |
| +// refused with them for simplicity, since nothing stamps one. |
| +func DeliverableUnitName(unit string) error { |
| + if unit == "" || !registry.ValidUnitName(unit) { |
| + return fmt.Errorf("%q is not a systemd service unit name", unit) |
| + } |
| + if strings.Contains(unit, "@") { |
| + return fmt.Errorf("%s is a template or instance name; its drop-in directory would apply to every instance of the template", unit) |
| + } |
| + if strings.HasSuffix(strings.TrimSuffix(unit, ".service"), "-") { |
| + return fmt.Errorf("%s is a prefix name; systemd applies its drop-in directory to every unit sharing the prefix", unit) |
| + } |
| + return nil |
| +} |
| + |
| +var hostLabelRe = regexp.MustCompile(`^[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?$`) |
| + |
| +// BareHostname: labels of [A-Za-z0-9-] joined by '.', no label empty or |
| +// edged with '-', at most 253 bytes. syncHost's basename check stops a path |
| +// separator; this stops a newline, which matters once a host is written |
| +// into a file systemd parses as directives (spec §3.2). |
| +func BareHostname(host string) bool { |
| + if host == "" || len(host) > 253 { |
| + return false |
| + } |
| + for _, l := range strings.Split(host, ".") { |
| + if !hostLabelRe.MatchString(l) { |
| + return false |
| + } |
| + } |
| + return true |
| +} |
| +``` |
| + |
| +- [ ] **Step 4: Exclude stamped routes from the exec side** |
| + |
| +In `internal/appconfig/syncexec.go`, change `execRouteBase` (line 478) to: |
| + |
| +```go |
| +func execRouteBase(rt registry.Route) bool { |
| + return rt.Kind == "instance" && rt.App != "" && !rt.Platform && |
| + (rt.Status == "" || rt.Status == "ready") && |
| + rt.Backing != registry.BackingUnit && |
| + rt.Unit == "" |
| +} |
| +``` |
| + |
| +Add to its doc comment, after "unit routes belong to the root adopt path": |
| + |
| +```go |
| +// ... A STAMPED route (rt.Unit != "") belongs to the root pass too, whatever |
| +// its backing: see stamped.go and spec §3.1. Excluding it here removes it |
| +// from execSyncable, execAddrCandidate, ExecConfigApps, the exec |
| +// fingerprint and AwaitingRestart's delivered-to set at once. |
| +``` |
| + |
| +- [ ] **Step 5: Make `UnitConfigApps` and the unit fingerprint use `unitDelivered`** |
| + |
| +In `internal/appconfig/sync.go` `UnitConfigApps` (line 303), replace |
| + |
| +```go |
| + if !syncable(rt) || rt.Backing != registry.BackingUnit { |
| +``` |
| + |
| +with |
| + |
| +```go |
| + if !unitDelivered(rt) { |
| +``` |
| + |
| +In `internal/appconfig/routefingerprint.go` (lines 67-73), replace the row and the unit test with: |
| + |
| +```go |
| + row := rt.Host + "|" + release.RouteAccount(rt) + "|" + rt.App + "|" + rt.Environment |
| + if execSyncable(rt) || execAddrCandidate(rt) { |
| + execRows = append(execRows, row) |
| + } |
| + if unitDelivered(rt) { |
| + // Unit is part of the unit row (spec §3.5): a re-stamp to a |
| + // different unit changes which drop-in the route belongs in |
| + // without changing the env file's bytes, and this digest is |
| + // what turns that registry change into a root pass request. |
| + unitRows = append(unitRows, row+"|"+rt.Unit) |
| + } |
| +``` |
| + |
| +- [ ] **Step 6: Add fingerprint and app-list tests** |
| + |
| +Append to `internal/appconfig/routefingerprint_test.go`: |
| + |
| +```go |
| +func fpStampedRoute(host, unit string) registry.Route { |
| + return registry.Route{Host: host, Kind: "instance", App: "woodstar-relay", |
| + Account: "bdm", Status: "ready", Addr: "127.0.0.1:8451", Unit: unit} |
| +} |
| + |
| +func TestConfigRouteFingerprintsStampedRouteIsInTheUnitHalf(t *testing.T) { |
| + stamped := []registry.Route{fpStampedRoute("relay.bdm.oncarlos.com", "woodstar-relay.service")} |
| + none := []registry.Route{} |
| + e, u := ConfigRouteFingerprints(stamped) |
| + e0, u0 := ConfigRouteFingerprints(none) |
| + if e != e0 { |
| + t.Fatal("a stamped route must not be in the exec digest") |
| + } |
| + if u == u0 { |
| + t.Fatal("a stamped route must be in the unit digest") |
| + } |
| + _, u2 := ConfigRouteFingerprints([]registry.Route{fpStampedRoute("relay.bdm.oncarlos.com", "other.service")}) |
| + if u == u2 { |
| + t.Fatal("re-stamping to a different unit must move the unit digest") |
| + } |
| +} |
| + |
| +func TestUnitConfigAppsIncludesStampedRoutes(t *testing.T) { |
| + got := UnitConfigApps([]registry.Route{fpStampedRoute("relay.bdm.oncarlos.com", "woodstar-relay.service")}) |
| + if len(got) != 1 || got[0] != [2]string{"bdm", "woodstar-relay"} { |
| + t.Fatalf("UnitConfigApps = %v, want [[bdm woodstar-relay]]", got) |
| + } |
| + if got := ExecConfigApps([]registry.Route{fpStampedRoute("relay.bdm.oncarlos.com", "woodstar-relay.service")}); len(got) != 0 { |
| + t.Fatalf("ExecConfigApps = %v, want none for a stamped route", got) |
| + } |
| +} |
| +``` |
| + |
| +- [ ] **Step 7: Run the package tests** |
| + |
| +Run: `GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test ./internal/appconfig/ -count=1` |
| +Expected: PASS. `TestAwaitingRestartCarriesUnit` now FAILS (a stamped route is no longer an exec addr candidate). That is expected and is fixed in Task 9; for this commit, change that test to assert the opposite and rename it: |
| + |
| +```go |
| +// TestAwaitingRestartLeavesStampedRoutesToTheRootPass: a stamped route is |
| +// the root pass's (spec §3.1); it is delivered and restarted there, so it |
| +// is never a host the notice tells someone to restart by hand. |
| +func TestAwaitingRestartLeavesStampedRoutesToTheRootPass(t *testing.T) { |
| + ctx, s, _, _, _, _ := execKit(t) |
| + if err := SetExecDelivery(ctx, s, "bab", "relay", ExecDeliveryFile); err != nil { |
| + t.Fatalf("SetExecDelivery: %v", err) |
| + } |
| + rt := addrRoute("console.example.com", "relay", "bab", "") |
| + rt.Unit = "console.service" |
| + got := AwaitingRestart(ctx, s, []registry.Route{rt}, "bab", "relay", "", nil) |
| + if len(got.Hosts) != 0 { |
| + t.Fatalf("Hosts = %+v, want none for a stamped route", got.Hosts) |
| + } |
| +} |
| +``` |
| + |
| +Re-run; expected: PASS across the package. Then `go vet ./internal/appconfig/ && gofmt -l internal/appconfig/`. |
| + |
| +- [ ] **Step 8: Commit** |
| + |
| +```bash |
| +git add internal/appconfig/stamped.go internal/appconfig/stamped_test.go internal/appconfig/sync.go internal/appconfig/syncexec.go internal/appconfig/routefingerprint.go internal/appconfig/routefingerprint_test.go internal/appconfig/awaitingrestart_test.go |
| +git commit -m "appconfig: a stamped route is the root pass's; shared shape and name rails (#114) |
| + |
| +Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>" |
| +``` |
| + |
| +--- |
| + |
| +### Task 2: `syncHost` returns a three-state outcome |
| + |
| +**Files:** |
| +- Modify: `internal/appconfig/sync.go:331-441` (`syncHost`) |
| +- Test: `internal/appconfig/sync_test.go` |
| + |
| +**Interfaces:** |
| +- Produces: `type hostOutcome int` with `hostFailed`, `hostUnchanged`, `hostChanged`; `func prepareHost(rt registry.Route, m *Manifest, defaultMissing bool, priv *ecdh.PrivateKey, envDir string) (path string, rendered []byte, cur []byte, existed bool, ok bool)`; `func commitHost(host, path string, rendered []byte) bool`; `func syncHost(...)` keeps its signature and is now `prepareHost` + `commitHost` + restart. Task 4 calls `prepareHost` and `commitHost` directly. |
| + |
| +- [ ] **Step 1: Write the failing test** |
| + |
| +Append to `internal/appconfig/sync_test.go`: |
| + |
| +```go |
| +func TestPrepareAndCommitHostOutcomes(t *testing.T) { |
| + envDir := t.TempDir() |
| + priv, _, err := GenerateKey() |
| + if err != nil { |
| + t.Fatalf("GenerateKey: %v", err) |
| + } |
| + privKey, err := ParsePrivateKey(priv, "test") |
| + if err != nil { |
| + t.Fatalf("ParsePrivateKey: %v", err) |
| + } |
| + rt := registry.Route{Host: "keymail.example.com", Kind: "instance", App: "keymail", Account: "bab", Status: "ready"} |
| + m := &Manifest{Vars: map[string]Var{"LOG_LEVEL": {Kind: "env", Value: "debug"}}} |
| + |
| + path, rendered, cur, existed, ok := prepareHost(rt, m, false, privKey, envDir) |
| + if !ok || existed || cur != nil || path != filepath.Join(envDir, "keymail.example.com.env") { |
| + t.Fatalf("first prepare: ok=%v existed=%v cur=%q path=%q", ok, existed, cur, path) |
| + } |
| + if !commitHost(rt.Host, path, rendered) { |
| + t.Fatal("commitHost failed") |
| + } |
| + _, rendered2, cur2, existed2, ok2 := prepareHost(rt, m, false, privKey, envDir) |
| + if !ok2 || !existed2 || !bytes.Equal(cur2, rendered2) { |
| + t.Fatalf("second prepare: ok=%v existed=%v equal=%v", ok2, existed2, bytes.Equal(cur2, rendered2)) |
| + } |
| + |
| + // A non-bare host is a refusal: ok == false, nothing written. |
| + bad := rt |
| + bad.Host = "../x" |
| + if _, _, _, _, ok := prepareHost(bad, m, false, privKey, envDir); ok { |
| + t.Fatal("prepareHost accepted a non-bare hostname") |
| + } |
| +} |
| +``` |
| + |
| +Add `"bytes"` to the test file's imports. |
| + |
| +- [ ] **Step 2: Run to verify it fails** |
| + |
| +Run: `GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test ./internal/appconfig/ -run TestPrepareAndCommitHostOutcomes -count=1` |
| +Expected: FAIL, `undefined: prepareHost`. |
| + |
| +- [ ] **Step 3: Split `syncHost`** |
| + |
| +Replace the body of `syncHost` in `internal/appconfig/sync.go` (keep its doc comment) with: |
| + |
| +```go |
| +func syncHost(rt registry.Route, m *Manifest, defaultMissing bool, priv *ecdh.PrivateKey, envDir string, restart func(host string) error) { |
| + path, rendered, cur, existed, ok := prepareHost(rt, m, defaultMissing, priv, envDir) |
| + if !ok { |
| + return |
| + } |
| + if existed && bytes.Equal(cur, rendered) { |
| + return // up to date — stay quiet on the periodic tick |
| + } |
| + if !commitHost(rt.Host, path, rendered) { |
| + return |
| + } |
| + // No rt.Hibernate special case on purpose: try-restart is a no-op on an |
| + // inactive unit, so a hibernated route's file converges without waking it. |
| + if err := restart(rt.Host); err != nil { |
| + fmt.Printf(" %s: config updated but restart failed: %v\n", rt.Host, err) |
| + return |
| + } |
| + fmt.Printf(" %s: config updated, restarted\n", rt.Host) |
| +} |
| + |
| +// hostOutcome is what one route's env file did this pass. Three states, |
| +// not two (spec §3.2, codex R2-2): a refusal, a render error or a write |
| +// error are all hostFailed, and a caller that treats "nothing changed" and |
| +// "could not write" alike would restart a unit against a stale or absent |
| +// file. |
| +type hostOutcome int |
| + |
| +const ( |
| + hostFailed hostOutcome = iota |
| + hostUnchanged |
| + hostChanged |
| +) |
| + |
| +// prepareHost is the read-and-render half of syncHost: everything up to |
| +// the decision to write, with every refusal syncHost makes. ok == false |
| +// means a refusal or error was printed and nothing may be written for this |
| +// route this pass. Split from the write so the stamped path (unitsync.go) |
| +// can decide what else must happen before the env file lands. |
| +func prepareHost(rt registry.Route, m *Manifest, defaultMissing bool, priv *ecdh.PrivateKey, envDir string) (path string, rendered, cur []byte, existed, ok bool) { |
| + // The host is joined into a root-written path; a registry row carrying |
| + // a separator must never place a file outside envDir. Same trust level |
| + // as release's live/<host> symlinks, but this one is one line to pin. |
| + if rt.Host == "" || rt.Host == "." || rt.Host == ".." || rt.Host != filepath.Base(rt.Host) { |
| + fmt.Printf(" %q: not a bare hostname, refusing to derive an env file path from it\n", rt.Host) |
| + return "", nil, nil, false, false |
| + } |
| + path = filepath.Join(envDir, rt.Host+".env") |
| + cur, err := os.ReadFile(path) |
| + existed = err == nil |
| + if err != nil && !os.IsNotExist(err) { |
| + fmt.Printf(" %s: read %s: %v\n", rt.Host, path, err) |
| + return "", nil, nil, false, false |
| + } |
| + if !existed { |
| + cur = nil |
| + } |
| + // (the defaultMissing / lostKeys guard block moves here unchanged, |
| + // returning ("", nil, nil, false, false) where it returned before) |
| + if defaultMissing && existed && len(cur) > 0 { |
| + lost, parsed := lostKeys(cur, m) |
| + switch { |
| + case !parsed: |
| + fmt.Printf(" %s: environment %q is bound but %s has no default config bundle, so the merged view is that environment ALONE — "+ |
| + "refusing to replace %s, which this pass cannot parse as KEY=value and so cannot prove is safe to overwrite; "+ |
| + "import its config (`carlos env set --app %s KEY=value ...`) or remove the file\n", |
| + rt.Host, rt.Environment, rt.App, path, rt.App) |
| + return "", nil, nil, false, false |
| + case len(lost) > 0: |
| + fmt.Printf(" %s: environment %q is bound but %s has no default config bundle, so the merged view is that environment ALONE — "+ |
| + "refusing to drop %d var(s) already in %s (%s); import them (`carlos env set --app %s KEY=value ...`) "+ |
| + "and this route converges on the next pass, or remove the file if it is dead\n", |
| + rt.Host, rt.Environment, rt.App, len(lost), path, strings.Join(lost, ", "), rt.App) |
| + return "", nil, nil, false, false |
| + } |
| + } |
| + rendered, err = RenderEnv(m, priv) |
| + if err != nil { |
| + fmt.Printf(" %s: render config: %v\n", rt.Host, err) |
| + return "", nil, nil, false, false |
| + } |
| + rendered = applyAgentVars(rendered) |
| + return path, rendered, cur, existed, true |
| +} |
| + |
| +// commitHost writes rendered to path atomically. False means the write |
| +// failed and was printed; the file on disk is whatever it was. |
| +func commitHost(host, path string, rendered []byte) bool { |
| + tmp := path + ".tmp" |
| + if err := os.WriteFile(tmp, rendered, 0o600); err != nil { |
| + fmt.Printf(" %s: write %s: %v\n", host, path, err) |
| + return false |
| + } |
| + if err := os.Rename(tmp, path); err != nil { |
| + os.Remove(tmp) // don't leave rendered secrets behind |
| + fmt.Printf(" %s: rename %s: %v\n", host, path, err) |
| + return false |
| + } |
| + return true |
| +} |
| +``` |
| + |
| +Move the long comment blocks (the CRITICAL 1 guard explanation, the Spec §7.3 `applyAgentVars` note) with the code they describe; do not drop them. |
| + |
| +- [ ] **Step 4: Run the whole package** |
| + |
| +Run: `GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test ./internal/appconfig/ -count=1 && go vet ./internal/appconfig/ && gofmt -l internal/appconfig/` |
| +Expected: PASS, no vet output, no unformatted files. Every existing Sync test still passes: behaviour is unchanged. |
| + |
| +- [ ] **Step 5: Commit** |
| + |
| +```bash |
| +git add internal/appconfig/sync.go internal/appconfig/sync_test.go |
| +git commit -m "appconfig: split syncHost into prepare and commit with a three-state outcome |
| + |
| +Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>" |
| +``` |
| + |
| +--- |
| + |
| +### Task 3: The drop-in file model |
| + |
| +**Files:** |
| +- Create: `internal/appconfig/dropin.go` |
| +- Create: `internal/appconfig/dropin_test.go` |
| + |
| +**Interfaces:** |
| +- Produces: |
| + - `const DropInFile = "zz-carlos-env.conf"`, `const PendingFile = "carlos-env.pending"`, `const dropInMarker = "# Managed by carlos: config delivery for the route naming this unit."` |
| + - `func dropInDir(root, unit string) string` → `<root>/<unit>.d` |
| + - `func renderDropIn(envDir, host string) []byte` |
| + - `func ensureDropInDir(root, unit string) error` (mkdir 0755; Lstat; refuse symlink or non-dir) |
| + - `func readDropIn(root, unit string) (cur []byte, exists, marked bool, err error)` |
| + - `func writeDropIn(root, unit string, rendered []byte) error` (atomic, 0644) |
| + - `func writePending(root, unit string) error`, `func removePending(root, unit string) error`, `func hasPending(root, unit string) bool` |
| + - `func listMarkedDropIns(root string) (units []string, skipped []string)` (walks `*.service.d`, Lstat each, returns units with a marked drop-in; symlinked dirs in `skipped`) |
| + - `func listPending(root string) []string` (units with a marker, same Lstat rule) |
| + - `func removeDropIn(root, unit string) error` |
| +- Task 4 and Task 6 consume all of these. |
| + |
| +- [ ] **Step 1: Write the failing tests** |
| + |
| +Create `internal/appconfig/dropin_test.go`: |
| + |
| +```go |
| +package appconfig |
| + |
| +import ( |
| + "os" |
| + "path/filepath" |
| + "strings" |
| + "testing" |
| +) |
| + |
| +func TestRenderDropInHasMarkerResetAndFile(t *testing.T) { |
| + got := string(renderDropIn("/etc/carlos/apps", "relay.example.com")) |
| + want := dropInMarker + "\n" + |
| + "# Edit the route's config with `carlos env` / `carlos secrets`, not this file.\n" + |
| + "[Service]\n" + |
| + "EnvironmentFile=\n" + |
| + "EnvironmentFile=-/etc/carlos/apps/relay.example.com.env\n" |
| + if got != want { |
| + t.Fatalf("renderDropIn =\n%s\nwant\n%s", got, want) |
| + } |
| +} |
| + |
| +func TestWriteReadAndMarkDropIn(t *testing.T) { |
| + root := t.TempDir() |
| + if err := ensureDropInDir(root, "relay.service"); err != nil { |
| + t.Fatalf("ensureDropInDir: %v", err) |
| + } |
| + if _, exists, _, err := readDropIn(root, "relay.service"); err != nil || exists { |
| + t.Fatalf("readDropIn before write: exists=%v err=%v", exists, err) |
| + } |
| + body := renderDropIn("/etc/carlos/apps", "relay.example.com") |
| + if err := writeDropIn(root, "relay.service", body); err != nil { |
| + t.Fatalf("writeDropIn: %v", err) |
| + } |
| + cur, exists, marked, err := readDropIn(root, "relay.service") |
| + if err != nil || !exists || !marked || string(cur) != string(body) { |
| + t.Fatalf("readDropIn after write: exists=%v marked=%v err=%v", exists, marked, err) |
| + } |
| + st, _ := os.Stat(filepath.Join(root, "relay.service.d", DropInFile)) |
| + if st.Mode().Perm() != 0o644 { |
| + t.Fatalf("drop-in mode = %o, want 0644", st.Mode().Perm()) |
| + } |
| + if hasPending(root, "relay.service") { |
| + t.Fatal("pending marker present before writePending") |
| + } |
| + if err := writePending(root, "relay.service"); err != nil { |
| + t.Fatalf("writePending: %v", err) |
| + } |
| + if !hasPending(root, "relay.service") { |
| + t.Fatal("pending marker absent after writePending") |
| + } |
| + if got := listPending(root); len(got) != 1 || got[0] != "relay.service" { |
| + t.Fatalf("listPending = %v", got) |
| + } |
| + if err := removePending(root, "relay.service"); err != nil || hasPending(root, "relay.service") { |
| + t.Fatalf("removePending: err=%v still pending=%v", err, hasPending(root, "relay.service")) |
| + } |
| + if err := removePending(root, "relay.service"); err != nil { |
| + t.Fatalf("removePending twice must be a no-op, got %v", err) |
| + } |
| +} |
| + |
| +func TestReadDropInSeesAnUnmarkedFile(t *testing.T) { |
| + root := t.TempDir() |
| + dir := filepath.Join(root, "relay.service.d") |
| + os.MkdirAll(dir, 0o755) |
| + os.WriteFile(filepath.Join(dir, DropInFile), []byte("[Service]\nEnvironmentFile=/x\n"), 0o644) |
| + _, exists, marked, err := readDropIn(root, "relay.service") |
| + if err != nil || !exists || marked { |
| + t.Fatalf("exists=%v marked=%v err=%v, want an existing unmarked file", exists, marked, err) |
| + } |
| + units, _ := listMarkedDropIns(root) |
| + if len(units) != 0 { |
| + t.Fatalf("listMarkedDropIns = %v, want none for an unmarked file", units) |
| + } |
| +} |
| + |
| +func TestEnsureDropInDirRefusesSymlinkAndFile(t *testing.T) { |
| + root := t.TempDir() |
| + elsewhere := t.TempDir() |
| + os.Symlink(elsewhere, filepath.Join(root, "linked.service.d")) |
| + if err := ensureDropInDir(root, "linked.service"); err == nil || !strings.Contains(err.Error(), "symlink") { |
| + t.Fatalf("ensureDropInDir(symlink) = %v, want a symlink refusal", err) |
| + } |
| + os.WriteFile(filepath.Join(root, "file.service.d"), []byte("x"), 0o644) |
| + if err := ensureDropInDir(root, "file.service"); err == nil { |
| + t.Fatal("ensureDropInDir(regular file) accepted") |
| + } |
| + if err := ensureDropInDir(root, "fresh.service"); err != nil { |
| + t.Fatalf("ensureDropInDir(fresh) = %v", err) |
| + } |
| + st, _ := os.Lstat(filepath.Join(root, "fresh.service.d")) |
| + if !st.IsDir() || st.Mode().Perm() != 0o755 { |
| + t.Fatalf("fresh dir mode = %v", st.Mode()) |
| + } |
| +} |
| + |
| +func TestListMarkedDropInsSkipsSymlinkedDirs(t *testing.T) { |
| + root := t.TempDir() |
| + elsewhere := t.TempDir() |
| + os.WriteFile(filepath.Join(elsewhere, DropInFile), renderDropIn("/e", "h"), 0o644) |
| + os.Symlink(elsewhere, filepath.Join(root, "retired.service.d")) |
| + os.MkdirAll(filepath.Join(root, "real.service.d"), 0o755) |
| + os.WriteFile(filepath.Join(root, "real.service.d", DropInFile), renderDropIn("/e", "h"), 0o644) |
| + units, skipped := listMarkedDropIns(root) |
| + if len(units) != 1 || units[0] != "real.service" { |
| + t.Fatalf("units = %v, want [real.service]", units) |
| + } |
| + if len(skipped) != 1 || skipped[0] != "retired.service" { |
| + t.Fatalf("skipped = %v, want [retired.service]", skipped) |
| + } |
| + if _, err := os.Stat(filepath.Join(elsewhere, DropInFile)); err != nil { |
| + t.Fatal("the file behind the symlink must be untouched") |
| + } |
| +} |
| + |
| +func TestRemoveDropIn(t *testing.T) { |
| + root := t.TempDir() |
| + ensureDropInDir(root, "relay.service") |
| + writeDropIn(root, "relay.service", renderDropIn("/e", "h")) |
| + if err := removeDropIn(root, "relay.service"); err != nil { |
| + t.Fatalf("removeDropIn: %v", err) |
| + } |
| + if _, exists, _, _ := readDropIn(root, "relay.service"); exists { |
| + t.Fatal("drop-in still present") |
| + } |
| + if _, err := os.Stat(filepath.Join(root, "relay.service.d")); err != nil { |
| + t.Fatal("the directory must never be removed") |
| + } |
| +} |
| +``` |
| + |
| +- [ ] **Step 2: Run to verify they fail** |
| + |
| +Run: `GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test ./internal/appconfig/ -run 'DropIn|Pending' -count=1` |
| +Expected: FAIL, `undefined: renderDropIn`. |
| + |
| +- [ ] **Step 3: Create `internal/appconfig/dropin.go`** |
| + |
| +```go |
| +// The drop-in a stamped route's unit reads its config through (spec §3.2): |
| +// <root>/<unit>.d/zz-carlos-env.conf, marker-owned, carrying a reset and |
| +// ONE EnvironmentFile line; and beside it carlos-env.pending, the record |
| +// that the unit owes a daemon-reload and (while it is still owned) a |
| +// restart. Everything here is a pure file operation with no systemd in it, |
| +// so the pass's protocol (unitsync.go) is testable with a temp dir. |
| +package appconfig |
| + |
| +import ( |
| + "bytes" |
| + "errors" |
| + "fmt" |
| + "os" |
| + "path/filepath" |
| + "sort" |
| + "strings" |
| +) |
| + |
| +const ( |
| + // DropInFile sorts after override.conf and every conventionally named |
| + // drop-in; with the reset line that ordering is belt and braces. |
| + DropInFile = "zz-carlos-env.conf" |
| + // PendingFile is not a .conf on purpose: systemd ignores it. |
| + PendingFile = "carlos-env.pending" |
| + // dropInMarker is the first line of every drop-in this package writes, |
| + // and the ONLY evidence it accepts before replacing or removing one. |
| + dropInMarker = "# Managed by carlos: config delivery for the route naming this unit." |
| +) |
| + |
| +func dropInDir(root, unit string) string { return filepath.Join(root, unit+".d") } |
| + |
| +// renderDropIn: the reset line hands the unit's EnvironmentFile list to |
| +// carlos whole (spec §3.2, codex R2-8); the second line is the rendered |
| +// bundle, with the '-' prefix so an absent file is not a start failure. |
| +func renderDropIn(envDir, host string) []byte { |
| + return []byte(dropInMarker + "\n" + |
| + "# Edit the route's config with `carlos env` / `carlos secrets`, not this file.\n" + |
| + "[Service]\n" + |
| + "EnvironmentFile=\n" + |
| + "EnvironmentFile=-" + filepath.Join(envDir, host+".env") + "\n") |
| +} |
| + |
| +// ensureDropInDir creates <unit>.d if absent and refuses to use it if it |
| +// is not a plain directory: a symlinked drop-in directory would let a root |
| +// write land somewhere else (spec §3.2 rail; codex R1-2, R2-1). |
| +func ensureDropInDir(root, unit string) error { |
| + dir := dropInDir(root, unit) |
| + st, err := os.Lstat(dir) |
| + switch { |
| + case err == nil: |
| + if st.Mode()&os.ModeSymlink != 0 { |
| + return fmt.Errorf("%s is a symlink; refusing to write through it", dir) |
| + } |
| + if !st.IsDir() { |
| + return fmt.Errorf("%s exists and is not a directory", dir) |
| + } |
| + return nil |
| + case os.IsNotExist(err): |
| + return os.Mkdir(dir, 0o755) |
| + default: |
| + return err |
| + } |
| +} |
| + |
| +// dirIsPlain is ensureDropInDir's check without the mkdir, for readers. |
| +func dirIsPlain(dir string) bool { |
| + st, err := os.Lstat(dir) |
| + return err == nil && st.IsDir() && st.Mode()&os.ModeSymlink == 0 |
| +} |
| + |
| +func readDropIn(root, unit string) (cur []byte, exists, marked bool, err error) { |
| + cur, err = os.ReadFile(filepath.Join(dropInDir(root, unit), DropInFile)) |
| + if err != nil { |
| + if os.IsNotExist(err) { |
| + return nil, false, false, nil |
| + } |
| + return nil, false, false, err |
| + } |
| + first, _, _ := bytes.Cut(cur, []byte("\n")) |
| + return cur, true, string(first) == dropInMarker, nil |
| +} |
| + |
| +func writeDropIn(root, unit string, rendered []byte) error { |
| + path := filepath.Join(dropInDir(root, unit), DropInFile) |
| + tmp := path + ".tmp" |
| + if err := os.WriteFile(tmp, rendered, 0o644); err != nil { |
| + return err |
| + } |
| + if err := os.Rename(tmp, path); err != nil { |
| + os.Remove(tmp) |
| + return err |
| + } |
| + return nil |
| +} |
| + |
| +func removeDropIn(root, unit string) error { |
| + err := os.Remove(filepath.Join(dropInDir(root, unit), DropInFile)) |
| + if err != nil && !os.IsNotExist(err) { |
| + return err |
| + } |
| + return nil |
| +} |
| + |
| +func pendingPath(root, unit string) string { return filepath.Join(dropInDir(root, unit), PendingFile) } |
| + |
| +func writePending(root, unit string) error { |
| + return os.WriteFile(pendingPath(root, unit), []byte("reload and restart owed\n"), 0o644) |
| +} |
| + |
| +func removePending(root, unit string) error { |
| + err := os.Remove(pendingPath(root, unit)) |
| + if err != nil && !os.IsNotExist(err) { |
| + return err |
| + } |
| + return nil |
| +} |
| + |
| +func hasPending(root, unit string) bool { |
| + _, err := os.Lstat(pendingPath(root, unit)) |
| + return err == nil |
| +} |
| + |
| +// unitDirs lists <unit> for every plain <unit>.d directory under root; |
| +// symlinked or non-directory entries are returned in skipped and never |
| +// entered (codex R2-1). |
| +func unitDirs(root string) (units, skipped []string) { |
| + ents, err := os.ReadDir(root) |
| + if err != nil { |
| + return nil, nil |
| + } |
| + for _, e := range ents { |
| + name := e.Name() |
| + if !strings.HasSuffix(name, ".service.d") { |
| + continue |
| + } |
| + unit := strings.TrimSuffix(name, ".d") |
| + if !dirIsPlain(filepath.Join(root, name)) { |
| + skipped = append(skipped, unit) |
| + continue |
| + } |
| + units = append(units, unit) |
| + } |
| + sort.Strings(units) |
| + sort.Strings(skipped) |
| + return units, skipped |
| +} |
| + |
| +// listMarkedDropIns: units whose plain <unit>.d holds a marker-bearing |
| +// drop-in. An unmarked file at the path is not carlos's and is not listed. |
| +func listMarkedDropIns(root string) (units, skipped []string) { |
| + dirs, skipped := unitDirs(root) |
| + for _, u := range dirs { |
| + if _, exists, marked, err := readDropIn(root, u); err == nil && exists && marked { |
| + units = append(units, u) |
| + } |
| + } |
| + return units, skipped |
| +} |
| + |
| +// listPending: units whose plain <unit>.d holds a pending marker. |
| +func listPending(root string) []string { |
| + dirs, _ := unitDirs(root) |
| + var out []string |
| + for _, u := range dirs { |
| + if hasPending(root, u) { |
| + out = append(out, u) |
| + } |
| + } |
| + return out |
| +} |
| + |
| +var errNotOurs = errors.New("not carlos's file") |
| +``` |
| + |
| +(`errNotOurs` is used by Task 4.) |
| + |
| +- [ ] **Step 4: Run the tests** |
| + |
| +Run: `GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test ./internal/appconfig/ -run 'DropIn|Pending' -count=1 && go vet ./internal/appconfig/ && gofmt -l internal/appconfig/` |
| +Expected: PASS. |
| + |
| +- [ ] **Step 5: Commit** |
| + |
| +```bash |
| +git add internal/appconfig/dropin.go internal/appconfig/dropin_test.go |
| +git commit -m "appconfig: the marker-owned drop-in and pending marker as pure file operations |
| + |
| +Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>" |
| +``` |
| + |
| +--- |
| + |
| +### Task 4: `UnitOps`, the lock, the rail, and preparation of stamped routes |
| + |
| +**Files:** |
| +- Create: `internal/appconfig/unitsync.go` |
| +- Create: `internal/appconfig/unitsync_test.go` |
| +- Modify: `internal/appconfig/sync.go:42-180` (`Sync` becomes a wrapper around `SyncUnits`; the route loop uses `unitDelivered` and dispatches stamped routes) |
| + |
| +**Interfaces:** |
| +- Produces: |
| + ```go |
| + type UnitState struct { ID, LoadState string; EnvFiles []string } |
| + type UnitOps struct { |
| + DropInRoot string |
| + Lock func() (unlock func(), err error) |
| + Probe func(units []string) (map[string]UnitState, error) |
| + Reload func() error |
| + Restart func(unit string) error |
| + } |
| + func SyncUnits(ctx context.Context, reg *registry.Registry, s release.Store, priv *ecdh.PrivateKey, envDir string, restart func(host string) error, ops *UnitOps) error |
| + func Sync(ctx, reg, s, priv, envDir, restart) error // == SyncUnits(..., nil) |
| + ``` |
| +- Consumes: Task 1 rails, Task 2 `prepareHost`/`commitHost`, Task 3 file helpers. |
| +- Task 5 adds the reload/verify/restart half inside the same `SyncUnits`; this task ends with stamped routes prepared and marked, no reload or restart yet. |
| + |
| +- [ ] **Step 1: Write the failing tests** |
| + |
| +Create `internal/appconfig/unitsync_test.go`: |
| + |
| +```go |
| +package appconfig |
| + |
| +import ( |
| + "context" |
| + "crypto/ecdh" |
| + "errors" |
| + "os" |
| + "path/filepath" |
| + "strings" |
| + "testing" |
| + |
| + "github.com/carlosframework/platform/internal/registry" |
| + "github.com/carlosframework/platform/internal/release" |
| +) |
| + |
| +// fakeUnits is the UnitOps seam with everything recorded: what was probed, |
| +// reloaded and restarted, and what the probe answers. |
| +type fakeUnits struct { |
| + root string |
| + states map[string]UnitState |
| + probeErr error |
| + reloadErr error |
| + restartErr map[string]error |
| + lockErr error |
| + probed [][]string |
| + reloads int |
| + restarted []string |
| +} |
| + |
| +func newFakeUnits(t *testing.T) *fakeUnits { |
| + t.Helper() |
| + return &fakeUnits{root: t.TempDir(), states: map[string]UnitState{}, restartErr: map[string]error{}} |
| +} |
| + |
| +// loaded answers the probe for a healthy unit whose effective env list is |
| +// exactly carlos's file — the steady state after a successful pass. |
| +func (f *fakeUnits) loaded(unit, envDir, host string) { |
| + f.states[unit] = UnitState{ID: unit, LoadState: "loaded", EnvFiles: []string{filepath.Join(envDir, host+".env")}} |
| +} |
| + |
| +func (f *fakeUnits) ops() *UnitOps { |
| + return &UnitOps{ |
| + DropInRoot: f.root, |
| + Lock: func() (func(), error) { |
| + if f.lockErr != nil { |
| + return nil, f.lockErr |
| + } |
| + return func() {}, nil |
| + }, |
| + Probe: func(units []string) (map[string]UnitState, error) { |
| + f.probed = append(f.probed, append([]string(nil), units...)) |
| + if f.probeErr != nil { |
| + return nil, f.probeErr |
| + } |
| + out := map[string]UnitState{} |
| + for _, u := range units { |
| + if st, ok := f.states[u]; ok { |
| + out[u] = st |
| + } else { |
| + out[u] = UnitState{ID: u, LoadState: "not-found"} |
| + } |
| + } |
| + return out, nil |
| + }, |
| + Reload: func() error { f.reloads++; return f.reloadErr }, |
| + Restart: func(unit string) error { |
| + f.restarted = append(f.restarted, unit) |
| + return f.restartErr[unit] |
| + }, |
| + } |
| +} |
| + |
| +// unitKit: a store with one configured app "relay" (LOG_LEVEL=debug), a |
| +// registry, an env dir and the secrets key. |
| +func unitKit(t *testing.T) (ctx context.Context, s release.Store, reg *registry.Registry, envDir string, priv *ecdh.PrivateKey) { |
| + t.Helper() |
| + s = release.NewDirStore(t.TempDir()) |
| + ctx = context.Background() |
| + reg = testRegistry(t) |
| + envDir = t.TempDir() |
| + p, _, err := GenerateKey() |
| + if err != nil { |
| + t.Fatalf("GenerateKey: %v", err) |
| + } |
| + priv, err = ParsePrivateKey(p, "test") |
| + if err != nil { |
| + t.Fatalf("ParsePrivateKey: %v", err) |
| + } |
| + if err := SetEnv(ctx, s, "bab", "relay", map[string]string{"LOG_LEVEL": "debug"}); err != nil { |
| + t.Fatalf("SetEnv: %v", err) |
| + } |
| + return |
| +} |
| + |
| +func upsertStamped(t *testing.T, reg *registry.Registry, host, unit string, mut func(*registry.Route)) { |
| + t.Helper() |
| + rt := registry.Route{Host: host, Kind: "instance", App: "relay", Account: "bab", |
| + Status: "ready", Addr: "127.0.0.1:8451", Unit: unit} |
| + if mut != nil { |
| + mut(&rt) |
| + } |
| + if err := reg.Upsert(rt); err != nil { |
| + t.Fatalf("Upsert %s: %v", host, err) |
| + } |
| +} |
| + |
| +func noHostRestart(t *testing.T) func(string) error { |
| + return func(h string) error { t.Fatalf("host-keyed restart called for %s; a stamped route restarts by unit", h); return nil } |
| +} |
| + |
| +func TestSyncUnitsPreparesAStampedRoute(t *testing.T) { |
| + ctx, s, reg, envDir, priv := unitKit(t) |
| + f := newFakeUnits(t) |
| + f.states["relay.service"] = UnitState{ID: "relay.service", LoadState: "loaded"} |
| + upsertStamped(t, reg, "relay.example.com", "relay.service", nil) |
| + |
| + if err := SyncUnits(ctx, reg, s, priv, envDir, noHostRestart(t), f.ops()); err != nil { |
| + t.Fatalf("SyncUnits: %v", err) |
| + } |
| + got, err := os.ReadFile(filepath.Join(envDir, "relay.example.com.env")) |
| + if err != nil || string(got) != "LOG_LEVEL=debug\nCARLOS_PURGE=header\n" { |
| + t.Fatalf("env file = %q, %v", got, err) |
| + } |
| + cur, exists, marked, _ := readDropIn(f.root, "relay.service") |
| + if !exists || !marked || string(cur) != string(renderDropIn(envDir, "relay.example.com")) { |
| + t.Fatalf("drop-in exists=%v marked=%v body=%q", exists, marked, cur) |
| + } |
| + if len(f.probed) < 1 || strings.Join(f.probed[0], ",") != "relay.service" { |
| + t.Fatalf("probed = %v, want [[relay.service]] first", f.probed) |
| + } |
| +} |
| + |
| +func TestSyncUnitsWithNilOpsSkipsStampedRoutes(t *testing.T) { |
| + ctx, s, reg, envDir, priv := unitKit(t) |
| + upsertStamped(t, reg, "relay.example.com", "relay.service", nil) |
| + out := captureStdout(t, func() { |
| + if err := SyncUnits(ctx, reg, s, priv, envDir, noHostRestart(t), nil); err != nil { |
| + t.Fatalf("SyncUnits: %v", err) |
| + } |
| + }) |
| + if _, err := os.Stat(filepath.Join(envDir, "relay.example.com.env")); !os.IsNotExist(err) { |
| + t.Fatal("a pass with no unit ops must not render for a stamped route") |
| + } |
| + if !strings.Contains(out, "relay.example.com") || !strings.Contains(out, "no unit ops") { |
| + t.Fatalf("expected a skip line naming the host, got %q", out) |
| + } |
| +} |
| + |
| +func TestSyncUnitsDoesNothingWithoutTheLock(t *testing.T) { |
| + ctx, s, reg, envDir, priv := unitKit(t) |
| + f := newFakeUnits(t) |
| + f.lockErr = errors.New("held elsewhere") |
| + upsertStamped(t, reg, "relay.example.com", "relay.service", nil) |
| + if err := reg.Upsert(registry.Route{Host: "km.example.com", Kind: "instance", App: "relay", Account: "bab", |
| + Status: "ready", Backing: registry.BackingUnit}); err != nil { |
| + t.Fatal(err) |
| + } |
| + restarted := 0 |
| + if err := SyncUnits(ctx, reg, s, priv, envDir, func(string) error { restarted++; return nil }, f.ops()); err != nil { |
| + t.Fatalf("SyncUnits: %v", err) |
| + } |
| + ents, _ := os.ReadDir(envDir) |
| + if len(ents) != 0 || restarted != 0 || len(f.probed) != 0 { |
| + t.Fatalf("a pass that cannot lock must touch nothing: files=%d restarts=%d probes=%d", len(ents), restarted, len(f.probed)) |
| + } |
| +} |
| + |
| +func TestSyncUnitsRefusesBadShapesNamesAndHosts(t *testing.T) { |
| + ctx, s, reg, envDir, priv := unitKit(t) |
| + f := newFakeUnits(t) |
| + for _, u := range []string{"sock.service", "hib.service", "carlos-app@.service", "carlos-.service", "alias.service", "gone.service"} { |
| + f.states[u] = UnitState{ID: u, LoadState: "loaded"} |
| + } |
| + f.states["alias.service"] = UnitState{ID: "real.service", LoadState: "loaded"} |
| + f.states["gone.service"] = UnitState{ID: "gone.service", LoadState: "not-found"} |
| + upsertStamped(t, reg, "sock.example.com", "sock.service", func(r *registry.Route) { r.Socket = "/run/x.sock"; r.Addr = "" }) |
| + upsertStamped(t, reg, "hib.example.com", "hib.service", func(r *registry.Route) { r.Hibernate = true }) |
| + upsertStamped(t, reg, "tmpl.example.com", "carlos-app@.service", nil) |
| + upsertStamped(t, reg, "pref.example.com", "carlos-.service", nil) |
| + upsertStamped(t, reg, "alias.example.com", "alias.service", nil) |
| + upsertStamped(t, reg, "gone.example.com", "gone.service", nil) |
| + |
| + out := captureStdout(t, func() { |
| + if err := SyncUnits(ctx, reg, s, priv, envDir, noHostRestart(t), f.ops()); err != nil { |
| + t.Fatalf("SyncUnits: %v", err) |
| + } |
| + }) |
| + ents, _ := os.ReadDir(envDir) |
| + if len(ents) != 0 { |
| + t.Fatalf("refused routes must render nothing, got %v", ents) |
| + } |
| + if dirs, _ := os.ReadDir(f.root); len(dirs) != 0 { |
| + t.Fatalf("refused routes must get no drop-in directory, got %v", dirs) |
| + } |
| + for _, want := range []string{"sock.example.com", "hib.example.com", "tmpl.example.com", "pref.example.com", |
| + "alias.example.com", "real.service", "gone.example.com"} { |
| + if !strings.Contains(out, want) { |
| + t.Errorf("refusal output lacks %q:\n%s", want, out) |
| + } |
| + } |
| + // Only the routes that passed the shape and name rails reach the probe. |
| + if len(f.probed) != 1 || strings.Join(f.probed[0], ",") != "alias.service,gone.service" { |
| + t.Fatalf("probed = %v, want the two rail-passing units", f.probed) |
| + } |
| +} |
| + |
| +func TestSyncUnitsRefusesTwoRoutesOnOneUnit(t *testing.T) { |
| + ctx, s, reg, envDir, priv := unitKit(t) |
| + f := newFakeUnits(t) |
| + f.states["relay.service"] = UnitState{ID: "relay.service", LoadState: "loaded"} |
| + upsertStamped(t, reg, "a.example.com", "relay.service", nil) |
| + upsertStamped(t, reg, "b.example.com", "relay.service", nil) |
| + out := captureStdout(t, func() { |
| + if err := SyncUnits(ctx, reg, s, priv, envDir, noHostRestart(t), f.ops()); err != nil { |
| + t.Fatalf("SyncUnits: %v", err) |
| + } |
| + }) |
| + if ents, _ := os.ReadDir(envDir); len(ents) != 0 { |
| + t.Fatalf("neither route may be delivered, got %v", ents) |
| + } |
| + if !strings.Contains(out, "a.example.com") || !strings.Contains(out, "b.example.com") { |
| + t.Fatalf("both hosts must be named:\n%s", out) |
| + } |
| +} |
| + |
| +func TestSyncUnitsRefusesAnUnmarkedDropIn(t *testing.T) { |
| + ctx, s, reg, envDir, priv := unitKit(t) |
| + f := newFakeUnits(t) |
| + f.states["relay.service"] = UnitState{ID: "relay.service", LoadState: "loaded"} |
| + upsertStamped(t, reg, "relay.example.com", "relay.service", nil) |
| + os.MkdirAll(dropInDir(f.root, "relay.service"), 0o755) |
| + foreign := []byte("[Service]\nEnvironmentFile=/x\n") |
| + os.WriteFile(filepath.Join(dropInDir(f.root, "relay.service"), DropInFile), foreign, 0o644) |
| + out := captureStdout(t, func() { |
| + if err := SyncUnits(ctx, reg, s, priv, envDir, noHostRestart(t), f.ops()); err != nil { |
| + t.Fatalf("SyncUnits: %v", err) |
| + } |
| + }) |
| + cur, _, _, _ := readDropIn(f.root, "relay.service") |
| + if string(cur) != string(foreign) { |
| + t.Fatal("an unmarked file must never be replaced") |
| + } |
| + if hasPending(f.root, "relay.service") { |
| + t.Fatal("no marker may be written for a blocked route") |
| + } |
| + if !strings.Contains(out, "not carlos's file") { |
| + t.Fatalf("expected the blocked line, got %q", out) |
| + } |
| +} |
| + |
| +func TestSyncUnitsWritesMarkerBeforeFiles(t *testing.T) { |
| + ctx, s, reg, envDir, priv := unitKit(t) |
| + f := newFakeUnits(t) |
| + f.states["relay.service"] = UnitState{ID: "relay.service", LoadState: "loaded"} |
| + upsertStamped(t, reg, "relay.example.com", "relay.service", nil) |
| + // Make the env dir unwritable so the env write fails after the marker. |
| + if os.Geteuid() == 0 { |
| + t.Skip("root ignores directory modes") |
| + } |
| + os.Chmod(envDir, 0o500) |
| + t.Cleanup(func() { os.Chmod(envDir, 0o700) }) |
| + out := captureStdout(t, func() { |
| + if err := SyncUnits(ctx, reg, s, priv, envDir, noHostRestart(t), f.ops()); err != nil { |
| + t.Fatalf("SyncUnits: %v", err) |
| + } |
| + }) |
| + if !hasPending(f.root, "relay.service") { |
| + t.Fatal("the marker records intent before mutation and must survive a failed write") |
| + } |
| + if len(f.restarted) != 0 { |
| + t.Fatal("a failed preparation must not restart") |
| + } |
| + if !strings.Contains(out, "relay.example.com") { |
| + t.Fatalf("expected a failure line, got %q", out) |
| + } |
| +} |
| + |
| +func TestSyncUnitsCarlosAppRoutesUnchanged(t *testing.T) { |
| + ctx, s, reg, envDir, priv := unitKit(t) |
| + f := newFakeUnits(t) |
| + if err := reg.Upsert(registry.Route{Host: "km.example.com", Kind: "instance", App: "relay", Account: "bab", |
| + Status: "ready", Backing: registry.BackingUnit}); err != nil { |
| + t.Fatal(err) |
| + } |
| + var restarted []string |
| + if err := SyncUnits(ctx, reg, s, priv, envDir, func(h string) error { restarted = append(restarted, h); return nil }, f.ops()); err != nil { |
| + t.Fatalf("SyncUnits: %v", err) |
| + } |
| + if len(restarted) != 1 || restarted[0] != "km.example.com" { |
| + t.Fatalf("restarted = %v, want [km.example.com]", restarted) |
| + } |
| + if len(f.probed) != 0 || len(f.restarted) != 0 { |
| + t.Fatal("a carlos-app@ route must not touch the unit ops") |
| + } |
| +} |
| +``` |
| + |
| +- [ ] **Step 2: Run to verify they fail** |
| + |
| +Run: `GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test ./internal/appconfig/ -run TestSyncUnits -count=1` |
| +Expected: FAIL, `undefined: SyncUnits`. |
| + |
| +- [ ] **Step 3: Create `internal/appconfig/unitsync.go`** |
| + |
| +```go |
| +// The stamped half of the root config pass (spec §3.2): for a route that |
| +// names its unit, render the env file, make the unit read it through a |
| +// marker-owned drop-in, and restart the unit through a pending marker that |
| +// survives any failure between the two. Everything that touches systemd |
| +// comes in through UnitOps, so the whole protocol runs in tests against a |
| +// temp dir and a fake. |
| +package appconfig |
| + |
| +import ( |
| + "fmt" |
| + "path/filepath" |
| + "sort" |
| + |
| + "github.com/carlosframework/platform/internal/registry" |
| +) |
| + |
| +// UnitState is one unit's answer to `systemctl show -p Id,LoadState, |
| +// EnvironmentFiles`. EnvFiles are paths only, in order, with systemd's |
| +// "(ignore_errors=yes)" annotation already stripped by the prober. |
| +type UnitState struct { |
| + ID string |
| + LoadState string |
| + EnvFiles []string |
| +} |
| + |
| +// UnitOps is the root pass's seam onto systemd and the filesystem it may |
| +// not touch in tests. A nil *UnitOps means "this pass has no unit ops": |
| +// stamped routes are skipped with a log line, carlos-app@ routes are |
| +// delivered exactly as before. |
| +type UnitOps struct { |
| + // DropInRoot is /etc/systemd/system in production. |
| + DropInRoot string |
| + // Lock serialises config passes across processes (spec §3.2, codex |
| + // R2-3). An error means another pass holds it: do nothing this pass. |
| + Lock func() (unlock func(), err error) |
| + // Probe answers Id, LoadState and EnvironmentFiles for every unit in |
| + // one systemctl invocation. A missing unit answers LoadState |
| + // "not-found". An error means the probe itself failed and nothing |
| + // about any unit is known this pass. |
| + Probe func(units []string) (map[string]UnitState, error) |
| + // Reload is `systemctl daemon-reload`. |
| + Reload func() error |
| + // Restart is `systemctl restart <unit>` — restart, not try-restart: |
| + // a marked unit owes a restart even when it is inactive. |
| + Restart func(unit string) error |
| +} |
| + |
| +// stampedPlan is what the pass decided about the stamped routes on this |
| +// box before touching anything: which unit each delivered host maps to, |
| +// and why each refused host was refused. |
| +type stampedPlan struct { |
| + // unitOf maps a delivered host to its (canonical) unit. |
| + unitOf map[string]string |
| + // refused maps a host to its reason; printed per route in the loop. |
| + refused map[string]string |
| + // probe is the pre-mutation probe answer for every delivered unit. |
| + probe map[string]UnitState |
| +} |
| + |
| +// planStamped applies the rail (spec §3.2) to every stamped route in |
| +// routes, in this order: shape, unit name, hostname, one-route-per-unit, |
| +// then the batched systemd probe (loaded, and the stamp is the canonical |
| +// name). Refusals are collected, never printed here: the route loop prints |
| +// them beside the route so a log reads in one place. |
| +func planStamped(routes []registry.Route, ops *UnitOps) stampedPlan { |
| + p := stampedPlan{unitOf: map[string]string{}, refused: map[string]string{}, probe: map[string]UnitState{}} |
| + byUnit := map[string][]string{} |
| + for _, rt := range routes { |
| + if !Stamped(rt) { |
| + continue |
| + } |
| + if err := StampShape(rt); err != nil { |
| + p.refused[rt.Host] = err.Error() |
| + continue |
| + } |
| + if err := DeliverableUnitName(rt.Unit); err != nil { |
| + p.refused[rt.Host] = err.Error() |
| + continue |
| + } |
| + if !BareHostname(rt.Host) { |
| + p.refused[rt.Host] = fmt.Sprintf("%q is not a bare hostname; refusing to write it into a systemd drop-in", rt.Host) |
| + continue |
| + } |
| + byUnit[rt.Unit] = append(byUnit[rt.Unit], rt.Host) |
| + } |
| + var units []string |
| + for unit, hosts := range byUnit { |
| + if len(hosts) > 1 { |
| + sort.Strings(hosts) |
| + for _, h := range hosts { |
| + p.refused[h] = fmt.Sprintf("two or more routes name %s (%v); one unit takes one route's config", unit, hosts) |
| + } |
| + continue |
| + } |
| + units = append(units, unit) |
| + } |
| + sort.Strings(units) |
| + if len(units) == 0 { |
| + return p |
| + } |
| + states, err := ops.Probe(units) |
| + if err != nil { |
| + for _, unit := range units { |
| + p.refused[byUnit[unit][0]] = fmt.Sprintf("systemd probe failed (%v); nothing is known about %s this pass", err, unit) |
| + } |
| + return p |
| + } |
| + for _, unit := range units { |
| + host := byUnit[unit][0] |
| + st, ok := states[unit] |
| + switch { |
| + case !ok || st.LoadState != "loaded": |
| + p.refused[host] = fmt.Sprintf("%s is not loaded by systemd (%s); install the unit and the route converges on the next pass", unit, st.LoadState) |
| + case st.ID != unit: |
| + p.refused[host] = fmt.Sprintf("%s is an alias of %s; stamp the canonical unit name", unit, st.ID) |
| + default: |
| + p.unitOf[host] = unit |
| + p.probe[unit] = st |
| + } |
| + } |
| + return p |
| +} |
| + |
| +// unitWork is what one delivered unit's preparation left behind, for the |
| +// reload/restart half to act on. |
| +type unitWork struct { |
| + host string |
| + // ok: both files converged this pass (changed or unchanged). False |
| + // means a refusal or write failure was printed and this unit may be |
| + // reloaded but must not be restarted this pass. |
| + ok bool |
| +} |
| + |
| +// prepareStamped converges one stamped route's env file and drop-in |
| +// (spec §3.2 step 1): compare both first, write the pending marker before |
| +// any mutation, then the env file, then the drop-in. Returns ok as |
| +// unitWork.ok describes. |
| +func prepareStamped(rt registry.Route, unit string, m *Manifest, defaultMissing bool, priv *ecdh.PrivateKey, envDir string, ops *UnitOps) unitWork { |
| + w := unitWork{host: rt.Host} |
| + path, rendered, cur, existed, ok := prepareHost(rt, m, defaultMissing, priv, envDir) |
| + if !ok { |
| + return w |
| + } |
| + if err := ensureDropInDir(ops.DropInRoot, unit); err != nil { |
| + fmt.Printf(" %s: drop-in directory for %s: %v\n", rt.Host, unit, err) |
| + return w |
| + } |
| + curDrop, dropExists, marked, err := readDropIn(ops.DropInRoot, unit) |
| + if err != nil { |
| + fmt.Printf(" %s: read drop-in for %s: %v\n", rt.Host, unit, err) |
| + return w |
| + } |
| + if dropExists && !marked { |
| + fmt.Printf(" %s: %s holds a %s that is %v; delivery is blocked until it is moved\n", |
| + rt.Host, dropInDir(ops.DropInRoot, unit), DropInFile, errNotOurs) |
| + return w |
| + } |
| + wantDrop := renderDropIn(envDir, rt.Host) |
| + envChanged := !(existed && bytes.Equal(cur, rendered)) |
| + dropChanged := !(dropExists && bytes.Equal(curDrop, wantDrop)) |
| + if !envChanged && !dropChanged { |
| + w.ok = true |
| + return w |
| + } |
| + if err := writePending(ops.DropInRoot, unit); err != nil { |
| + fmt.Printf(" %s: record pending restart for %s: %v (nothing written)\n", rt.Host, unit, err) |
| + return w |
| + } |
| + if envChanged && !commitHost(rt.Host, path, rendered) { |
| + return w |
| + } |
| + if dropChanged { |
| + if err := writeDropIn(ops.DropInRoot, unit, wantDrop); err != nil { |
| + fmt.Printf(" %s: write drop-in for %s: %v\n", rt.Host, unit, err) |
| + return w |
| + } |
| + fmt.Printf(" %s: drop-in written for %s\n", rt.Host, unit) |
| + } |
| + if envChanged { |
| + fmt.Printf(" %s: config updated for %s\n", rt.Host, unit) |
| + } |
| + w.ok = true |
| + return w |
| +} |
| +``` |
| + |
| +Add `"bytes"`, `"crypto/ecdh"` to the imports. |
| + |
| +- [ ] **Step 4: Rework `Sync` into `SyncUnits`** |
| + |
| +In `internal/appconfig/sync.go`: |
| + |
| +1. Rename the existing `func Sync(...)` to `func SyncUnits(ctx context.Context, reg *registry.Registry, s release.Store, priv *ecdh.PrivateKey, envDir string, restart func(host string) error, ops *UnitOps) error` and add above it: |
| + |
| +```go |
| +// Sync is SyncUnits with no unit ops: the carlos-app@ half only. Kept as |
| +// the name every existing test and the docs use; production (RunAdopt) |
| +// calls SyncUnits with the real ops. |
| +func Sync(ctx context.Context, reg *registry.Registry, s release.Store, priv *ecdh.PrivateKey, envDir string, restart func(host string) error) error { |
| + return SyncUnits(ctx, reg, s, priv, envDir, restart, nil) |
| +} |
| +``` |
| + |
| +2. At the top of `SyncUnits`, after `routes, err := reg.List()`: |
| + |
| +```go |
| + if ops != nil && ops.Lock != nil { |
| + unlock, err := ops.Lock() |
| + if err != nil { |
| + fmt.Printf(" config sync: another pass holds the lock (%v); nothing touched this pass\n", err) |
| + return nil |
| + } |
| + defer unlock() |
| + } |
| + var plan stampedPlan |
| + if ops != nil { |
| + plan = planStamped(routes, ops) |
| + } |
| + work := map[string]unitWork{} // by unit |
| +``` |
| + |
| +3. In both route loops, replace `if !syncable(rt) {` with `if !unitDelivered(rt) {`. |
| + |
| +4. In the second loop, replace the block from `// Only unit-backed routes read /etc/carlos/apps` through `syncHost(rt, m, r.defaultMissing, priv, envDir, restart)` with: |
| + |
| +```go |
| + if Stamped(rt) { |
| + // A stamped route is delivered here, by unit, never through the |
| + // host-keyed restart callback (spec §3.2, codex R1-12). |
| + if ops == nil { |
| + fmt.Printf(" %s: stamped %s — no unit ops on this pass, skipped\n", rt.Host, rt.Unit) |
| + continue |
| + } |
| + if reason, bad := plan.refused[rt.Host]; bad { |
| + fmt.Printf(" %s: delivery blocked: %s\n", rt.Host, reason) |
| + continue |
| + } |
| + unit := plan.unitOf[rt.Host] |
| + work[unit] = prepareStamped(rt, unit, m, r.defaultMissing, priv, envDir, ops) |
| + continue |
| + } |
| + // Only unit-backed routes read /etc/carlos/apps — an exec-backed |
| + // route's env comes from <DataDir>/<host>.env, which the activator |
| + // owns (it also carries CARLOS_ADMIN_TOKEN, so it can't be blindly |
| + // overwritten here). Materializing for one would write a file |
| + // nothing reads and log a restart that restarted nothing; say so |
| + // instead of succeeding silently. Spec §5 records this limitation. |
| + // Delivery still happens for these routes — SyncExec, called from |
| + // the agent's in-process configSyncLoop, not this pass. |
| + if rt.Backing != registry.BackingUnit { |
| + fmt.Printf(" %s: exec-backed — config delivered by the agent's in-process loop to /data/<host>.config.env, not this pass\n", rt.Host) |
| + continue |
| + } |
| + syncHost(rt, m, r.defaultMissing, priv, envDir, restart) |
| +``` |
| + |
| +Note the unresolved-resolution branch above this (`r.err != nil`) already prints and `continue`s for stamped routes too, and a stamped route whose app was never configured hits `if m == nil { continue }` before this block, so it gets no drop-in (spec §4). |
| + |
| +5. Keep `_ = work` at the end of the loop for now (Task 5 consumes it), so the build is clean. |
| + |
| +- [ ] **Step 5: Run the tests** |
| + |
| +Run: `GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test ./internal/appconfig/ -count=1 && go vet ./internal/appconfig/ && gofmt -l internal/appconfig/` |
| +Expected: PASS for all, including every pre-existing `Sync` test. |
| + |
| +- [ ] **Step 6: Commit** |
| + |
| +```bash |
| +git add internal/appconfig/unitsync.go internal/appconfig/unitsync_test.go internal/appconfig/sync.go |
| +git commit -m "appconfig: SyncUnits prepares stamped routes behind a lock and the root-write rail |
| + |
| +Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>" |
| +``` |
| + |
| +--- |
| + |
| +### Task 5: Reload, verify, restart, and drift |
| + |
| +**Files:** |
| +- Modify: `internal/appconfig/unitsync.go` (add `applyStamped`) |
| +- Modify: `internal/appconfig/sync.go` (call `applyStamped` after the route loop, before env reaping) |
| +- Test: `internal/appconfig/unitsync_test.go` |
| + |
| +**Interfaces:** |
| +- Produces: `func applyStamped(envDir string, ops *UnitOps, plan stampedPlan, work map[string]unitWork)`; `func envFilesMatch(got []string, want string) bool`. |
| +- Consumes: Task 4's `stampedPlan`, `unitWork`, Task 3's marker helpers. |
| + |
| +- [ ] **Step 1: Write the failing tests** |
| + |
| +Append to `internal/appconfig/unitsync_test.go`: |
| + |
| +```go |
| +// syncOnce runs one pass and returns its output. |
| +func syncOnce(t *testing.T, ctx context.Context, reg *registry.Registry, s release.Store, priv *ecdh.PrivateKey, envDir string, f *fakeUnits) string { |
| + t.Helper() |
| + return captureStdout(t, func() { |
| + if err := SyncUnits(ctx, reg, s, priv, envDir, noHostRestart(t), f.ops()); err != nil { |
| + t.Fatalf("SyncUnits: %v", err) |
| + } |
| + }) |
| +} |
| + |
| +func TestSyncUnitsReloadsVerifiesRestartsAndClears(t *testing.T) { |
| + ctx, s, reg, envDir, priv := unitKit(t) |
| + f := newFakeUnits(t) |
| + f.loaded("relay.service", envDir, "relay.example.com") |
| + upsertStamped(t, reg, "relay.example.com", "relay.service", nil) |
| + |
| + syncOnce(t, ctx, reg, s, priv, envDir, f) |
| + if f.reloads != 1 { |
| + t.Fatalf("reloads = %d, want 1", f.reloads) |
| + } |
| + if len(f.restarted) != 1 || f.restarted[0] != "relay.service" { |
| + t.Fatalf("restarted = %v, want [relay.service]", f.restarted) |
| + } |
| + if hasPending(f.root, "relay.service") { |
| + t.Fatal("marker must be cleared after a successful restart") |
| + } |
| + // Post-reload verification probes the marked unit again. |
| + if len(f.probed) != 2 { |
| + t.Fatalf("probed = %v, want the rail probe and the post-reload probe", f.probed) |
| + } |
| + |
| + // Steady state: nothing happens. |
| + f.reloads, f.restarted = 0, nil |
| + syncOnce(t, ctx, reg, s, priv, envDir, f) |
| + if f.reloads != 0 || len(f.restarted) != 0 { |
| + t.Fatalf("second pass: reloads=%d restarted=%v, want none", f.reloads, f.restarted) |
| + } |
| + |
| + // A config change marks and restarts again. |
| + if err := SetEnv(ctx, s, "bab", "relay", map[string]string{"LOG_LEVEL": "info"}); err != nil { |
| + t.Fatal(err) |
| + } |
| + syncOnce(t, ctx, reg, s, priv, envDir, f) |
| + if f.reloads != 1 || len(f.restarted) != 1 { |
| + t.Fatalf("after change: reloads=%d restarted=%v", f.reloads, f.restarted) |
| + } |
| +} |
| + |
| +func TestSyncUnitsReloadFailureKeepsMarkerAndRetries(t *testing.T) { |
| + ctx, s, reg, envDir, priv := unitKit(t) |
| + f := newFakeUnits(t) |
| + f.loaded("relay.service", envDir, "relay.example.com") |
| + upsertStamped(t, reg, "relay.example.com", "relay.service", nil) |
| + f.reloadErr = errors.New("busy") |
| + syncOnce(t, ctx, reg, s, priv, envDir, f) |
| + if len(f.restarted) != 0 || !hasPending(f.root, "relay.service") { |
| + t.Fatalf("reload failure: restarted=%v pending=%v", f.restarted, hasPending(f.root, "relay.service")) |
| + } |
| + f.reloadErr = nil |
| + syncOnce(t, ctx, reg, s, priv, envDir, f) |
| + if len(f.restarted) != 1 || hasPending(f.root, "relay.service") { |
| + t.Fatalf("retry with identical bytes: restarted=%v pending=%v", f.restarted, hasPending(f.root, "relay.service")) |
| + } |
| +} |
| + |
| +func TestSyncUnitsRestartFailureKeepsMarkerAndRetries(t *testing.T) { |
| + ctx, s, reg, envDir, priv := unitKit(t) |
| + f := newFakeUnits(t) |
| + f.loaded("relay.service", envDir, "relay.example.com") |
| + upsertStamped(t, reg, "relay.example.com", "relay.service", nil) |
| + f.restartErr["relay.service"] = errors.New("exit 1") |
| + syncOnce(t, ctx, reg, s, priv, envDir, f) |
| + if !hasPending(f.root, "relay.service") { |
| + t.Fatal("restart failure must keep the marker") |
| + } |
| + delete(f.restartErr, "relay.service") |
| + f.restarted = nil |
| + syncOnce(t, ctx, reg, s, priv, envDir, f) |
| + if len(f.restarted) != 1 || hasPending(f.root, "relay.service") { |
| + t.Fatalf("retry: restarted=%v pending=%v", f.restarted, hasPending(f.root, "relay.service")) |
| + } |
| +} |
| + |
| +func TestSyncUnitsVerificationMismatchBlocks(t *testing.T) { |
| + ctx, s, reg, envDir, priv := unitKit(t) |
| + f := newFakeUnits(t) |
| + // Loaded, but the effective list has a legacy file first: a later |
| + // drop-in re-added it. "Ends with carlos's file" is not enough. |
| + f.states["relay.service"] = UnitState{ID: "relay.service", LoadState: "loaded", |
| + EnvFiles: []string{"/data/relay.example.com.config.env", filepath.Join(envDir, "relay.example.com.env")}} |
| + upsertStamped(t, reg, "relay.example.com", "relay.service", nil) |
| + out := syncOnce(t, ctx, reg, s, priv, envDir, f) |
| + if len(f.restarted) != 0 || !hasPending(f.root, "relay.service") { |
| + t.Fatalf("mismatch: restarted=%v pending=%v", f.restarted, hasPending(f.root, "relay.service")) |
| + } |
| + if !strings.Contains(out, "blocked") || !strings.Contains(out, "/data/relay.example.com.config.env") { |
| + t.Fatalf("expected a blocked line naming the actual list, got %q", out) |
| + } |
| +} |
| + |
| +func TestSyncUnitsDriftIsReportedEveryPassAndNeverRestarted(t *testing.T) { |
| + ctx, s, reg, envDir, priv := unitKit(t) |
| + f := newFakeUnits(t) |
| + f.loaded("relay.service", envDir, "relay.example.com") |
| + upsertStamped(t, reg, "relay.example.com", "relay.service", nil) |
| + syncOnce(t, ctx, reg, s, priv, envDir, f) // delivered, cleared |
| + f.restarted, f.reloads = nil, 0 |
| + // An operator's later drop-in took the list back. |
| + f.states["relay.service"] = UnitState{ID: "relay.service", LoadState: "loaded", EnvFiles: []string{"/etc/other.env"}} |
| + out := syncOnce(t, ctx, reg, s, priv, envDir, f) |
| + if len(f.restarted) != 0 || f.reloads != 0 { |
| + t.Fatalf("drift must not restart or reload: restarted=%v reloads=%d", f.restarted, f.reloads) |
| + } |
| + if !strings.Contains(out, "blocked") || !strings.Contains(out, "/etc/other.env") { |
| + t.Fatalf("expected a drift line naming the actual list, got %q", out) |
| + } |
| + if hasPending(f.root, "relay.service") { |
| + t.Fatal("drift writes no marker") |
| + } |
| +} |
| + |
| +func TestSyncUnitsRefusedRouteKeepsAnEarlierMarker(t *testing.T) { |
| + ctx, s, reg, envDir, priv := unitKit(t) |
| + f := newFakeUnits(t) |
| + f.loaded("relay.service", envDir, "relay.example.com") |
| + upsertStamped(t, reg, "relay.example.com", "relay.service", nil) |
| + f.reloadErr = errors.New("busy") |
| + syncOnce(t, ctx, reg, s, priv, envDir, f) // marker left behind |
| + f.reloadErr = nil |
| + // Now the route is refused (a second route names the unit). |
| + upsertStamped(t, reg, "b.example.com", "relay.service", nil) |
| + syncOnce(t, ctx, reg, s, priv, envDir, f) |
| + if len(f.restarted) != 0 || !hasPending(f.root, "relay.service") { |
| + t.Fatalf("refused route: restarted=%v pending=%v, want no restart and the marker kept", f.restarted, hasPending(f.root, "relay.service")) |
| + } |
| +} |
| + |
| +func TestEnvFilesMatch(t *testing.T) { |
| + want := "/etc/carlos/apps/h.env" |
| + if !envFilesMatch([]string{want}, want) { |
| + t.Fatal("exact single match must pass") |
| + } |
| + for _, bad := range [][]string{nil, {}, {"/x", want}, {want, "/x"}, {"/x"}} { |
| + if envFilesMatch(bad, want) { |
| + t.Fatalf("%v must not match", bad) |
| + } |
| + } |
| +} |
| +``` |
| + |
| +- [ ] **Step 2: Run to verify they fail** |
| + |
| +Run: `GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test ./internal/appconfig/ -run 'TestSyncUnits|TestEnvFilesMatch' -count=1` |
| +Expected: FAIL (`undefined: envFilesMatch`, and the reload/restart tests fail on counts). |
| + |
| +- [ ] **Step 3: Add `applyStamped` to `internal/appconfig/unitsync.go`** |
| + |
| +```go |
| +// envFilesMatch: the unit's effective EnvironmentFile list must be EXACTLY |
| +// carlos's one file (spec §3.2 step 4, codex R3-3). Presence or last |
| +// position is not enough: a later drop-in that resets the list and re-adds |
| +// a legacy file before carlos's would resurrect an unset key. |
| +func envFilesMatch(got []string, want string) bool { |
| + return len(got) == 1 && got[0] == want |
| +} |
| + |
| +// applyStamped is spec §3.2 steps 3 to 7: one reload if anything is |
| +// pending, then per marked unit verify and restart, then drift for the |
| +// rest. Order matters: reload before any verification, verification |
| +// before any restart, and a unit that failed or was refused this pass is |
| +// never restarted however old its marker. |
| +func applyStamped(envDir string, ops *UnitOps, plan stampedPlan, work map[string]unitWork) { |
| + pending := listPending(ops.DropInRoot) |
| + if len(pending) > 0 { |
| + if err := ops.Reload(); err != nil { |
| + for _, unit := range pending { |
| + fmt.Printf(" %s: daemon-reload failed (%v); reload and restart still owed\n", unit, err) |
| + } |
| + return |
| + } |
| + } |
| + marked := map[string]bool{} |
| + for _, unit := range pending { |
| + marked[unit] = true |
| + w, prepared := work[unit] |
| + _, exists, isMarked, _ := readDropIn(ops.DropInRoot, unit) |
| + switch { |
| + case prepared && !w.ok: |
| + // Prepared this pass and something failed (env write, drop-in |
| + // write): the marker is intent that has not been met. Keep it |
| + // whether or not a drop-in exists yet (codex R3-1). |
| + fmt.Printf(" %s: restart still owed, but delivery to %s failed this pass; retrying next pass\n", unit, w.host) |
| + continue |
| + case !exists || !isMarked: |
| + // Reaped (or never ours): the reload it owed has happened; it |
| + // is not owned and is never restarted from here. |
| + if err := removePending(ops.DropInRoot, unit); err != nil { |
| + fmt.Printf(" %s: clear pending marker: %v\n", unit, err) |
| + } |
| + continue |
| + case !prepared: |
| + fmt.Printf(" %s: restart still owed, but its route was refused this pass; retrying next pass\n", unit) |
| + continue |
| + } |
| + states, err := ops.Probe([]string{unit}) |
| + if err != nil { |
| + fmt.Printf(" %s: post-reload probe failed (%v); restart still owed\n", unit, err) |
| + continue |
| + } |
| + want := filepath.Join(envDir, w.host+".env") |
| + if !envFilesMatch(states[unit].EnvFiles, want) { |
| + fmt.Printf(" %s: delivery blocked: %s reads %v after reload, want exactly [%s]; a later drop-in owns the EnvironmentFile list\n", |
| + w.host, unit, states[unit].EnvFiles, want) |
| + continue |
| + } |
| + if err := ops.Restart(unit); err != nil { |
| + fmt.Printf(" %s: config updated but restart of %s failed: %v; restart still owed\n", w.host, unit, err) |
| + continue |
| + } |
| + if err := removePending(ops.DropInRoot, unit); err != nil { |
| + fmt.Printf(" %s: clear pending marker for %s: %v\n", w.host, unit, err) |
| + continue |
| + } |
| + fmt.Printf(" %s: config updated, %s restarted\n", w.host, unit) |
| + } |
| + // Drift (step 7): an unmarked, delivered unit whose pre-mutation probe |
| + // shows anything but carlos's file is running what the operator |
| + // configured. Say so every pass; never fight it. |
| + units := make([]string, 0, len(work)) |
| + for unit := range work { |
| + units = append(units, unit) |
| + } |
| + sort.Strings(units) |
| + for _, unit := range units { |
| + w := work[unit] |
| + if marked[unit] || !w.ok { |
| + continue |
| + } |
| + want := filepath.Join(envDir, w.host+".env") |
| + if st, ok := plan.probe[unit]; ok && !envFilesMatch(st.EnvFiles, want) { |
| + fmt.Printf(" %s: delivery blocked: %s reads %v, want exactly [%s]; a later drop-in owns the EnvironmentFile list\n", |
| + w.host, unit, st.EnvFiles, want) |
| + } |
| + } |
| +} |
| +``` |
| + |
| +- [ ] **Step 4: Call it from `SyncUnits`** |
| + |
| +In `internal/appconfig/sync.go`, replace `_ = work` with, placed AFTER the route loop and BEFORE the `if len(routes) == 0 { return nil }` guard: |
| + |
| +```go |
| + if ops != nil { |
| + applyStamped(envDir, ops, plan, work) |
| + } |
| +``` |
| + |
| +(Task 6 inserts the drop-in reaper between the route loop and this call.) |
| + |
| +- [ ] **Step 5: Run the tests** |
| + |
| +Run: `GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test ./internal/appconfig/ -count=1 && go vet ./internal/appconfig/ && gofmt -l internal/appconfig/` |
| +Expected: PASS. |
| + |
| +- [ ] **Step 6: Commit** |
| + |
| +```bash |
| +git add internal/appconfig/unitsync.go internal/appconfig/unitsync_test.go internal/appconfig/sync.go |
| +git commit -m "appconfig: reload, verify exactly one env file, restart marked units, report drift |
| + |
| +Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>" |
| +``` |
| + |
| +--- |
| + |
| +### Task 6: Reaping drop-ins with the reload owed recorded first |
| + |
| +**Files:** |
| +- Modify: `internal/appconfig/unitsync.go` (add `reapDropIns`) |
| +- Modify: `internal/appconfig/sync.go` (call it after the route loop, before `applyStamped`) |
| +- Test: `internal/appconfig/unitsync_test.go` |
| + |
| +**Interfaces:** |
| +- Produces: `func reapDropIns(routes []registry.Route, ops *UnitOps)`. |
| + |
| +- [ ] **Step 1: Write the failing tests** |
| + |
| +Append to `internal/appconfig/unitsync_test.go`: |
| + |
| +```go |
| +func TestSyncUnitsReapsDropInForAnUnstampedRouteWithMarkerFirst(t *testing.T) { |
| + ctx, s, reg, envDir, priv := unitKit(t) |
| + f := newFakeUnits(t) |
| + f.loaded("relay.service", envDir, "relay.example.com") |
| + upsertStamped(t, reg, "relay.example.com", "relay.service", nil) |
| + syncOnce(t, ctx, reg, s, priv, envDir, f) |
| + // Unstamp: the row stays, the unit is no longer named by any route. |
| + if err := reg.SetUnit("relay.example.com", ""); err != nil { |
| + t.Fatal(err) |
| + } |
| + f.reloads, f.restarted = 0, nil |
| + f.reloadErr = errors.New("busy") |
| + syncOnce(t, ctx, reg, s, priv, envDir, f) |
| + if _, exists, _, _ := readDropIn(f.root, "relay.service"); exists { |
| + t.Fatal("drop-in must be removed for a unit no route names") |
| + } |
| + if !hasPending(f.root, "relay.service") { |
| + t.Fatal("the reload owed must be recorded when the reload fails") |
| + } |
| + if len(f.restarted) != 0 { |
| + t.Fatal("an unowned unit is never restarted") |
| + } |
| + f.reloadErr = nil |
| + syncOnce(t, ctx, reg, s, priv, envDir, f) |
| + if hasPending(f.root, "relay.service") || len(f.restarted) != 0 { |
| + t.Fatalf("after a good reload: pending=%v restarted=%v", hasPending(f.root, "relay.service"), f.restarted) |
| + } |
| + if _, err := os.Stat(dropInDir(f.root, "relay.service")); err != nil { |
| + t.Fatal("the directory is never removed") |
| + } |
| + // The env file stays while the row exists (today's reap policy). |
| + if _, err := os.Stat(filepath.Join(envDir, "relay.example.com.env")); err != nil { |
| + t.Fatal("env file must stay while the route row exists") |
| + } |
| +} |
| + |
| +func TestSyncUnitsReaperNeverRemovesUnmarkedOrSymlinked(t *testing.T) { |
| + ctx, s, reg, envDir, priv := unitKit(t) |
| + f := newFakeUnits(t) |
| + os.MkdirAll(dropInDir(f.root, "foreign.service"), 0o755) |
| + foreign := []byte("[Service]\nEnvironmentFile=/x\n") |
| + os.WriteFile(filepath.Join(dropInDir(f.root, "foreign.service"), DropInFile), foreign, 0o644) |
| + elsewhere := t.TempDir() |
| + os.WriteFile(filepath.Join(elsewhere, DropInFile), renderDropIn(envDir, "h"), 0o644) |
| + os.Symlink(elsewhere, dropInDir(f.root, "linked.service")) |
| + // One unrelated carlos-app@ route so the route list is not empty. |
| + if err := reg.Upsert(registry.Route{Host: "km.example.com", Kind: "instance", App: "relay", Account: "bab", |
| + Status: "ready", Backing: registry.BackingUnit}); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := SyncUnits(ctx, reg, s, priv, envDir, func(string) error { return nil }, f.ops()); err != nil { |
| + t.Fatal(err) |
| + } |
| + if cur, _, _, _ := readDropIn(f.root, "foreign.service"); string(cur) != string(foreign) { |
| + t.Fatal("unmarked file touched") |
| + } |
| + if _, err := os.Stat(filepath.Join(elsewhere, DropInFile)); err != nil { |
| + t.Fatal("file behind a symlinked directory removed") |
| + } |
| + if f.reloads != 0 { |
| + t.Fatal("nothing carlos owned changed; no reload") |
| + } |
| +} |
| + |
| +func TestSyncUnitsEmptyRegistryReapsNoDropIns(t *testing.T) { |
| + ctx, s, reg, envDir, priv := unitKit(t) |
| + f := newFakeUnits(t) |
| + ensureDropInDir(f.root, "relay.service") |
| + writeDropIn(f.root, "relay.service", renderDropIn(envDir, "relay.example.com")) |
| + if err := SyncUnits(ctx, reg, s, priv, envDir, func(string) error { return nil }, f.ops()); err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, exists, _, _ := readDropIn(f.root, "relay.service"); !exists { |
| + t.Fatal("an empty route list is never authority to delete") |
| + } |
| +} |
| + |
| +func TestSyncUnitsReStampMovesTheDropIn(t *testing.T) { |
| + ctx, s, reg, envDir, priv := unitKit(t) |
| + f := newFakeUnits(t) |
| + f.loaded("a.service", envDir, "relay.example.com") |
| + f.loaded("b.service", envDir, "relay.example.com") |
| + upsertStamped(t, reg, "relay.example.com", "a.service", nil) |
| + syncOnce(t, ctx, reg, s, priv, envDir, f) |
| + if err := reg.SetUnit("relay.example.com", "b.service"); err != nil { |
| + t.Fatal(err) |
| + } |
| + f.restarted = nil |
| + syncOnce(t, ctx, reg, s, priv, envDir, f) |
| + if _, exists, _, _ := readDropIn(f.root, "a.service"); exists { |
| + t.Fatal("old unit's drop-in must be reaped") |
| + } |
| + if _, exists, marked, _ := readDropIn(f.root, "b.service"); !exists || !marked { |
| + t.Fatal("new unit's drop-in must be written") |
| + } |
| + if len(f.restarted) != 1 || f.restarted[0] != "b.service" { |
| + t.Fatalf("restarted = %v, want [b.service] only", f.restarted) |
| + } |
| + if hasPending(f.root, "a.service") || hasPending(f.root, "b.service") { |
| + t.Fatal("both markers must be cleared after one good reload and restart") |
| + } |
| +} |
| +``` |
| + |
| +- [ ] **Step 2: Run to verify they fail** |
| + |
| +Run: `GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test ./internal/appconfig/ -run 'TestSyncUnitsReap|TestSyncUnitsEmptyRegistry|TestSyncUnitsReStamp' -count=1` |
| +Expected: FAIL (drop-ins are not reaped yet). |
| + |
| +- [ ] **Step 3: Add `reapDropIns` to `internal/appconfig/unitsync.go`** |
| + |
| +```go |
| +// reapDropIns (spec §3.2 reaping, step 2): for every marker-bearing drop-in |
| +// whose unit is named by no route row on the box, record the reload it |
| +// owes FIRST, then remove the drop-in (codex R3-2). Keyed off the full |
| +// route list, never the delivered subset; an empty list is never authority |
| +// to delete anything (the same rule as reap for env files). Unmarked files |
| +// and symlinked directories are never touched. |
| +func reapDropIns(routes []registry.Route, ops *UnitOps) { |
| + if len(routes) == 0 { |
| + return |
| + } |
| + named := map[string]bool{} |
| + for _, rt := range routes { |
| + if rt.Unit != "" { |
| + named[rt.Unit] = true |
| + } |
| + } |
| + units, skipped := listMarkedDropIns(ops.DropInRoot) |
| + for _, unit := range skipped { |
| + fmt.Printf(" %s: %s is not a plain directory; not entered\n", unit, dropInDir(ops.DropInRoot, unit)) |
| + } |
| + for _, unit := range units { |
| + if named[unit] { |
| + continue |
| + } |
| + if err := writePending(ops.DropInRoot, unit); err != nil { |
| + fmt.Printf(" %s: record reload owed: %v (drop-in kept)\n", unit, err) |
| + continue |
| + } |
| + if err := removeDropIn(ops.DropInRoot, unit); err != nil { |
| + fmt.Printf(" %s: remove drop-in: %v\n", unit, err) |
| + continue |
| + } |
| + fmt.Printf(" %s: no route names this unit, removed its drop-in; reload owed\n", unit) |
| + } |
| +} |
| +``` |
| + |
| +- [ ] **Step 4: Call it from `SyncUnits`** |
| + |
| +In `internal/appconfig/sync.go`, change the block added in Task 5 to: |
| + |
| +```go |
| + if ops != nil { |
| + reapDropIns(routes, ops) |
| + applyStamped(envDir, ops, plan, work) |
| + } |
| +``` |
| + |
| +- [ ] **Step 5: Run the tests** |
| + |
| +Run: `GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test ./internal/appconfig/ -count=1 && go vet ./internal/appconfig/ && gofmt -l internal/appconfig/` |
| +Expected: PASS. |
| + |
| +- [ ] **Step 6: Commit** |
| + |
| +```bash |
| +git add internal/appconfig/unitsync.go internal/appconfig/unitsync_test.go internal/appconfig/sync.go |
| +git commit -m "appconfig: reap drop-ins for units no route names, recording the reload owed first |
| + |
| +Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>" |
| +``` |
| + |
| +--- |
| + |
| +### Task 7: The real `UnitOps` in `internal/host` and the RunAdopt wiring |
| + |
| +**Files:** |
| +- Create: `internal/host/unitops.go` |
| +- Create: `internal/host/unitops_test.go` |
| +- Modify: `internal/host/agent.go:2895-2955` (RunAdopt: build ops, call `appconfig.SyncUnits`, rewrite the comment at 2899-2907) |
| + |
| +**Interfaces:** |
| +- Produces: `func productionUnitOps(envDir string) *appconfig.UnitOps`; `func parseSystemctlShow(out []byte, units []string) (map[string]appconfig.UnitState, error)`; `func lockConfigSync(path string) (func(), error)`. |
| +- Consumes: `appconfig.UnitOps`, `appconfig.UnitState`, `appconfig.SyncUnits`, `lockFileEx`/`unlockFile` (existing, per-OS). |
| + |
| +- [ ] **Step 1: Write the failing parser test** |
| + |
| +Create `internal/host/unitops_test.go`: |
| + |
| +```go |
| +package host |
| + |
| +import ( |
| + "strings" |
| + "testing" |
| +) |
| + |
| +func TestParseSystemctlShowBatched(t *testing.T) { |
| + out := []byte("Id=relay.service\nLoadState=loaded\nEnvironmentFiles=/etc/carlos/apps/relay.example.com.env (ignore_errors=yes)\n" + |
| + "\n" + |
| + "Id=real.service\nLoadState=loaded\nEnvironmentFiles=/data/x.env (ignore_errors=no)\nEnvironmentFiles=/etc/carlos/apps/h.env (ignore_errors=yes)\n" + |
| + "\n" + |
| + "Id=gone.service\nLoadState=not-found\nEnvironmentFiles=\n") |
| + got, err := parseSystemctlShow(out, []string{"relay.service", "alias.service", "gone.service"}) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if st := got["relay.service"]; st.ID != "relay.service" || st.LoadState != "loaded" || |
| + strings.Join(st.EnvFiles, ",") != "/etc/carlos/apps/relay.example.com.env" { |
| + t.Fatalf("relay: %+v", st) |
| + } |
| + // The second block was asked for as alias.service and answered as |
| + // real.service: keyed by the name asked, Id carries the canonical one. |
| + if st := got["alias.service"]; st.ID != "real.service" || strings.Join(st.EnvFiles, ",") != "/data/x.env,/etc/carlos/apps/h.env" { |
| + t.Fatalf("alias: %+v", st) |
| + } |
| + if st := got["gone.service"]; st.LoadState != "not-found" || len(st.EnvFiles) != 0 { |
| + t.Fatalf("gone: %+v", st) |
| + } |
| +} |
| + |
| +func TestParseSystemctlShowCountMismatchIsAnError(t *testing.T) { |
| + if _, err := parseSystemctlShow([]byte("Id=a.service\nLoadState=loaded\n"), []string{"a.service", "b.service"}); err == nil { |
| + t.Fatal("a short batch cannot be attributed positionally and must be an error") |
| + } |
| +} |
| + |
| +func TestParseSystemctlShowSpaceSeparatedEnvFiles(t *testing.T) { |
| + // Some systemd versions print the list on one line. |
| + out := []byte("Id=a.service\nLoadState=loaded\nEnvironmentFiles=/x.env (ignore_errors=no) /y.env (ignore_errors=yes)\n") |
| + got, err := parseSystemctlShow(out, []string{"a.service"}) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if strings.Join(got["a.service"].EnvFiles, ",") != "/x.env,/y.env" { |
| + t.Fatalf("EnvFiles = %v", got["a.service"].EnvFiles) |
| + } |
| +} |
| +``` |
| + |
| +- [ ] **Step 2: Run to verify it fails** |
| + |
| +Run: `GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test ./internal/host/ -run TestParseSystemctlShow -count=1` |
| +Expected: FAIL, `undefined: parseSystemctlShow`. |
| + |
| +- [ ] **Step 3: Create `internal/host/unitops.go`** |
| + |
| +```go |
| +// The root pass's real UnitOps (spec §3.2): systemctl for the probe, the |
| +// reload and the restart; a flock for the pass. Kept in one file, beside |
| +// the batched-probe shape adopt_advice.go already uses, so what the pass |
| +// runs on a box is readable in one place. |
| +package host |
| + |
| +import ( |
| + "bytes" |
| + "fmt" |
| + "log" |
| + "os" |
| + "os/exec" |
| + "strings" |
| + |
| + "github.com/carlosframework/platform/internal/appconfig" |
| +) |
| + |
| +// dropInRoot is where systemd looks for a unit's drop-ins whichever tree |
| +// the unit file itself lives in. |
| +const dropInRoot = "/etc/systemd/system" |
| + |
| +func productionUnitOps(envDir string) *appconfig.UnitOps { |
| + return &appconfig.UnitOps{ |
| + DropInRoot: dropInRoot, |
| + Lock: func() (func(), error) { return lockConfigSync(envDir + "/.sync") }, |
| + Probe: probeUnits, |
| + Reload: daemonReload, |
| + Restart: systemctlRestartUnit, |
| + } |
| +} |
| + |
| +// lockConfigSync is lockRestartMarks's shape for the config pass: an |
| +// exclusive flock on <path>.lock held for the whole of one SyncUnits, so |
| +// carlos-adopt.service and carlos-config-sync.service queue rather than |
| +// interleave inside a config transaction (spec §3.2, codex R2-3). |
| +func lockConfigSync(path string) (unlock func(), err error) { |
| + f, err := os.OpenFile(path+".lock", os.O_CREATE|os.O_RDWR, 0o644) |
| + if err != nil { |
| + return nil, err |
| + } |
| + if err := lockFileEx(f); err != nil { |
| + f.Close() |
| + return nil, err |
| + } |
| + return func() { unlockFile(f); f.Close() }, nil |
| +} |
| + |
| +// probeUnits: ONE `systemctl show` for every unit, positional like |
| +// unitsAreActive. The three properties are the whole rail: LoadState says |
| +// the unit exists, Id says whether the stamp is its canonical name, and |
| +// EnvironmentFiles is the effective list the drift check and the |
| +// post-reload verification compare against. |
| +func probeUnits(units []string) (map[string]appconfig.UnitState, error) { |
| + args := append([]string{"show", "-p", "Id,LoadState,EnvironmentFiles"}, units...) |
| + out, err := exec.Command("systemctl", args...).Output() |
| + if err != nil && len(out) == 0 { |
| + return nil, fmt.Errorf("systemctl show: %w", err) |
| + } |
| + return parseSystemctlShow(out, units) |
| +} |
| + |
| +// parseSystemctlShow splits `systemctl show` output into per-unit blocks |
| +// (blank-line separated, in the order asked) and maps them onto units |
| +// positionally. A block count that does not match cannot be attributed and |
| +// is an error, never a guess (the same rule as parseIsActiveBatch). |
| +// EnvironmentFiles may repeat one path per line or list several on one |
| +// line; each entry is "<path> (ignore_errors=yes|no)" and only the path is |
| +// kept. |
| +func parseSystemctlShow(out []byte, units []string) (map[string]appconfig.UnitState, error) { |
| + blocks := bytes.Split(bytes.TrimSpace(out), []byte("\n\n")) |
| + if len(bytes.TrimSpace(out)) == 0 { |
| + blocks = nil |
| + } |
| + if len(blocks) != len(units) { |
| + return nil, fmt.Errorf("systemctl show answered %d unit(s) for %d asked", len(blocks), len(units)) |
| + } |
| + res := make(map[string]appconfig.UnitState, len(units)) |
| + for i, unit := range units { |
| + var st appconfig.UnitState |
| + for _, line := range strings.Split(string(blocks[i]), "\n") { |
| + k, v, ok := strings.Cut(line, "=") |
| + if !ok { |
| + continue |
| + } |
| + switch k { |
| + case "Id": |
| + st.ID = v |
| + case "LoadState": |
| + st.LoadState = v |
| + case "EnvironmentFiles": |
| + for _, f := range strings.Fields(v) { |
| + if strings.HasPrefix(f, "(") { |
| + continue |
| + } |
| + st.EnvFiles = append(st.EnvFiles, f) |
| + } |
| + } |
| + } |
| + res[unit] = st |
| + } |
| + return res, nil |
| +} |
| + |
| +func daemonReload() error { |
| + out, err := exec.Command("systemctl", "daemon-reload").CombinedOutput() |
| + if err != nil { |
| + return fmt.Errorf("daemon-reload: %w: %s", err, strings.TrimSpace(string(out))) |
| + } |
| + return nil |
| +} |
| + |
| +// systemctlRestartUnit is `systemctl restart`, NOT try-restart: a marked |
| +// unit owes a restart even when a previous one left it inactive (spec |
| +// §3.2 step 4). Logs and returns like systemctlTryRestartUnit. |
| +func systemctlRestartUnit(unit string) error { |
| + out, err := exec.Command("systemctl", "restart", unit).CombinedOutput() |
| + if err != nil { |
| + log.Printf("adopt: restart %s: %v: %s", unit, err, strings.TrimSpace(string(out))) |
| + return fmt.Errorf("restart %s: %w: %s", unit, err, strings.TrimSpace(string(out))) |
| + } |
| + return nil |
| +} |
| +``` |
| + |
| +- [ ] **Step 4: Wire RunAdopt** |
| + |
| +In `internal/host/agent.go`, replace the comment block at lines 2895-2907 (from `// One callback for both halves below` to `// ONE restart dispatch on this pass, not two that could drift.`) with: |
| + |
| +```go |
| + // One callback for both halves below, built once here now that it needs |
| + // the registry — it reads each route's `unit` column to decide what to |
| + // cycle. See rootRestartRoute for why the named-unit arm has to exist on |
| + // THIS pass. |
| + // |
| + // The release pass reaches that arm for stamped routes. Config sync |
| + // does NOT: appconfig.SyncUnits restarts a stamped route's unit itself, |
| + // bound to the unit it prepared rather than re-read from the row (spec |
| + // 2026-09-07-named-unit-config-delivery-design.md §3.2), and uses this |
| + // callback only for carlos-app@ routes. Both still dispatch through |
| + // makeRestartRoute's arms for what they hand it. |
| +``` |
| + |
| +Then change the `appconfig.Sync(...)` call (line 2950) to: |
| + |
| +```go |
| + } else if err := appconfig.SyncUnits(ctx, reg, store, secretsPriv, appconfig.DefaultEnvDir, restart, productionUnitOps(appconfig.DefaultEnvDir)); err != nil { |
| +``` |
| + |
| +- [ ] **Step 5: Build, vet, cross-compile, test** |
| + |
| +Run: |
| +``` |
| +GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go build ./... && go vet ./internal/host/ && GOOS=darwin go build ./... && GOOS=windows go build ./... && go test ./internal/host/ -run 'TestParseSystemctlShow|TestMakeRestartRoute|TestAdoptPass' -count=1 && gofmt -l internal/host/ |
| +``` |
| +Expected: all clean, tests PASS. (The `lockFileEx` pair already has a Windows stub; `unitops.go` uses nothing platform-specific.) |
| + |
| +- [ ] **Step 6: Commit** |
| + |
| +```bash |
| +git add internal/host/unitops.go internal/host/unitops_test.go internal/host/agent.go |
| +git commit -m "host: real unit ops for the config pass (batched show, daemon-reload, restart, flock) |
| + |
| +Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>" |
| +``` |
| + |
| +--- |
| + |
| +### Task 8: The exec side leaves a stamped route's old file alone |
| + |
| +**Files:** |
| +- Test: `internal/appconfig/syncexec_test.go` |
| + |
| +**Interfaces:** none new. Task 1's `execRouteBase` change already excludes stamped routes; this task pins the file behaviour the spec promises (§3.3). |
| + |
| +- [ ] **Step 1: Write the test** |
| + |
| +Find an existing `SyncExec` test that opts an addr route into file delivery (grep `SetExecDelivery` in `syncexec_test.go`) and copy its setup shape. Append: |
| + |
| +```go |
| +func TestSyncExecLeavesAStampedRoutesOldFileAlone(t *testing.T) { |
| + ctx, s, reg, dataDir, priv, _ := execKit(t) |
| + privKey, err := ParsePrivateKey(priv, "test") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := SetEnv(ctx, s, "bab", "relay", map[string]string{"TOKEN": "old"}); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := SetExecDelivery(ctx, s, "bab", "relay", ExecDeliveryFile); err != nil { |
| + t.Fatal(err) |
| + } |
| + rt := registry.Route{Host: "relay.example.com", Kind: "instance", App: "relay", Account: "bab", |
| + Status: "ready", Addr: "127.0.0.1:8451"} |
| + if err := reg.Upsert(rt); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := SyncExec(ctx, reg, s, privKey, dataDir, nil, ""); err != nil { |
| + t.Fatal(err) |
| + } |
| + path := filepath.Join(dataDir, "relay.example.com.config.env") |
| + before, err := os.ReadFile(path) |
| + if err != nil { |
| + t.Fatalf("the opted-in addr route must have a file before the stamp: %v", err) |
| + } |
| + // Stamp it and change the bundle: the exec side must neither refresh |
| + // nor remove the file (spec §3.3, the ruling at syncexec.go:428). |
| + if err := reg.SetUnit("relay.example.com", "relay.service"); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := SetEnv(ctx, s, "bab", "relay", map[string]string{"TOKEN": "new"}); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := SyncExec(ctx, reg, s, privKey, dataDir, nil, ""); err != nil { |
| + t.Fatal(err) |
| + } |
| + after, err := os.ReadFile(path) |
| + if err != nil { |
| + t.Fatalf("file removed for a stamped route: %v", err) |
| + } |
| + if string(after) != string(before) { |
| + t.Fatalf("file refreshed for a stamped route: %q -> %q", before, after) |
| + } |
| +} |
| +``` |
| + |
| +Check the exact `SyncExec` argument list against `syncexec.go:65` (`ctx, reg, s, priv, dataDir, bounce, appsDomain`) and the existing tests' way of passing a nil bounce; adjust the call to match what the neighbouring tests do. |
| + |
| +- [ ] **Step 2: Run it** |
| + |
| +Run: `GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test ./internal/appconfig/ -run TestSyncExecLeavesAStampedRoutesOldFileAlone -count=1` |
| +Expected: PASS already (Task 1 made it so). If it FAILS on the first `ReadFile`, the setup does not match how the package opts a route in; compare with the nearest existing opted-in test and fix the setup, not the assertion. |
| + |
| +- [ ] **Step 3: Commit** |
| + |
| +```bash |
| +git add internal/appconfig/syncexec_test.go |
| +git commit -m "appconfig: pin that SyncExec leaves a stamped route's old file alone |
| + |
| +Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>" |
| +``` |
| + |
| +--- |
| + |
| +### Task 9: `AwaitingRestart` gains `Undelivered` and `Blocked`, keeps stamped routes in `Unresolved` |
| + |
| +**Files:** |
| +- Modify: `internal/appconfig/syncexec.go:620-790` (`RestartPending`, `Empty`, `AwaitingRestart`) |
| +- Test: `internal/appconfig/awaitingrestart_test.go` |
| + |
| +**Interfaces:** |
| +- Produces: |
| + ```go |
| + type BlockedHost struct { Host, Reason string } |
| + type RestartPending struct { |
| + Hosts []RestartHost |
| + Unresolved []string |
| + Undelivered []string |
| + Blocked []BlockedHost |
| + } |
| + func BlockedStamps(routes []registry.Route, acct, app string) []BlockedHost // exported; the console uses it through AwaitingRestart |
| + ``` |
| +- Consumes: Task 1 rails. |
| + |
| +- [ ] **Step 1: Write the failing tests** |
| + |
| +Append to `internal/appconfig/awaitingrestart_test.go`: |
| + |
| +```go |
| +func TestAwaitingRestartListsUnoptedAddrRouteAsUndelivered(t *testing.T) { |
| + ctx, s, _, _, _, _ := execKit(t) |
| + if err := SetEnv(ctx, s, "bab", "relay", map[string]string{"K": "v"}); err != nil { |
| + t.Fatal(err) |
| + } |
| + routes := []registry.Route{addrRoute("plain.example.com", "relay", "bab", "")} |
| + got := AwaitingRestart(ctx, s, routes, "bab", "relay", "", []string{"K"}) |
| + if len(got.Undelivered) != 1 || got.Undelivered[0] != "plain.example.com" { |
| + t.Fatalf("Undelivered = %v, want [plain.example.com]", got.Undelivered) |
| + } |
| + if len(got.Hosts) != 0 || got.Empty() { |
| + t.Fatalf("Hosts = %v, Empty = %v", got.Hosts, got.Empty()) |
| + } |
| +} |
| + |
| +func TestAwaitingRestartStampedRouteWithMissingBundleIsUnresolved(t *testing.T) { |
| + ctx, s, _, _, _, _ := execKit(t) |
| + if err := SetEnv(ctx, s, "bab", "relay", map[string]string{"K": "v"}); err != nil { |
| + t.Fatal(err) |
| + } |
| + rt := addrRoute("relay.example.com", "relay", "bab", "missing") |
| + rt.Unit = "relay.service" |
| + got := AwaitingRestart(ctx, s, []registry.Route{rt}, "bab", "relay", "", []string{"K"}) |
| + if len(got.Unresolved) != 1 || got.Unresolved[0] != "relay.example.com" { |
| + t.Fatalf("Unresolved = %v, want the stamped route with a missing bound bundle", got.Unresolved) |
| + } |
| + if len(got.Hosts) != 0 || len(got.Undelivered) != 0 || len(got.Blocked) != 0 { |
| + t.Fatalf("stamped route leaked into another set: %+v", got) |
| + } |
| +} |
| + |
| +func TestAwaitingRestartHealthyStampedRouteIsListedNowhere(t *testing.T) { |
| + ctx, s, _, _, _, _ := execKit(t) |
| + if err := SetEnv(ctx, s, "bab", "relay", map[string]string{"K": "v"}); err != nil { |
| + t.Fatal(err) |
| + } |
| + rt := addrRoute("relay.example.com", "relay", "bab", "") |
| + rt.Unit = "relay.service" |
| + got := AwaitingRestart(ctx, s, []registry.Route{rt}, "bab", "relay", "", []string{"K"}) |
| + if !got.Empty() { |
| + t.Fatalf("healthy stamped route must be silent, got %+v", got) |
| + } |
| +} |
| + |
| +func TestAwaitingRestartBlockedStamps(t *testing.T) { |
| + ctx, s, _, _, _, _ := execKit(t) |
| + if err := SetEnv(ctx, s, "bab", "relay", map[string]string{"K": "v"}); err != nil { |
| + t.Fatal(err) |
| + } |
| + sock := addrRoute("sock.example.com", "relay", "bab", "") |
| + sock.Unit, sock.Addr, sock.Socket = "sock.service", "", "/run/x.sock" |
| + tmpl := addrRoute("tmpl.example.com", "relay", "bab", "") |
| + tmpl.Unit = "carlos-app@.service" |
| + a := addrRoute("a.example.com", "relay", "bab", "") |
| + a.Unit = "shared.service" |
| + // The duplicate is another ACCOUNT's route: it blocks a but is not disclosed. |
| + other := addrRoute("other.example.com", "relay", "bae", "") |
| + other.Unit = "shared.service" |
| + got := AwaitingRestart(ctx, s, []registry.Route{sock, tmpl, a, other}, "bab", "relay", "", []string{"K"}) |
| + byHost := map[string]string{} |
| + for _, b := range got.Blocked { |
| + byHost[b.Host] = b.Reason |
| + } |
| + if len(byHost) != 3 { |
| + t.Fatalf("Blocked = %+v, want sock, tmpl and a", got.Blocked) |
| + } |
| + if !strings.Contains(byHost["sock.example.com"], "socket") || |
| + !strings.Contains(byHost["tmpl.example.com"], "template") || |
| + !strings.Contains(byHost["a.example.com"], "shared.service") { |
| + t.Fatalf("reasons = %v", byHost) |
| + } |
| + if _, leaked := byHost["other.example.com"]; leaked { |
| + t.Fatal("another account's route must not be disclosed") |
| + } |
| + if strings.Contains(byHost["a.example.com"], "other.example.com") { |
| + t.Fatal("the reason must not name another account's host") |
| + } |
| + if got.Empty() { |
| + t.Fatal("Blocked must count as something to say") |
| + } |
| +} |
| +``` |
| + |
| +Add `"strings"` to the imports if missing. |
| + |
| +- [ ] **Step 2: Run to verify they fail** |
| + |
| +Run: `GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test ./internal/appconfig/ -run 'TestAwaitingRestart' -count=1` |
| +Expected: FAIL, `got.Undelivered undefined`. |
| + |
| +- [ ] **Step 3: Extend the types and the loop** |
| + |
| +In `internal/appconfig/syncexec.go`: |
| + |
| +Replace `RestartPending` and `Empty` (lines 647-655) with: |
| + |
| +```go |
| +// BlockedHost is a stamped route the root pass will refuse for a reason |
| +// knowable from the registry alone (spec §3.4, codex R2-6): the shape |
| +// rail, the name rail, the hostname rail or the one-route-per-unit rule. |
| +// The systemd-side refusals (alias, load state, the EnvironmentFiles |
| +// verification) are not knowable at the door and are not claimed here. |
| +type BlockedHost struct { |
| + Host string |
| + Reason string |
| +} |
| + |
| +// RestartPending is what a config write left behind on ONE box. |
| +// |
| +// Hosts: files that landed for processes carlos does not own, and |
| +// therefore does not restart. Unresolved: routes nothing can say |
| +// "delivered" or "not delivered" about. Undelivered: routes this write |
| +// reached that no pass delivers a file to (an un-opted addr route with no |
| +// stamp). Blocked: stamped routes the root pass will refuse. Each is its |
| +// own list because the honest sentence differs, and "could not tell" is |
| +// never rendered as any of the others. |
| +type RestartPending struct { |
| + Hosts []RestartHost |
| + Unresolved []string |
| + Undelivered []string |
| + Blocked []BlockedHost |
| +} |
| + |
| +// Empty reports whether there is nothing to say. The renderers' one test. |
| +func (r RestartPending) Empty() bool { |
| + return len(r.Hosts) == 0 && len(r.Unresolved) == 0 && len(r.Undelivered) == 0 && len(r.Blocked) == 0 |
| +} |
| + |
| +// BlockedStamps applies the door-side rails to the stamped routes of |
| +// (acct, app) in routes. Uniqueness is checked against the FULL list, so |
| +// a route of another account blocks this one, but only this account's |
| +// hosts are returned and no other account's host is named in a reason. |
| +func BlockedStamps(routes []registry.Route, acct, app string) []BlockedHost { |
| + byUnit := map[string]int{} |
| + for _, rt := range routes { |
| + if Stamped(rt) && StampShape(rt) == nil && DeliverableUnitName(rt.Unit) == nil && BareHostname(rt.Host) { |
| + byUnit[rt.Unit]++ |
| + } |
| + } |
| + var out []BlockedHost |
| + for _, rt := range routes { |
| + if !Stamped(rt) || rt.App != app || release.RouteAccount(rt) != acct { |
| + continue |
| + } |
| + switch { |
| + case StampShape(rt) != nil: |
| + out = append(out, BlockedHost{rt.Host, StampShape(rt).Error()}) |
| + case DeliverableUnitName(rt.Unit) != nil: |
| + out = append(out, BlockedHost{rt.Host, DeliverableUnitName(rt.Unit).Error()}) |
| + case !BareHostname(rt.Host): |
| + out = append(out, BlockedHost{rt.Host, fmt.Sprintf("%q is not a bare hostname", rt.Host)}) |
| + case byUnit[rt.Unit] > 1: |
| + out = append(out, BlockedHost{rt.Host, fmt.Sprintf("two or more routes on this box name %s; one unit takes one route's config", rt.Unit)}) |
| + } |
| + } |
| + sort.Slice(out, func(i, j int) bool { return out[i].Host < out[j].Host }) |
| + return out |
| +} |
| +``` |
| + |
| +In `AwaitingRestart`: |
| + |
| +1. Right after `var out RestartPending`, add: |
| + |
| +```go |
| + out.Blocked = BlockedStamps(routes, acct, app) |
| + blocked := map[string]bool{} |
| + for _, b := range out.Blocked { |
| + blocked[b.Host] = true |
| + } |
| +``` |
| + |
| +2. Change the loop's first filter from |
| + |
| +```go |
| + if !execAddrCandidate(rt) || rt.App != app || release.RouteAccount(rt) != acct { |
| + continue |
| + } |
| +``` |
| + |
| +to |
| + |
| +```go |
| + stamped := Stamped(rt) && !blocked[rt.Host] |
| + if (!execAddrCandidate(rt) && !stamped) || rt.App != app || release.RouteAccount(rt) != acct { |
| + continue |
| + } |
| +``` |
| + |
| +3. After the `eb` bound-bundle checks and the `resolveExecDelivery` call (the two places that append to `out.Unresolved` and `continue`), and BEFORE the `if !v.On` test, add: |
| + |
| +```go |
| + // A stamped route is delivered and restarted by the root pass within |
| + // seconds of this write; it has nothing to be told about unless its |
| + // bundle would not resolve, which the checks above have already |
| + // reported (spec §3.4, codex R1-8). |
| + if stamped { |
| + continue |
| + } |
| +``` |
| + |
| +Note `resolveExecDelivery` is still called for a stamped route so that a default-bundle fetch error lands it in `Unresolved`; that is the point. |
| + |
| +4. Replace |
| + |
| +```go |
| + if !v.On { |
| + continue |
| + } |
| +``` |
| + |
| +with |
| + |
| +```go |
| + // Not opted in — including the Unknown case, which decides "off" — |
| + // means no pass delivers a file to this route: this write reached |
| + // nothing here. Say so (spec §3.4, codex R1-9: "this write", not |
| + // "no file" — an opted-out route may hold a file from before). |
| + if !v.On { |
| + out.Undelivered = append(out.Undelivered, rt.Host) |
| + continue |
| + } |
| +``` |
| + |
| +5. Before `return out`, add `sort.Strings(out.Undelivered)`. |
| + |
| +- [ ] **Step 4: Run the tests** |
| + |
| +Run: `GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test ./internal/appconfig/ -count=1 && go vet ./internal/appconfig/ && gofmt -l internal/appconfig/` |
| +Expected: PASS. `TestAwaitingRestartSkipsUnoptedAndSocketRoutes` may now fail because an un-opted addr route is listed in `Undelivered`; read that test: if it asserts `Empty()` over a set that includes an un-opted ADDR route, change that assertion to expect the host in `Undelivered` and keep the socket/unit-backed expectations. If it only covers socket and unit-backed routes (as its name says), it passes unchanged. |
| + |
| +- [ ] **Step 5: Commit** |
| + |
| +```bash |
| +git add internal/appconfig/syncexec.go internal/appconfig/awaitingrestart_test.go |
| +git commit -m "appconfig: the notice says when a write reached nothing or a stamp is blocked |
| + |
| +Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>" |
| +``` |
| + |
| +--- |
| + |
| +### Task 10: Wire and CLI rendering |
| + |
| +**Files:** |
| +- Modify: `internal/console/apiconfig.go:495-543` (`apiConfigRestart`, `configRestartNotice`) |
| +- Modify: `apimode.go:2193-2207` (`apiConfigRestart`) |
| +- Modify: `appconfig_cli.go:43-53` (`restartFromAPI`), `:94-120` (`printRestartNotice`), `:149-157` (copy) |
| +- Test: `appconfig_restart_test.go`, `internal/console` (an existing apiconfig test file, grep `configRestartNotice` or `apiConfigRestart` under `internal/console/*_test.go`) |
| + |
| +**Interfaces:** |
| +- Both `apiConfigRestart` types gain `Undelivered []string \`json:"undelivered,omitempty"\`` and `Blocked []apiConfigBlockedHost \`json:"blocked,omitempty"\`` with `type apiConfigBlockedHost struct { Host string \`json:"host"\`; Reason string \`json:"reason"\` }`. |
| + |
| +- [ ] **Step 1: Write the failing CLI tests** |
| + |
| +Append to `appconfig_restart_test.go`: |
| + |
| +```go |
| +func TestRestartFromAPICarriesUndeliveredAndBlocked(t *testing.T) { |
| + got := restartFromAPI(&apiConfigRestart{Known: true, |
| + Undelivered: []string{"plain.example.com"}, |
| + Blocked: []apiConfigBlockedHost{{Host: "a.example.com", Reason: "two or more routes on this box name shared.service"}}, |
| + }) |
| + if !got.known || len(got.pending.Undelivered) != 1 || len(got.pending.Blocked) != 1 || |
| + got.pending.Blocked[0].Reason == "" { |
| + t.Fatalf("restartFromAPI = %+v", got) |
| + } |
| +} |
| + |
| +func TestPrintRestartNoticeUndeliveredAndBlocked(t *testing.T) { |
| + var b bytes.Buffer |
| + printRestartNotice(&b, "relay", &restartAnswer{known: true, pending: appconfig.RestartPending{ |
| + Undelivered: []string{"plain.example.com"}, |
| + Blocked: []appconfig.BlockedHost{{Host: "a.example.com", Reason: "two or more routes on this box name shared.service; one unit takes one route's config"}}, |
| + }}) |
| + out := b.String() |
| + for _, want := range []string{"note:", "did not reach plain.example.com", "carlos route --unit", "exec-delivery --app relay file", |
| + "blocked", "a.example.com", "shared.service", restartCopyScope} { |
| + if !strings.Contains(out, want) { |
| + t.Errorf("output lacks %q:\n%s", want, out) |
| + } |
| + } |
| +} |
| + |
| +func TestPrintRestartNoticeStillRendersAnOldConsolesUnitRemedy(t *testing.T) { |
| + var b bytes.Buffer |
| + printRestartNotice(&b, "relay", restartFromAPI(&apiConfigRestart{Known: true, |
| + Hosts: []apiConfigRestartHost{{Host: "console.example.com", Unit: "console.service"}}})) |
| + if !strings.Contains(b.String(), "carlos restart --app relay cycles console.service") { |
| + t.Fatalf("old-console unit remedy lost:\n%s", b.String()) |
| + } |
| +} |
| +``` |
| + |
| +Add `"strings"` and `"github.com/carlosframework/platform/internal/appconfig"` to that test file's imports if missing. |
| + |
| +- [ ] **Step 2: Run to verify they fail** |
| + |
| +Run: `GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test . -run 'TestRestartFromAPI|TestPrintRestartNotice' -count=1` |
| +Expected: FAIL, `unknown field Undelivered`. |
| + |
| +- [ ] **Step 3: Extend both wire types** |
| + |
| +`apimode.go` (lines 2196-2207): |
| + |
| +```go |
| +type apiConfigRestart struct { |
| + Known bool `json:"known"` |
| + Hosts []apiConfigRestartHost `json:"hosts"` |
| + Unresolved []string `json:"unresolved"` |
| + // Undelivered and Blocked are additive (spec §3.4): an older console |
| + // sends neither and this binary prints nothing for them. |
| + Undelivered []string `json:"undelivered,omitempty"` |
| + Blocked []apiConfigBlockedHost `json:"blocked,omitempty"` |
| +} |
| + |
| +// apiConfigBlockedHost is a stamped route the root pass will refuse, with |
| +// the door-side reason. |
| +type apiConfigBlockedHost struct { |
| + Host string `json:"host"` |
| + Reason string `json:"reason"` |
| +} |
| +``` |
| + |
| +`internal/console/apiconfig.go` (lines 495-510): add the same two fields and the same `apiConfigBlockedHost` type to the console's `apiConfigRestart`, with the comment "Undelivered: routes this write reached that no pass delivers to. Blocked: stamped routes the root pass will refuse, with the reason. Both omitted when empty so an older CLI sees exactly what it saw." |
| + |
| +In `configRestartNotice` (line 538), replace |
| + |
| +```go |
| + out := apiConfigRestart{Known: true, Unresolved: p.Unresolved} |
| +``` |
| + |
| +with |
| + |
| +```go |
| + out := apiConfigRestart{Known: true, Unresolved: p.Unresolved, Undelivered: p.Undelivered} |
| + for _, b := range p.Blocked { |
| + out.Blocked = append(out.Blocked, apiConfigBlockedHost{Host: b.Host, Reason: b.Reason}) |
| + } |
| +``` |
| + |
| +- [ ] **Step 4: Convert and render in the CLI** |
| + |
| +`appconfig_cli.go` `restartFromAPI`: after `out.pending.Unresolved = r.Unresolved` add |
| + |
| +```go |
| + out.pending.Undelivered = r.Undelivered |
| + for _, b := range r.Blocked { |
| + out.pending.Blocked = append(out.pending.Blocked, appconfig.BlockedHost{Host: b.Host, Reason: b.Reason}) |
| + } |
| +``` |
| + |
| +`printRestartNotice`: after the `Unresolved` loop add |
| + |
| +```go |
| + for _, h := range a.pending.Undelivered { |
| + fmt.Fprintf(w, "%s %s\n", c.warn("note:"), fmt.Sprintf(restartCopyUndelivered, h, app)) |
| + } |
| + for _, b := range a.pending.Blocked { |
| + fmt.Fprintf(w, "%s %s\n", c.warn("note:"), fmt.Sprintf(restartCopyBlocked, b.Host, b.Reason)) |
| + } |
| +``` |
| + |
| +Copy block: add |
| + |
| +```go |
| + restartCopyUndelivered = "this write did not reach %s. No pass delivers config to it. If a systemd unit runs its process, name it on the box (carlos route --unit); if the app's own supervisor reads a delivered file, opt the bundle in (carlos env exec-delivery --app %s file)." |
| + restartCopyBlocked = "delivery to %s is blocked: %s" |
| +``` |
| + |
| +Leave `restartCopyUnit` and the `Unit` fields in place (spec §3.4: an older console still sends them). |
| + |
| +- [ ] **Step 5: Console-side test** |
| + |
| +Find the console test that exercises `configRestartNotice` or decodes the config door's `restart` field (`grep -rn 'restart' internal/console/apiconfig_test.go` or the nearest `*_test.go` naming `apiConfigRestart`). Add one case that seeds the test registry with an un-opted addr route and a stamped socket route for the app, performs the write the door exposes, and asserts the decoded JSON has `undelivered: ["<host>"]` and one `blocked` entry whose `reason` contains `socket`. Follow that file's existing setup verbatim (server construction, registry path, bearer token). |
| + |
| +- [ ] **Step 6: Run everything** |
| + |
| +Run: `GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go build ./... && go test . ./internal/console/ ./internal/appconfig/ -count=1 && go vet ./... && gofmt -l .` |
| +Expected: PASS, clean. (If a console socket test fails with `bind: invalid argument`, re-run with `TMPDIR=/tmp/g` after `mkdir -p /tmp/g`.) |
| + |
| +- [ ] **Step 7: Commit** |
| + |
| +```bash |
| +git add apimode.go appconfig_cli.go appconfig_restart_test.go internal/console/apiconfig.go internal/console/ |
| +git commit -m "notice: undelivered and blocked over both wire types, rendered by the CLI (copy pending copy-review) |
| + |
| +Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>" |
| +``` |
| + |
| +--- |
| + |
| +### Task 11: The doors validate the resulting row |
| + |
| +**Files:** |
| +- Modify: `cli.go:1486-1525` (`runRouteUnit`, `unitRail`), `cli.go:1317-1330` (`runRouteHibernate`), `cli.go:85-110` (`carlos add`) |
| +- Test: `cli_test.go:466-510` |
| + |
| +**Interfaces:** |
| +- `unitRail(rt registry.Route, unit string) error` (signature change: takes the proposed stamp; `""` always passes). |
| +- `hibernateRail(rt registry.Route) error` (new: refuses hibernation on a stamped row). |
| + |
| +- [ ] **Step 1: Write the failing tests** |
| + |
| +In `cli_test.go`, update `TestUnitRailRefusals` to the two-argument form (`unitRail(unitBacked, "console.service")`, and in the accept loop `unitRail(c.rt, "console.service")`), then append: |
| + |
| +```go |
| +func TestUnitRailValidatesTheResultingRow(t *testing.T) { |
| + sock := registry.Route{Host: "s.example.com", Kind: "instance", App: "relay", Socket: "/run/s.sock"} |
| + if err := unitRail(sock, "s.service"); err == nil || !strings.Contains(err.Error(), "socket") { |
| + t.Fatalf("stamping a socket route = %v, want a socket refusal", err) |
| + } |
| + hib := registry.Route{Host: "h.example.com", Kind: "instance", App: "relay", Addr: "127.0.0.1:1", Hibernate: true} |
| + if err := unitRail(hib, "h.service"); err == nil || !strings.Contains(err.Error(), "hibernat") { |
| + t.Fatalf("stamping a hibernating route = %v, want a hibernate refusal", err) |
| + } |
| + // Clearing is always allowed, whatever the old row's shape. |
| + sock.Unit = "s.service" |
| + if err := unitRail(sock, ""); err != nil { |
| + t.Fatalf("clearing a stamp on a socket route = %v, want accepted", err) |
| + } |
| +} |
| + |
| +func TestHibernateRailRefusesAStampedRow(t *testing.T) { |
| + stamped := registry.Route{Host: "c.example.com", Kind: "instance", App: "console", Addr: "127.0.0.1:8090", Unit: "console.service"} |
| + if err := hibernateRail(stamped); err == nil || !strings.Contains(err.Error(), "console.service") { |
| + t.Fatalf("hibernateRail(stamped) = %v, want a refusal naming the unit", err) |
| + } |
| + stamped.Unit = "" |
| + if err := hibernateRail(stamped); err != nil { |
| + t.Fatalf("hibernateRail(unstamped) = %v, want accepted", err) |
| + } |
| +} |
| +``` |
| + |
| +- [ ] **Step 2: Run to verify they fail** |
| + |
| +Run: `GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test . -run 'TestUnitRail|TestHibernateRail' -count=1` |
| +Expected: FAIL (compile: too many arguments / undefined `hibernateRail`). |
| + |
| +- [ ] **Step 3: Change the rails** |
| + |
| +In `cli.go`, replace `unitRail`: |
| + |
| +```go |
| +// unitRail is the row-shape half of runRouteUnit's refusals, split out as a |
| +// pure function for the same reason backingRail is. It validates the ROW |
| +// THAT WOULD RESULT (spec 2026-09-07-named-unit-config-delivery-design.md |
| +// §3.1, codex R3-5): clearing a stamp (unit == "") is always allowed, so |
| +// that no row can be stranded; stamping refuses a unit-backed row (two |
| +// answers to one question) and a row outside the supported stamped shape |
| +// (appconfig.StampShape: an addr upstream, no socket, no hibernation), |
| +// which the root pass would refuse every pass anyway. |
| +// |
| +// Deliberately NOT refused: Platform rows and service rows — see the |
| +// previous version's reasoning, which still holds. |
| +func unitRail(rt registry.Route, unit string) error { |
| + if unit == "" { |
| + return nil |
| + } |
| + if rt.Backing == registry.BackingUnit { |
| + return fmt.Errorf("unit-backed route — its process is the carlos-app@%s unit the platform derives and already restarts, "+ |
| + "so a second unit name here would be two answers to one question (drop the backing first with `carlos route --backing \"\"` if this route really is owned by a bespoke service)", rt.Host) |
| + } |
| + proposed := rt |
| + proposed.Unit = unit |
| + if err := appconfig.StampShape(proposed); err != nil { |
| + return fmt.Errorf("%v — the root pass delivers config only to an addr-backed, non-hibernating route", err) |
| + } |
| + return nil |
| +} |
| + |
| +// hibernateRail refuses hibernation on a stamped row: the root pass |
| +// restarts a stamped unit on config change, and a hibernating route is the |
| +// activator's to wake, so the two would fight over one process. |
| +func hibernateRail(rt registry.Route) error { |
| + if rt.Unit != "" { |
| + return fmt.Errorf("route names unit %s — a stamped route is restarted by the root config pass and cannot also hibernate; clear the stamp first (`carlos route --host %s --unit -`)", rt.Unit, rt.Host) |
| + } |
| + return nil |
| +} |
| +``` |
| + |
| +Update `runRouteUnit`'s first line to `if err := unitRail(rt, unit); err != nil {`. |
| + |
| +In `runRouteHibernate`, inside `if on {` before the daemon check, add: |
| + |
| +```go |
| + if err := hibernateRail(rt); err != nil { |
| + log.Fatalf("route: %s: %v", rt.Host, err) |
| + } |
| +``` |
| + |
| +In `carlos add` (after the `--backing unit with --unit` refusal, before the socket default), add: |
| + |
| +```go |
| + // The root pass delivers config to a stamped route only in one shape |
| + // (appconfig.StampShape); this door must not mint a row it would refuse |
| + // every pass. --socket is refused even though the default below would |
| + // add one for a bare instance: a stamped route needs --addr. |
| + if *unit != "" { |
| + if *socket != "" || *addr == "" || *hibernate { |
| + log.Fatalf("add %s: --unit %s needs --addr and neither --socket nor --hibernate: the root pass delivers config to a stamped route only when a unit owns an addr-backed, always-on process", host, *unit) |
| + } |
| + } |
| +``` |
| + |
| +Add `"github.com/carlosframework/platform/internal/appconfig"` to `cli.go`'s imports if it is not already there (check: `grep -n 'internal/appconfig' cli.go`). |
| + |
| +- [ ] **Step 4: Run the CLI tests** |
| + |
| +Run: `GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go build ./... && go test . -run 'Rail|TestRouteUnit|TestAdd' -count=1 && go vet . && gofmt -l .` |
| +Expected: PASS. `TestRouteUnitStampsAnExistingRoute` drives the door as a subprocess on an addr route and must still pass; if it stamps a route with no `--addr`, add `--addr 127.0.0.1:8090` to its fixture, because that route would now be refused by design. |
| + |
| +- [ ] **Step 5: Commit** |
| + |
| +```bash |
| +git add cli.go cli_test.go |
| +git commit -m "cli: route --unit, --hibernate and add validate the row that would result |
| + |
| +Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>" |
| +``` |
| + |
| +--- |
| + |
| +### Task 12: Documentation and the full gate |
| + |
| +**Files:** |
| +- Modify: `docs/site/cli.md:395-440` (`carlos env`, `carlos secrets`), `:872-895` (`carlos add`), `:897-915` (`carlos route`) |
| +- Modify: `AGENTS.md:282-290` |
| + |
| +- [ ] **Step 1: `docs/site/cli.md`** |
| + |
| +In the `### carlos route` section, after the sentence ending "so the command will not let you write both." (line 911), add: |
| + |
| +```markdown |
| +Naming a unit also hands its config to the root pass: on every config |
| +write the pass renders the route's bundle to `/etc/carlos/apps/<host>.env`, |
| +keeps a drop-in under `/etc/systemd/system/<unit>.d/` that makes that file |
| +the unit's only `EnvironmentFile=`, reloads systemd and restarts the unit. |
| +A stamp needs an addr upstream and no hibernation; `--unit` refuses a |
| +socket or hibernating row, `--hibernate` refuses a stamped one, and |
| +`--unit -` always clears. |
| +``` |
| + |
| +In `### carlos add`, extend the `--unit console.service` sentence: "…which is what lets adoption cycle it and the root pass deliver its config; it needs `--addr` and neither `--socket` nor `--hibernate`." |
| + |
| +In `### carlos env` and `### carlos secrets`, after the paragraph that describes the existing restart note (grep `still running the old values` on the page; if the page does not mention the note, add this after the `set` example), add one sentence each: |
| + |
| +```markdown |
| +A write that reaches a route no pass delivers to says so ("this write did |
| +not reach <host>"), with the two ways out: name the unit that runs the |
| +process on the box, or opt the bundle into file delivery. A stamped route |
| +the root pass will refuse is reported as blocked with the reason. |
| +``` |
| + |
| +- [ ] **Step 2: `AGENTS.md`** |
| + |
| +In the "Console restarts: manual until the route is unit-stamped" bullet (line 282), append after "Ordinary unit/exec tenants restart themselves either way.": |
| + |
| +```markdown |
| + A stamped route also receives its config from the root pass (spec |
| + `docs/superpowers/specs/2026-09-07-named-unit-config-delivery-design.md`): |
| + the drop-in it writes RESETS the unit's `EnvironmentFile=` list to the |
| + rendered bundle, so a hand-built unit's own `EnvironmentFile=` lines stop |
| + applying once it is stamped; `Environment=` lines are untouched. |
| +``` |
| + |
| +- [ ] **Step 3: Run the whole gate** |
| + |
| +Run: |
| +``` |
| +mkdir -p /tmp/g && GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off TMPDIR=/tmp/g go build ./... && go vet ./... && TMPDIR=/tmp/g go test ./... -count=1 && gofmt -l . && GOOS=darwin go build ./... && GOOS=windows go build ./... && GOOS=linux GOARCH=arm64 go build ./... |
| +``` |
| +Expected: every package PASS, no vet output, no unformatted files, three cross-compiles clean. `internal/docsite` checks the CLI page's headings and examples; none were added or removed, so it passes as before. |
| + |
| +- [ ] **Step 4: Commit** |
| + |
| +```bash |
| +git add docs/site/cli.md AGENTS.md |
| +git commit -m "docs: a stamped route gets its config from the root pass; the notice's two new lines |
| + |
| +Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>" |
| +``` |
| + |
| +- [ ] **Step 5: Branch description and hand-off** |
| + |
| +The branch is complete when every task above is committed and the gate in Step 3 is green. Before it lands: run `copy-review` on the strings introduced in Tasks 1, 4, 5, 9, 10 and 11 (the refusal reasons, the `note:` lines, the door refusals) in an interactive session, then push to the forge and set the description with `amadan branch describe carlos/platform worktree-unit-route-config -body -`, wait for `amadan ci status carlos/platform -branch worktree-unit-route-config`, and land per the spec's §7. |
| + |
| +--- |
| + |
| +## Self-review notes |
| + |
| +**Spec coverage.** §3.1 predicates and door shape: Tasks 1, 11. §3.2 rail, drop-in with reset, marker-first protocol, lock, batched probe, alias refusal, one-per-unit, verify exactly one file, restart not try-restart, drift, reaping with marker first: Tasks 3 to 7. §3.3: Task 8. §3.4 `Undelivered`, `Blocked`, stamped routes in `Unresolved`, both wire types, `restartFromAPI` and `configRestartNotice`, `Unit` remedy kept: Tasks 9, 10. §3.5 fingerprint: Task 1. §3.6 docs and AGENTS.md: Task 12. §6's test list maps onto the tests in Tasks 1 to 11; the R2-3 interleaving is covered by the lock test in Task 4 (a pass that cannot lock does nothing). |
| + |
| +**Names used across tasks.** `Stamped`, `unitDelivered`, `StampShape`, `DeliverableUnitName`, `BareHostname` (Task 1) are used in Tasks 4, 9, 11 as written. `prepareHost`/`commitHost` (Task 2) are used in Task 4. `renderDropIn`, `ensureDropInDir`, `readDropIn`, `writeDropIn`, `removeDropIn`, `writePending`, `removePending`, `hasPending`, `listPending`, `listMarkedDropIns`, `dropInDir`, `DropInFile`, `errNotOurs` (Task 3) are used in Tasks 4 to 6. `UnitOps`, `UnitState`, `SyncUnits`, `stampedPlan`, `unitWork`, `planStamped`, `prepareStamped` (Task 4), `applyStamped`, `envFilesMatch` (Task 5), `reapDropIns` (Task 6) match Task 7's `productionUnitOps`. `BlockedHost`, `RestartPending.Undelivered/Blocked`, `BlockedStamps` (Task 9) match Task 10's wire fields. |
| + |
| +**Known judgement calls for the implementer.** Task 8 and Task 10 Step 5 tell the implementer to copy an existing test's setup rather than reproduce it here, because the exact `SyncExec` bounce argument and the console test server construction are long and already in the neighbouring tests; the assertions are given in full. |