merge: follow-up fixes — the bake clock leaves /opt, 1xx stops killing the cache, purges ride a host index, unit tenants learn the channel (#33)
Follow-up fixes: bake clock to /data (#13), 1xx latch, per-host purge index, §7.3 delivery
17 files changed,
+683
−68
docs/superpowers/specs/2026-08-03-unit-tenant-purge-channel.md+39 −10internal/activator/backend_exec.go+5 −0internal/appconfig/agentvars.go+51 −0internal/appconfig/agentvars_test.go+18 −0internal/appconfig/sync.go+29 −1internal/appconfig/sync_test.go+95 −24internal/appconfig/syncexec.go+10 −0internal/edge/cache.go+26 −12internal/edge/cache_test.go+29 −0internal/edge/edge.go+8 −0internal/edge/edge_test.go+52 −0internal/edge/purge_header.go+11 −11internal/host/agent.go+6 −6internal/release/adopt.go+29 −1internal/release/adopt_test.go+67 −0internal/release/bake.go+96 −3internal/release/bake_test.go+112 −0
diff --git a/docs/superpowers/specs/2026-08-03-unit-tenant-purge-channel.md b/docs/superpowers/specs/2026-08-03-unit-tenant-purge-channel.md| index df7a07293..fe27c7868 100644 |
| --- a/docs/superpowers/specs/2026-08-03-unit-tenant-purge-channel.md |
| +++ b/docs/superpowers/specs/2026-08-03-unit-tenant-purge-channel.md |
| @@ -683,20 +683,35 @@ also forwarded; the fix in both cases is upgrading the edge, not the app. |
| that restart, the box's edge does not read the header and apps that |
| emit it are simply not purging — which is exactly today's behaviour, so |
| the intermediate state is safe. |
| -3. **`CARLOS_PURGE=header`** — **DEFERRED, and exec-only as shipped.** The |
| - var is currently set by `internal/activator`'s exec backend for |
| - exec-backed instances only. Delivery to **unit** tenants via the |
| - `internal/appconfig` sync into `/etc/carlos/apps/<host>.env` on the root |
| - adopt path is *not* built yet; it is tracked as follow-on work. |
| - |
| - This costs the feature nothing, which is the point of the var being |
| - advisory: **the channel works whether or not the app ever sees it**, so a |
| - unit tenant that emits `Carlos-Purge` today is honoured today, variable |
| - or no variable. When the sync does land it takes effect on that unit's |
| +3. **`CARLOS_PURGE=header`** — **Landed 2026-08-03** (was deferred at ship): |
| + the appconfig sync appends the var to every unit tenant's env file — see |
| + `internal/appconfig/agentvars.go`. The var was already set by |
| + `internal/activator`'s exec backend for exec-backed instances; delivery to |
| + **unit** tenants via the `internal/appconfig` sync into |
| + `/etc/carlos/apps/<host>.env` on the root adopt path has now been built. |
| + |
| + Deferring this cost the feature nothing while it was deferred, which is |
| + the point of the var being advisory: **the channel works whether or not |
| + the app ever sees it**, so a unit tenant that emits `Carlos-Purge` was |
| + honoured before this landed, variable or no variable, just as it is now. |
| + Now that the sync appends it, delivery takes effect on each unit tenant's |
| next restart — hibernating tenants pick it up on wake, per the standard |
| rule — and because the var is advisory there is no window in which it |
| means something untrue. |
| + Landing it is not free of side effects, though: because every existing |
| + `/etc/carlos/apps/<host>.env` on the fleet gains this line, the *first* |
| + sync pass after this ships will change the byte content of every active |
| + unit tenant's file — `syncHost`'s `bytes.Equal` convergence check will see |
| + a diff for each one and will restart each once (hibernated routes are |
| + unaffected; `try-restart` is a no-op on an inactive unit). That one-time |
| + fleet-wide restart is weighed and accepted deliberately, not an |
| + overlooked side effect: the var cannot take effect without a restart. |
| + Contrast `internal/appconfig/syncexec.go`'s `execAddrConfigHeader`, |
| + which declines this exact trade for the socket-exec shape — a bare |
| + comment header isn't worth bouncing every instance on the fleet. Here |
| + the restart is not a cost of the payload, it *is* the payload landing. |
| + |
| Where the var *is* set, it is **agent-owned in both directions**: set |
| when there is a cache, and **removed** from the child's environment when |
| there is not. The removal half is load-bearing, not decoration. The env |
| @@ -873,6 +888,20 @@ privileges.** |
| ships (it is cheap and strictly better isolation) and candidate (a)'s |
| per-route group machinery has to be built alongside it. **This is |
| Paul's call and it is the only thing blocking a plan.** |
| + |
| + **Resolved 2026-08-03: request-coupled purge ships alone.** TTL-bounded |
| + staleness (≤ `DefaultCacheMaxTTL`, 300s) for scheduled sales open/close |
| + transitions is accepted: the parent spec's `SalesEndAt` default change |
| + already covers the common case, and a scheduled transition being up to |
| + five minutes stale on a cached public page is within the same tolerance |
| + the TTL already imposes on every uncached-to-cached transition; the |
| + effective bound per page is the app's own max-age when smaller (titogo's |
| + public pages set max-age=120, so its transitions bound at two minutes). |
| + Candidate (a)'s per-route group machinery is the documented escalation if |
| + production contradicts this. Decision taken in-session under delegated |
| + authority (Paul: "keep going until all those are done"), recorded here |
| + for veto. |
| + |
| 2. **Header name.** `Carlos-Purge` versus reusing the de-facto |
| `Surrogate-Key` vocabulary, which some app frameworks already emit and |
| which would make an app portable between CARLOS and a Fastly-style CDN. |
diff --git a/internal/activator/backend_exec.go b/internal/activator/backend_exec.go| index ace80a2e8..16ade10d1 100644 |
| --- a/internal/activator/backend_exec.go |
| +++ b/internal/activator/backend_exec.go |
| @@ -370,6 +370,11 @@ func withVar(env map[string]string, key, value string) map[string]string { |
| // it, "the agent decides whether this var is set" is only true when the agent |
| // sets it; a tenant supplying the same name in its own env file wins by |
| // default. Same copy discipline as withVar: the caller's map is never mutated. |
| +// |
| +// This package keeps its own agent-owned list inline (the two names above, |
| +// at the b.Cache != nil branch) rather than sharing one with |
| +// internal/appconfig's agentOwned (agentvars.go), which the unit-tenant sync |
| +// path uses for the same purpose — the two lists must not drift apart. |
| func withoutVars(env map[string]string, keys ...string) map[string]string { |
| out := make(map[string]string, len(env)) |
| for k, v := range env { |
diff --git a/internal/appconfig/agentvars.go b/internal/appconfig/agentvars.go| new file mode 100644 |
| index 000000000..ea6d32883 |
| --- /dev/null |
| +++ b/internal/appconfig/agentvars.go |
| @@ -0,0 +1,51 @@ |
| +package appconfig |
| + |
| +import "strings" |
| + |
| +// Agent-owned vars (parent spec §7.3): CARLOS_PURGE is set by the platform |
| +// when the box has a cache and REMOVED when it does not — the removal half |
| +// is load-bearing, since the env layers are tenant-supplied and a tenant |
| +// advertising a purge channel that does not exist would have apps skip |
| +// their own invalidation and serve stale content indefinitely. |
| +// CARLOS_CONTROL_SOCKET is stripped for the stronger reason: a |
| +// tenant-chosen socket path is a name the agent never vouched for. This |
| +// mirrors the exec backend's withoutVars (internal/activator/backend_exec.go); |
| +// the two lists must not drift. |
| +// |
| +// The sync appends CARLOS_PURGE=header UNCONDITIONALLY because cache |
| +// construction is unconditional in carlos agent (internal/host/agent.go, |
| +// edge.NewCache at startup) — every box this sync runs on serves through |
| +// that cache. The day the cache grows an off switch, this must learn to |
| +// read it. |
| +func agentOwned(name string) bool { |
| + return name == "CARLOS_PURGE" || name == "CARLOS_CONTROL_SOCKET" |
| +} |
| + |
| +// applyAgentVars rewrites one rendered env file: tenant-supplied |
| +// agent-owned vars are dropped and the agent's own CARLOS_PURGE=header is |
| +// appended last (last assignment wins under systemd EnvironmentFile, so |
| +// even a hand-staged earlier line could not override it — but we strip |
| +// those anyway). Deterministic and idempotent, so syncHost's bytes.Equal |
| +// convergence check still short-circuits the steady state. |
| +// |
| +// RenderEnv (materialize.go) emits exactly one `KEY=value\n` line per var, |
| +// keys sorted, and nothing else — no comments, no blank lines — so dropping |
| +// blank lines here is a no-op against its actual output; it's kept because a |
| +// transformation over line-oriented KEY=value content should not depend on |
| +// its input never containing one. |
| +func applyAgentVars(rendered []byte) []byte { |
| + var out strings.Builder |
| + for _, line := range strings.Split(string(rendered), "\n") { |
| + s := strings.TrimSpace(line) |
| + if s == "" { |
| + continue |
| + } |
| + if name, _, ok := strings.Cut(s, "="); ok && agentOwned(strings.TrimSpace(name)) { |
| + continue |
| + } |
| + out.WriteString(line) |
| + out.WriteByte('\n') |
| + } |
| + out.WriteString("CARLOS_PURGE=header\n") |
| + return []byte(out.String()) |
| +} |
diff --git a/internal/appconfig/agentvars_test.go b/internal/appconfig/agentvars_test.go| new file mode 100644 |
| index 000000000..cb2fc9104 |
| --- /dev/null |
| +++ b/internal/appconfig/agentvars_test.go |
| @@ -0,0 +1,18 @@ |
| +package appconfig |
| + |
| +import "testing" |
| + |
| +func TestApplyAgentVars(t *testing.T) { |
| + in := []byte("A=1\nCARLOS_PURGE=socket\nB=2\nCARLOS_CONTROL_SOCKET=/tmp/x\n") |
| + got := string(applyAgentVars(in)) |
| + want := "A=1\nB=2\nCARLOS_PURGE=header\n" |
| + if got != want { |
| + t.Fatalf("got %q want %q", got, want) |
| + } |
| + if again := string(applyAgentVars([]byte(got))); again != want { |
| + t.Fatalf("not idempotent: %q", again) |
| + } |
| + if got := string(applyAgentVars(nil)); got != "CARLOS_PURGE=header\n" { |
| + t.Fatalf("empty render: got %q", got) |
| + } |
| +} |
diff --git a/internal/appconfig/sync.go b/internal/appconfig/sync.go| index ce177e02a..8a471e7e0 100644 |
| --- a/internal/appconfig/sync.go |
| +++ b/internal/appconfig/sync.go |
| @@ -240,7 +240,9 @@ func reap(routes []registry.Route, envDir string) { |
| // render is what the caller is deciding whether to WRITE, and this decision |
| // has to be made before rendering (a refused route must never reach the |
| // render-diff-write-restart path at all). RenderEnv emits exactly one |
| -// KEY=value line per var, so the two key sets are the same by construction. |
| +// KEY=value line per var, so the two key sets are the same by construction — |
| +// except for the agent-owned names applyAgentVars adds to and removes from |
| +// the rendered file afterward, which is why they are skipped below. |
| // |
| // Blank lines and #-comments are ignored, matching how systemd's |
| // EnvironmentFile reads these files: they carry no configuration, so their |
| @@ -259,6 +261,12 @@ func lostKeys(cur []byte, m *Manifest) (lost []string, parsed bool) { |
| if !ok || name == "" { |
| return nil, false |
| } |
| + if agentOwned(name) { |
| + // The render is post-processed by applyAgentVars, which appends |
| + // CARLOS_PURGE=header to every file this sync writes: counting |
| + // it as lost would refuse every previously-converged route. |
| + continue |
| + } |
| if _, kept := m.Vars[name]; !kept { |
| lost = append(lost, name) |
| } |
| @@ -356,6 +364,26 @@ func syncHost(rt registry.Route, m *Manifest, defaultMissing bool, priv *ecdh.Pr |
| fmt.Printf(" %s: render config: %v\n", rt.Host, err) |
| return |
| } |
| + // Spec §7.3: deliver CARLOS_PURGE=header to unit tenants the same way |
| + // the exec backend already does for exec-backed ones (withoutVars / |
| + // CARLOS_PURGE in internal/activator/backend_exec.go's Start) — strip |
| + // any tenant-supplied agent-owned vars and append the agent's own. |
| + // Deterministic and idempotent (agentvars.go), so the bytes.Equal |
| + // check below still short-circuits the steady state ON EVERY PASS |
| + // AFTER THE FIRST. |
| + // |
| + // The first pass after this ships is NOT a no-op, though: every |
| + // existing <host>.env on the fleet gains this line, so bytes.Equal |
| + // fails for every active unit tenant and this pass's restart(rt.Host) |
| + // fires for each of them once — hibernated routes are unaffected |
| + // (try-restart is a no-op on an inactive unit). That was weighed and |
| + // accepted, not overlooked: the var can't take effect without a |
| + // restart, unlike syncexec.go's execAddrConfigHeader, which declined |
| + // exactly this trade for the socket-exec shape (a comment header |
| + // alone isn't worth bouncing every instance on the fleet). Here the |
| + // var itself is the payload, so the one-time fleet-wide restart is the |
| + // feature landing, not a side effect of it. |
| + rendered = applyAgentVars(rendered) |
| if existed && bytes.Equal(cur, rendered) { |
| return // up to date — stay quiet on the periodic tick |
| } |
diff --git a/internal/appconfig/sync_test.go b/internal/appconfig/sync_test.go| index 1fedbc201..c331df0b5 100644 |
| --- a/internal/appconfig/sync_test.go |
| +++ b/internal/appconfig/sync_test.go |
| @@ -64,7 +64,7 @@ func TestSyncWritesFileAndRestartsOnChange(t *testing.T) { |
| if err != nil { |
| t.Fatalf("read rendered env: %v", err) |
| } |
| - want := "LOG_LEVEL=debug\nSES_SECRET=shh\n" |
| + want := "LOG_LEVEL=debug\nSES_SECRET=shh\nCARLOS_PURGE=header\n" |
| if string(got) != want { |
| t.Fatalf("rendered env = %q, want %q", got, want) |
| } |
| @@ -161,10 +161,12 @@ func TestSyncLeavesHandStagedFileAloneWhenNoManifestExists(t *testing.T) { |
| } |
| } |
| -func TestSyncConvergesToEmptyAfterEveryVarIsUnset(t *testing.T) { |
| +func TestSyncConvergesToAgentOwnedOnlyAfterEveryVarIsUnset(t *testing.T) { |
| // The mirror of the case above: the manifest object DOES exist, it just |
| // has no vars left. That is a real, operator-intended state and must |
| - // converge — the file is emptied and the unit restarted. |
| + // converge — every tenant var is dropped and the unit restarted. The |
| + // file is not fully emptied: CARLOS_PURGE=header is agent-owned, not a |
| + // tenant var, and applyAgentVars appends it unconditionally. |
| s := release.NewDirStore(t.TempDir()) |
| ctx := context.Background() |
| reg := testRegistry(t) |
| @@ -192,8 +194,8 @@ func TestSyncConvergesToEmptyAfterEveryVarIsUnset(t *testing.T) { |
| t.Fatalf("Sync: %v", err) |
| } |
| path := filepath.Join(envDir, "keymail.example.com.env") |
| - if got, err := os.ReadFile(path); err != nil || string(got) != "LOG_LEVEL=debug\n" { |
| - t.Fatalf("after set: env = %q, err = %v; want LOG_LEVEL=debug", got, err) |
| + if got, err := os.ReadFile(path); err != nil || string(got) != "LOG_LEVEL=debug\nCARLOS_PURGE=header\n" { |
| + t.Fatalf("after set: env = %q, err = %v; want LOG_LEVEL=debug\\nCARLOS_PURGE=header", got, err) |
| } |
| if _, err := Unset(ctx, s, "bab", "keymail", []string{"LOG_LEVEL"}); err != nil { |
| @@ -207,8 +209,10 @@ func TestSyncConvergesToEmptyAfterEveryVarIsUnset(t *testing.T) { |
| if err != nil { |
| t.Fatalf("read env after unset: %v", err) |
| } |
| - if len(got) != 0 { |
| - t.Fatalf("after full unset: env = %q, want empty", got) |
| + // Convergence to empty is now "just the agent-owned line" — the agent |
| + // still owns CARLOS_PURGE regardless of what the tenant's manifest holds. |
| + if string(got) != "CARLOS_PURGE=header\n" { |
| + t.Fatalf("after full unset: env = %q, want just CARLOS_PURGE=header", got) |
| } |
| if len(restarted) != 1 || restarted[0] != "keymail.example.com" { |
| t.Fatalf("restarted = %v, want exactly [keymail.example.com]", restarted) |
| @@ -489,9 +493,9 @@ func TestSyncRendersEachRoutesBoundEnvironment(t *testing.T) { |
| } |
| want := map[string]string{ |
| - "plain.example.com": "LOG_LEVEL=info\nSHARED=yes\n", |
| - "demo.example.com": "DEMO_ONLY=1\nLOG_LEVEL=debug\nSHARED=yes\n", |
| - "staging.example.com": "LOG_LEVEL=trace\nSHARED=yes\nSTAGING_ONLY=1\n", |
| + "plain.example.com": "LOG_LEVEL=info\nSHARED=yes\nCARLOS_PURGE=header\n", |
| + "demo.example.com": "DEMO_ONLY=1\nLOG_LEVEL=debug\nSHARED=yes\nCARLOS_PURGE=header\n", |
| + "staging.example.com": "LOG_LEVEL=trace\nSHARED=yes\nSTAGING_ONLY=1\nCARLOS_PURGE=header\n", |
| } |
| for host, body := range want { |
| got, err := os.ReadFile(filepath.Join(envDir, host+".env")) |
| @@ -612,8 +616,8 @@ func TestSyncLeavesEnvFileUntouchedWhenBoundEnvironmentIsMissing(t *testing.T) { |
| if err != nil { |
| t.Fatalf("sibling route not materialized: %v", err) |
| } |
| - if string(sibling) != "LOG_LEVEL=info\n" { |
| - t.Fatalf("sibling env = %q, want LOG_LEVEL=info", sibling) |
| + if string(sibling) != "LOG_LEVEL=info\nCARLOS_PURGE=header\n" { |
| + t.Fatalf("sibling env = %q, want LOG_LEVEL=info + CARLOS_PURGE=header", sibling) |
| } |
| if len(restarted) != 1 || restarted[0] != "ok.example.com" { |
| t.Fatalf("restarted = %v, want exactly [ok.example.com]", restarted) |
| @@ -733,8 +737,8 @@ func TestSyncRefusesToReplaceStagedFileWhenAppHasNoDefaultBundle(t *testing.T) { |
| if err != nil { |
| t.Fatalf("fresh route not materialized: %v", err) |
| } |
| - if string(fresh) != "LOG_LEVEL=debug\n" { |
| - t.Fatalf("fresh route env = %q, want LOG_LEVEL=debug", fresh) |
| + if string(fresh) != "LOG_LEVEL=debug\nCARLOS_PURGE=header\n" { |
| + t.Fatalf("fresh route env = %q, want LOG_LEVEL=debug + CARLOS_PURGE=header", fresh) |
| } |
| if len(restarted) != 1 || restarted[0] != "fresh.example.com" { |
| t.Fatalf("restarted = %v, want exactly [fresh.example.com]", restarted) |
| @@ -778,8 +782,8 @@ func TestSyncEnvironmentOnlyAppKeepsConvergingAfterItsOwnFirstWrite(t *testing.T |
| t.Fatalf("Sync (pass 1): %v", err) |
| } |
| path := filepath.Join(envDir, "fresh.example.com.env") |
| - if got, err := os.ReadFile(path); err != nil || string(got) != "LOG_LEVEL=debug\n" { |
| - t.Fatalf("pass 1: env = %q, err = %v; want LOG_LEVEL=debug", got, err) |
| + if got, err := os.ReadFile(path); err != nil || string(got) != "LOG_LEVEL=debug\nCARLOS_PURGE=header\n" { |
| + t.Fatalf("pass 1: env = %q, err = %v; want LOG_LEVEL=debug + CARLOS_PURGE=header", got, err) |
| } |
| // The operator edits the bundle: one value changed, one key added. |
| @@ -797,7 +801,7 @@ func TestSyncEnvironmentOnlyAppKeepsConvergingAfterItsOwnFirstWrite(t *testing.T |
| if err != nil { |
| t.Fatalf("pass 2: read env: %v", err) |
| } |
| - if string(got) != "LOG_LEVEL=trace\nNEW=1\n" { |
| + if string(got) != "LOG_LEVEL=trace\nNEW=1\nCARLOS_PURGE=header\n" { |
| t.Fatalf("pass 2: env = %q, want the edited bundle to converge", got) |
| } |
| if len(restarted) != 1 || restarted[0] != "fresh.example.com" { |
| @@ -897,7 +901,7 @@ func TestSyncEnvOnlyKeyRemovalIsRefusedUntilADefaultBundleExists(t *testing.T) { |
| if err := Sync(ctx, reg, s, privKey, envDir, func(string) error { return nil }); err != nil { |
| t.Fatalf("Sync (pass 2): %v", err) |
| } |
| - if got, _ := os.ReadFile(path); string(got) != "GONE=2\nKEEP=1\n" { |
| + if got, _ := os.ReadFile(path); string(got) != "GONE=2\nKEEP=1\nCARLOS_PURGE=header\n" { |
| t.Fatalf("env = %q, want the removal refused while the app has no default bundle", got) |
| } |
| // The moment a default bundle exists the app is bucket-managed and the |
| @@ -909,7 +913,7 @@ func TestSyncEnvOnlyKeyRemovalIsRefusedUntilADefaultBundleExists(t *testing.T) { |
| if err := Sync(ctx, reg, s, privKey, envDir, func(string) error { return nil }); err != nil { |
| t.Fatalf("Sync (pass 3): %v", err) |
| } |
| - if got, _ := os.ReadFile(path); string(got) != "KEEP=1\n" { |
| + if got, _ := os.ReadFile(path); string(got) != "KEEP=1\nCARLOS_PURGE=header\n" { |
| t.Fatalf("env = %q, want the removal to land once a default bundle exists", got) |
| } |
| } |
| @@ -965,7 +969,7 @@ func TestSyncMergedRenderIsAuthoritativeOnceADefaultBundleExists(t *testing.T) { |
| if err != nil { |
| t.Fatalf("read env: %v", err) |
| } |
| - if string(got) != "LOG_LEVEL=debug\n" { |
| + if string(got) != "LOG_LEVEL=debug\nCARLOS_PURGE=header\n" { |
| t.Fatalf("env = %q, want the merged render to win once a default bundle exists", got) |
| } |
| if len(restarted) != 1 || restarted[0] != "prod.example.com" { |
| @@ -1015,12 +1019,79 @@ func TestSyncEnvironmentOnlyAppWithNoDefaultBundle(t *testing.T) { |
| if err != nil { |
| t.Fatalf("%s: read rendered env: %v", host, err) |
| } |
| - if string(got) != "ONLY=here\n" { |
| - t.Fatalf("%s: rendered env = %q, want ONLY=here", host, got) |
| + if string(got) != "ONLY=here\nCARLOS_PURGE=header\n" { |
| + t.Fatalf("%s: rendered env = %q, want ONLY=here + CARLOS_PURGE=header", host, got) |
| } |
| } |
| } |
| +func TestLostKeysIgnoresAgentOwnedVars(t *testing.T) { |
| + // The sync itself appends CARLOS_PURGE=header to every file it writes |
| + // (applyAgentVars). If lostKeys counts it, every defaultMissing route |
| + // the sync previously converged reads as "about to lose a key" and is |
| + // refused forever — the false-refusal loop. |
| + cur := []byte("A=1\nCARLOS_PURGE=header\n") |
| + m := &Manifest{Schema: 1, App: "keymail", Vars: map[string]Var{"A": {Kind: "env", Value: "1"}}} |
| + lost, parsed := lostKeys(cur, m) |
| + if !parsed || len(lost) != 0 { |
| + t.Fatalf("lost=%v parsed=%v, want none/true", lost, parsed) |
| + } |
| +} |
| + |
| +// TestSyncHostConvergesAgentOwnedVarWithoutRewrite pins the convergence |
| +// property applyAgentVars must not break: syncHost's bytes.Equal |
| +// short-circuit (sync.go) has to still hold once every render carries an |
| +// appended CARLOS_PURGE=header line. The first pass writes the file |
| +// (containing the var) and restarts; an identical second pass must leave the |
| +// file's mtime and content alone and must NOT call restart again — proof |
| +// that applyAgentVars is deterministic and idempotent, not just that its |
| +// output happens to look right once. |
| +func TestSyncHostConvergesAgentOwnedVarWithoutRewrite(t *testing.T) { |
| + envDir := t.TempDir() |
| + m := &Manifest{Schema: 1, App: "keymail", Vars: map[string]Var{"A": {Kind: "env", Value: "1"}}} |
| + rt := registry.Route{Host: "steady.example.com", Kind: "instance", App: "keymail", Account: "bab", Status: "ready", Backing: registry.BackingUnit} |
| + |
| + var restarted int |
| + restart := func(string) error { restarted++; return nil } |
| + |
| + syncHost(rt, m, false, nil, envDir, restart) |
| + path := filepath.Join(envDir, "steady.example.com.env") |
| + got, err := os.ReadFile(path) |
| + if err != nil { |
| + t.Fatalf("read env after first sync: %v", err) |
| + } |
| + if string(got) != "A=1\nCARLOS_PURGE=header\n" { |
| + t.Fatalf("env = %q, want A=1 + CARLOS_PURGE=header", got) |
| + } |
| + if restarted != 1 { |
| + t.Fatalf("restarted = %d after first sync, want 1", restarted) |
| + } |
| + fi, err := os.Stat(path) |
| + if err != nil { |
| + t.Fatalf("stat after first sync: %v", err) |
| + } |
| + |
| + // Second, identical pass. |
| + syncHost(rt, m, false, nil, envDir, restart) |
| + if restarted != 1 { |
| + t.Fatalf("restarted = %d after second identical sync, want still 1 (no re-restart)", restarted) |
| + } |
| + now, err := os.Stat(path) |
| + if err != nil { |
| + t.Fatalf("stat after second sync: %v", err) |
| + } |
| + if !now.ModTime().Equal(fi.ModTime()) { |
| + t.Fatalf("env file was rewritten on an unchanged pass (mtime moved)") |
| + } |
| + got2, err := os.ReadFile(path) |
| + if err != nil { |
| + t.Fatalf("read env after second sync: %v", err) |
| + } |
| + if string(got2) != string(got) { |
| + t.Fatalf("second pass content = %q, want unchanged (%q)", got2, got) |
| + } |
| +} |
| + |
| func TestSyncMaterializesEveryRouteOfAMultiRouteApp(t *testing.T) { |
| // One app, two simultaneous routes (e.g. a stable route and a canary |
| // route are two different Hosts) — the shared config must land in |
| @@ -1056,8 +1127,8 @@ func TestSyncMaterializesEveryRouteOfAMultiRouteApp(t *testing.T) { |
| if err != nil { |
| t.Fatalf("%s: read rendered env: %v", host, err) |
| } |
| - if string(got) != "LOG_LEVEL=debug\n" { |
| - t.Fatalf("%s: rendered env = %q, want LOG_LEVEL=debug", host, got) |
| + if string(got) != "LOG_LEVEL=debug\nCARLOS_PURGE=header\n" { |
| + t.Fatalf("%s: rendered env = %q, want LOG_LEVEL=debug + CARLOS_PURGE=header", host, got) |
| } |
| } |
| } |
diff --git a/internal/appconfig/syncexec.go b/internal/appconfig/syncexec.go| index d6ea31337..7366b8424 100644 |
| --- a/internal/appconfig/syncexec.go |
| +++ b/internal/appconfig/syncexec.go |
| @@ -522,6 +522,16 @@ func syncExecHost(rt registry.Route, m *Manifest, key string, priv *ecdh.Private |
| // the instance's own <host>.env, the one file here that IS |
| // hand-staged, is a separate file layered on top at spawn and never |
| // read or written by this sweep. |
| + // Deliberately NOT run through applyAgentVars (sync.go, spec §7.3): |
| + // this is <host>.config.env, one layer under the socket-exec |
| + // instance's own <host>.env, and the activator applies withVar/ |
| + // withoutVars to CARLOS_PURGE at spawn time (backend_exec.go's |
| + // Start) — cache-CONDITIONAL, set only when b.Cache != nil. Baking |
| + // CARLOS_PURGE=header into this file unconditionally would make the |
| + // var dishonest on disk on a cacheless box, the exact failure mode |
| + // agent-ownership exists to prevent. The asymmetry with sync.go's |
| + // unit path (which does apply it, unconditionally, because cache |
| + // construction is unconditional there) is correct; do not "fix" it. |
| rendered, err := RenderEnv(m, priv) |
| if err != nil { |
| fmt.Printf(" %s: render config: %v\n", rt.Host, err) |
diff --git a/internal/edge/cache.go b/internal/edge/cache.go| index 630a183c8..d1c08cb16 100644 |
| --- a/internal/edge/cache.go |
| +++ b/internal/edge/cache.go |
| @@ -63,6 +63,7 @@ type Cache struct { |
| entries map[cacheFullKey]*list.Element |
| byURL map[cacheURLKey]map[cacheFullKey]*list.Element // urlKey -> its Vary variants |
| byPath map[cachePathKey]map[cacheFullKey]*list.Element // pathKey -> every entry under that path |
| + byHost map[string]map[cacheFullKey]*list.Element // host -> every entry for that host |
| } |
| type cacheURLKey struct { |
| @@ -136,6 +137,7 @@ func NewCache(maxEntries, maxBodyBytes int) *Cache { |
| entries: make(map[cacheFullKey]*list.Element), |
| byURL: make(map[cacheURLKey]map[cacheFullKey]*list.Element), |
| byPath: make(map[cachePathKey]map[cacheFullKey]*list.Element), |
| + byHost: make(map[string]map[cacheFullKey]*list.Element), |
| } |
| } |
| @@ -335,6 +337,10 @@ func (c *Cache) Put(key CacheKey, vary string, resp *CachedResponse) { |
| c.byPath[pk] = make(map[cacheFullKey]*list.Element) |
| } |
| c.byPath[pk][fk] = el |
| + if c.byHost[uk.Host] == nil { |
| + c.byHost[uk.Host] = make(map[cacheFullKey]*list.Element) |
| + } |
| + c.byHost[uk.Host][fk] = el |
| c.capGroupLocked(c.byURL[uk], maxVaryVariantsPerURL, el) |
| c.capGroupLocked(c.byPath[pk], maxEntriesPerPath, el) |
| @@ -389,6 +395,12 @@ func (c *Cache) removeLocked(el *list.Element) { |
| delete(c.byPath, pk) |
| } |
| } |
| + if group := c.byHost[uk.Host]; group != nil { |
| + delete(group, e.key) |
| + if len(group) == 0 { |
| + delete(c.byHost, uk.Host) |
| + } |
| + } |
| } |
| // PurgeURL drops every cached variant (every VaryValue) of (host, path, rawQuery) |
| @@ -438,23 +450,25 @@ func (c *Cache) PurgePath(host, path string) int { |
| } |
| // PurgeHost drops every cached entry for host — all paths, all query strings, |
| -// all Vary variants — and returns how many it removed. This is what a deploy |
| -// calls: a fresh process for a host means new code and potentially new |
| -// content, so everything cached from the old one is suspect. A whole-cache |
| -// scan is fine here; it happens once per spawn, not per request. |
| +// all Vary variants — and returns how many it removed. A deploy calls this: a |
| +// fresh process for a host means new code and potentially new content, so |
| +// everything cached from the old one is suspect. |
| +// |
| +// It is no longer only a deploy-time cost, though, and so no longer a |
| +// whole-cache scan: a Carlos-Purge header that names more targets than the |
| +// 64-target/8KiB caps allow degrades to PurgeHost (see purge_header.go), |
| +// which puts it on the request path. It rides the byHost index instead — the |
| +// same discipline byURL and byPath already keep in Put/removeLocked — so a |
| +// misbehaving or hostile app declaring an oversized purge cannot turn every |
| +// response into a scan of the entire cache. |
| func (c *Cache) PurgeHost(host string) int { |
| c.mu.Lock() |
| defer c.mu.Unlock() |
| - var doomed []*list.Element |
| - for key, el := range c.entries { |
| - if key.Host == host { |
| - doomed = append(doomed, el) |
| - } |
| - } |
| - for _, el := range doomed { |
| + els := elementsOf(c.byHost[host]) |
| + for _, el := range els { |
| c.removeLocked(el) |
| } |
| - return len(doomed) |
| + return len(els) |
| } |
| // elementsOf snapshots a group's elements so the caller can remove them while |
diff --git a/internal/edge/cache_test.go b/internal/edge/cache_test.go| index eb9eb087b..28b321531 100644 |
| --- a/internal/edge/cache_test.go |
| +++ b/internal/edge/cache_test.go |
| @@ -468,6 +468,35 @@ func TestCachePurgeHostRemovesEveryEntryForThatHost(t *testing.T) { |
| } |
| } |
| +// PurgeHost must ride a per-host index rather than scan every entry: a |
| +// capped Carlos-Purge header degrades to PurgeHost on the request path (see |
| +// purge_header.go), so a whole-cache scan there is request-reachable, not |
| +// just a once-per-deploy cost. |
| +func TestPurgeHostUsesPerHostIndex(t *testing.T) { |
| + c := NewCache(100, 1<<20) |
| + put := func(host, path, q string) { |
| + c.Put(CacheKey{Host: host, Method: "GET", Path: path, RawQuery: q}, "", |
| + &CachedResponse{StatusCode: 200, Header: http.Header{}, Body: []byte("x"), StoredAt: time.Now(), TTL: time.Minute}) |
| + } |
| + put("a.example", "/1", "") |
| + put("a.example", "/1", "v=2") |
| + put("a.example", "/2", "") |
| + put("b.example", "/1", "") |
| + if n := c.PurgeHost("a.example"); n != 3 { |
| + t.Fatalf("purged %d, want 3", n) |
| + } |
| + if _, ok := c.get(CacheKey{Host: "b.example", Method: "GET", Path: "/1"}); !ok { |
| + t.Fatal("b.example must survive a.example's purge") |
| + } |
| + if len(c.byHost) != 1 { |
| + t.Fatalf("byHost holds %d hosts after purge, want 1 (empty groups must be deleted)", len(c.byHost)) |
| + } |
| + c.PurgeHost("b.example") |
| + if len(c.byHost) != 0 || len(c.entries) != 0 { |
| + t.Fatal("indexes must be empty after purging everything") |
| + } |
| +} |
| + |
| // An attacker pumping distinct query strings at one path must not be able to |
| // evict the rest of the fleet's cache: the per-path cap bounds how many |
| // entries a single path can occupy, independent of the global LRU. |
diff --git a/internal/edge/edge.go b/internal/edge/edge.go| index 916d8ce09..8c5372eb7 100644 |
| --- a/internal/edge/edge.go |
| +++ b/internal/edge/edge.go |
| @@ -627,6 +627,14 @@ type recordingResponseWriter struct { |
| } |
| func (r *recordingResponseWriter) WriteHeader(status int) { |
| + // 1xx responses (103 Early Hints, forwarded by httputil.ReverseProxy) |
| + // precede the final status; latching one would record it as THE status |
| + // and maybeStore's 2xx check would then refuse the real response that |
| + // follows. Pass them through and keep waiting for the final one. |
| + if status < 200 { |
| + r.ResponseWriter.WriteHeader(status) |
| + return |
| + } |
| if !r.wroteHdr { |
| r.status = status |
| r.wroteHdr = true |
diff --git a/internal/edge/edge_test.go b/internal/edge/edge_test.go| index 651fd1df7..59008f1ef 100644 |
| --- a/internal/edge/edge_test.go |
| +++ b/internal/edge/edge_test.go |
| @@ -13,6 +13,7 @@ import ( |
| "net/http/httptest" |
| "os" |
| "path/filepath" |
| + "slices" |
| "strings" |
| "sync/atomic" |
| "syscall" |
| @@ -1412,3 +1413,54 @@ func TestCacheBeforeWakeFullCycle(t *testing.T) { |
| t.Fatalf("expected the post-purge request to miss and wake once more, wakes = %d", wakes) |
| } |
| } |
| + |
| +type mockResponseWriterFor1xx struct { |
| + header http.Header |
| + codes []int |
| +} |
| + |
| +func (m *mockResponseWriterFor1xx) Header() http.Header { |
| + return m.header |
| +} |
| + |
| +func (m *mockResponseWriterFor1xx) WriteHeader(status int) { |
| + // Stand-in for a real ResponseWriter. httptest.ResponseRecorder rejects |
| + // writes after a 1xx status (RFC 9110 forbids bodies on 1xx), but a real |
| + // ResponseWriter accepts the sequence 1xx then 2xx (103 Early Hints |
| + // followed by the final response status) and forwards both. This mock |
| + // accepts and records both. |
| + m.codes = append(m.codes, status) |
| +} |
| + |
| +func (m *mockResponseWriterFor1xx) Write(p []byte) (int, error) { |
| + return len(p), nil |
| +} |
| + |
| +func TestRecordingWriterIgnoresInformational(t *testing.T) { |
| + mock := &mockResponseWriterFor1xx{header: make(http.Header)} |
| + |
| + rec := &recordingResponseWriter{ |
| + ResponseWriter: mock, |
| + request: httptest.NewRequest("GET", "http://app.example/x", nil), |
| + cacheable: true, |
| + maxBody: 1 << 20, |
| + } |
| + rec.WriteHeader(http.StatusEarlyHints) |
| + rec.Header().Set("Cache-Control", "max-age=60") |
| + rec.WriteHeader(http.StatusOK) |
| + if _, err := rec.Write([]byte("final body")); err != nil { |
| + t.Fatal(err) |
| + } |
| + if rec.status != http.StatusOK { |
| + t.Fatalf("recorded status %d, want %d — informational must not latch", rec.status, http.StatusOK) |
| + } |
| + if !slices.Equal(mock.codes, []int{http.StatusEarlyHints, http.StatusOK}) { |
| + t.Fatalf("underlying writer saw %v, want [103 200] — the 103 must be forwarded, not swallowed", mock.codes) |
| + } |
| + e := &Edge{cache: NewCache(DefaultCacheMaxEntries, DefaultCacheMaxBodyBytes)} |
| + key := CacheKey{Host: "app.example", Method: "GET", Path: "/x"} |
| + e.maybeStore(key, rec) |
| + if _, ok := e.cache.get(key); !ok { |
| + t.Fatal("response written after a 103 was not cached") |
| + } |
| +} |
diff --git a/internal/edge/purge_header.go b/internal/edge/purge_header.go| index 0112f6697..c9afa81a6 100644 |
| --- a/internal/edge/purge_header.go |
| +++ b/internal/edge/purge_header.go |
| @@ -140,17 +140,17 @@ const PurgeHostHeader = "Carlos-Purge-Host" |
| // path: without them a single response could name a million targets and drive |
| // a million locked cache operations before the client gets its bytes. |
| // |
| -// Degrading is NOT the cheap path — it is the SIMPLE one, and it is more |
| -// expensive than honouring the declaration. Honouring 64 targets is 128 map |
| -// lookups (each target hits the GET and HEAD index groups); PurgeHost takes |
| -// the same global lock and scans EVERY entry in the cache, which at |
| -// DefaultCacheMaxEntries (4096) is an order of magnitude more work — roughly |
| -// 32x at a full cache, and worse the fuller the cache is. So an app gains |
| -// nothing by exceeding the bound except a bigger blast radius on its own |
| -// cache; the bound is a cliff in behaviour (targeted -> whole host), not an |
| -// invitation. What the caps genuinely buy is that the per-response cost has a |
| -// CEILING that does not scale with the header's length — one host scan |
| -// instead of unbounded lookups. |
| +// Degrading is NOT the cheap path — it is the SIMPLE one. Honouring 64 |
| +// targets is a bounded operation: up to 128 map lookups (each target hits |
| +// the GET and HEAD index groups) plus parsing each target string. PurgeHost |
| +// is bounded too — it rides the byHost index (see cache.go), a single map |
| +// lookup for the whole host's entry set, not a scan of the cache. Neither |
| +// path's cost is the point of the caps; what they genuinely buy is that the |
| +// per-response cost has a CEILING that does not scale with how much the |
| +// header declares, whichever path is taken. So an app gains nothing by |
| +// exceeding the bound except a bigger blast radius on its own cache — the |
| +// coarser whole-host purge, not a more precise one; the bound is a cliff in |
| +// behaviour (targeted -> whole host), not an invitation. |
| // |
| // 64 targets is well above what a real write invalidates (a page, its |
| // paginated index, a couple of locale variants). 8 KiB bounds the joined |
diff --git a/internal/host/agent.go b/internal/host/agent.go| index d73c6c32f..49d4d016d 100644 |
| --- a/internal/host/agent.go |
| +++ b/internal/host/agent.go |
| @@ -215,7 +215,7 @@ func adoptLoop(reg *registry.Registry) { |
| // hourly callers both nagging would double the log line every hour; |
| // worse, if this loop's cadence ever changes, a nag wired here changes |
| // frequency with it silently. One caller, one nag. |
| - if err := release.Pass(ctx, reg, store, pub, optDir, restartRoute); err != nil { |
| + if err := release.Pass(ctx, reg, store, pub, optDir, restartRoute, release.BakeDir(dataDir())); err != nil { |
| log.Printf("agent: adopt pass: %v", err) |
| } |
| } |
| @@ -299,7 +299,7 @@ func canaryAdoptLoop(reg *registry.Registry) { |
| // still would nag on every single tick — a journal-flooding poll storm |
| // out of what is supposed to be an hourly, easy-to-skim log line. The |
| // nag belongs to RunAdopt (carlos-adopt.service) alone. |
| - if err := release.PassFilter(ctx, reg, store, pub, optDir, restartRoute, isCanaryRoute); err != nil { |
| + if err := release.PassFilter(ctx, reg, store, pub, optDir, restartRoute, isCanaryRoute, release.BakeDir(dataDir())); err != nil { |
| log.Printf("agent: canary adopt pass: %v", err) |
| } |
| } |
| @@ -777,10 +777,10 @@ func RunAdopt(args []string) { |
| // canary-TTL nag (spec §5) belongs to the hourly root pass |
| // (carlos-adopt.service, this verb's ExecStart), the same split PR #9's |
| // config sync above uses. adoptLoop and canaryAdoptLoop in this same |
| - // package call release.Pass/PassFilter with no options at all, so an |
| - // expired canary nags once an hour from here, never every 2-3 seconds |
| - // from the in-process fast loop. |
| - if err := release.Pass(ctx, reg, store, pub, release.OptDir(), restartRoute, release.NagExpiredCanaries(os.Stdout)); err != nil { |
| + // package call release.Pass/PassFilter without the nag option (they pass |
| + // BakeDir only), so an expired canary nags once an hour from here, never |
| + // every 2-3 seconds from the in-process fast loop. |
| + if err := release.Pass(ctx, reg, store, pub, release.OptDir(), restartRoute, release.NagExpiredCanaries(os.Stdout), release.BakeDir(dataDir())); err != nil { |
| log.Fatalf("adopt: %v", err) |
| } |
diff --git a/internal/release/adopt.go b/internal/release/adopt.go| index 895ecf69d..c654bb1da 100644 |
| --- a/internal/release/adopt.go |
| +++ b/internal/release/adopt.go |
| @@ -33,6 +33,9 @@ type passConfig struct { |
| // default — every call site that does not pass NagExpiredCanaries) |
| // means the nag never fires at all, not even to a discarded writer. |
| nag io.Writer |
| + // bakeDir, when non-empty, is the directory holding the bake clock |
| + // (BakeDir option); empty keeps the legacy /opt-dir location. |
| + bakeDir string |
| } |
| // NagExpiredCanaries opts a Pass/PassFilter call into spec §5's nag: one |
| @@ -58,6 +61,23 @@ func NagExpiredCanaries(w io.Writer) PassOption { |
| return func(c *passConfig) { c.nag = w } |
| } |
| +// BakeDir moves this pass's bake clock into dir — in production the data |
| +// dir (CARLOS_DATA_DIR, default /data), the one place both the sandboxed |
| +// edge unit (ReadWritePaths=/data) and the root adopt one-shot can write. |
| +// Reads migrate: a box whose clock still lives at the legacy |
| +// /opt/carlos/channel-first-seen.json keeps every observation (issue #13). |
| +// Unset, the clock stays at its legacy path — existing tests and callers |
| +// are unaffected. |
| +// |
| +// The migration is durable after the FIRST pass this option is set on a box |
| +// still on the legacy path: PassFilter forces a Save even when no new |
| +// channel head was observed, so the new path is fully populated (and the |
| +// legacy file provably safe to delete) one pass later, not whenever the |
| +// fleet next happens to see a new head. |
| +func BakeDir(dir string) PassOption { |
| + return func(c *passConfig) { c.bakeDir = dir } |
| +} |
| + |
| // Pass is one full adoption sweep: group instance routes by (App, Channel), |
| // resolve each channel pointer once, verify signatures and artifact sha256s, |
| // download/stage missing versions, and repoint live/<host> symlinks. |
| @@ -185,7 +205,15 @@ func PassFilter(ctx context.Context, reg *registry.Registry, s Store, pub ed2551 |
| // ever ticks for pointers that verified above. |
| now := time.Now() |
| bake := LoadFirstSeen(FirstSeenPath(optDir)) |
| - changed := false |
| + if cfg.bakeDir != "" { |
| + bake = LoadFirstSeenMigrating(FirstSeenPath(cfg.bakeDir), FirstSeenPath(optDir)) |
| + } |
| + // bake.migrated forces this pass's Save even if nothing new is observed |
| + // below: otherwise a changed-gated Save could leave the new path absent |
| + // for days on a fleet that happens to see no new channel head, and the |
| + // legacy file would look safe to delete long before it actually is (see |
| + // LoadFirstSeenMigrating and BakeDir's doc comments, 2026-08-03 review). |
| + changed := bake.migrated |
| for k, ptr := range pointers { |
| if ptr != nil && bake.Observe(k.acct, k.app, k.channel, ptr.Version, now) { |
| changed = true |
diff --git a/internal/release/adopt_test.go b/internal/release/adopt_test.go| index ab541f310..f7ffb98e1 100644 |
| --- a/internal/release/adopt_test.go |
| +++ b/internal/release/adopt_test.go |
| @@ -76,6 +76,73 @@ func TestPassRepointsAwakeAndHibernating(t *testing.T) { |
| } |
| } |
| +// TestPassBakeDirMovesTheClock proves the BakeDir option's whole point |
| +// (issue #13): the bake clock lands under the given dir, not optDir's |
| +// legacy /opt-dir location. |
| +func TestPassBakeDirMovesTheClock(t *testing.T) { |
| + s := NewDirStore(t.TempDir()) |
| + ctx := context.Background() |
| + priv, pub := testKeys(t) |
| + opt := t.TempDir() |
| + bakeDir := t.TempDir() |
| + reg := testRegistry(t) |
| + |
| + shipRelease(t, s, "bab", "myapp", "v1", adoptPlatform, []byte("build one")) |
| + if err := Promote(ctx, s, priv, "bab", "myapp", "canary/x", "v1"); err != nil { |
| + t.Fatalf("Promote: %v", err) |
| + } |
| + addRoute(t, reg, "awake.example.com", "myapp", "canary/x", false) |
| + |
| + if err := Pass(ctx, reg, s, pub, opt, func(string) error { return nil }, BakeDir(bakeDir)); err != nil { |
| + t.Fatalf("Pass: %v", err) |
| + } |
| + if _, err := os.Stat(filepath.Join(bakeDir, "channel-first-seen.json")); err != nil { |
| + t.Fatalf("BakeDir option did not land the clock in bakeDir: %v", err) |
| + } |
| + if _, err := os.Stat(FirstSeenPath(opt)); !os.IsNotExist(err) { |
| + t.Fatalf("bake clock also written to the legacy opt dir despite BakeDir (stat err %v)", err) |
| + } |
| +} |
| + |
| +// TestPassMigratesBakeClockDurably proves the migration off the legacy path |
| +// completes in ONE pass even when nothing new was observed (2026-08-03 |
| +// review, IMPORTANT): Save is changed-gated, so with the head already |
| +// recorded in the legacy clock, Observe reports no change this pass and the |
| +// ordinary "tick and save" path would never write the new location — a box |
| +// could run for days with the new path absent, and deleting the legacy file |
| +// in that window would silently reset the channel's bake window. The legacy |
| +// clock here already has this exact head, so the ONLY way the new path ends |
| +// up populated is the migration forcing its own first Save. |
| +func TestPassMigratesBakeClockDurably(t *testing.T) { |
| + s := NewDirStore(t.TempDir()) |
| + ctx := context.Background() |
| + priv, pub := testKeys(t) |
| + opt := t.TempDir() |
| + bakeDir := t.TempDir() |
| + reg := testRegistry(t) |
| + |
| + shipRelease(t, s, "bab", "myapp", "v1", adoptPlatform, []byte("build one")) |
| + if err := Promote(ctx, s, priv, "bab", "myapp", "canary/x", "v1"); err != nil { |
| + t.Fatalf("Promote: %v", err) |
| + } |
| + addRoute(t, reg, "awake.example.com", "myapp", "canary/x", false) |
| + |
| + legacy := LoadFirstSeen(FirstSeenPath(opt)) |
| + legacy.Observe("bab", "myapp", "canary/x", "v1", time.Now()) |
| + if err := legacy.Save(); err != nil { |
| + t.Fatalf("seed legacy clock: %v", err) |
| + } |
| + |
| + if err := Pass(ctx, reg, s, pub, opt, func(string) error { return nil }, BakeDir(bakeDir)); err != nil { |
| + t.Fatalf("Pass: %v", err) |
| + } |
| + |
| + st := LoadFirstSeen(filepath.Join(bakeDir, "channel-first-seen.json")) |
| + if _, ok := st.EligibleAt("bab", "myapp", "canary/x", "v1", DefaultHolds); !ok { |
| + t.Fatal("migrated content did not land in the new path after one pass with no new head observed") |
| + } |
| +} |
| + |
| func TestPassPoisonedBuildSkipsRemainingHosts(t *testing.T) { |
| s := NewDirStore(t.TempDir()) |
| ctx := context.Background() |
diff --git a/internal/release/bake.go b/internal/release/bake.go| index 9def2aae6..ed87fbc43 100644 |
| --- a/internal/release/bake.go |
| +++ b/internal/release/bake.go |
| @@ -55,7 +55,14 @@ func HoldFor(holds map[string]time.Duration, channel string) time.Duration { |
| return 72 * time.Hour |
| } |
| -// FirstSeenPath is where a box persists its bake clock, under its opt dir. |
| +// FirstSeenPath is the LEGACY bake clock location, under the box's opt dir. |
| +// It is read-only for every production caller now: BakeDir moved the |
| +// writable clock to the data dir (issue #13 — the sandboxed edge unit |
| +// cannot write under optDir, and must not be able to), and |
| +// LoadFirstSeenMigrating reaches back to this path only to carry an |
| +// existing box's observations across that move. Nothing in production |
| +// writes here anymore; a box still on the legacy path only reads it once, |
| +// on the pass that migrates it. |
| func FirstSeenPath(optDir string) string { |
| return filepath.Join(optDir, "channel-first-seen.json") |
| } |
| @@ -78,6 +85,13 @@ type FirstSeen struct { |
| Channels map[string]map[string]time.Time `json:"channels"` |
| path string // where Save persists; set by LoadFirstSeen |
| + |
| + // migrated marks a clock LoadFirstSeenMigrating built from its |
| + // legacy-fallback branch (the new path didn't exist yet). PassFilter |
| + // checks this to force an immediate Save even when Observe reports no |
| + // change this pass — see LoadFirstSeenMigrating's doc comment for why |
| + // a changed-gated Save alone isn't enough to make the migration durable. |
| + migrated bool |
| } |
| // channelClockKey identifies one channel head's clock. The ACCOUNT is part of |
| @@ -129,6 +143,34 @@ func LoadFirstSeen(path string) *FirstSeen { |
| return st |
| } |
| +// LoadFirstSeenMigrating reads the bake clock from path, reaching back to |
| +// legacyPath for its CONTENT when path does not exist yet — the one-time |
| +// migration off /opt/carlos (issue #13: the sandboxed edge cannot write the |
| +// version store, and must not be able to; the clock belongs in the data |
| +// dir). Save always writes path. The legacy file is never written or |
| +// deleted: an older binary still running against it keeps a working clock, |
| +// and this binary simply stops advancing it. |
| +// |
| +// The returned clock is marked migrated, which its caller (PassFilter) uses |
| +// to force that first Save even when nothing new was observed this pass — |
| +// Save is otherwise changed-gated (bake.go/adopt.go), and a box whose fleet |
| +// happens to see no new channel head could otherwise run for days with path |
| +// still absent, still re-reading legacyPath every pass. That window matters |
| +// because it invites deleting the legacy file as soon as this binary ships, |
| +// which — before the migrated Save lands — would silently wipe every stable |
| +// channel's observations and freeze the fleet on a fresh 72h window. Forcing |
| +// the first Save makes migration complete (path fully populated, |
| +// legacyPath now provably disposable) after exactly one pass. |
| +func LoadFirstSeenMigrating(path, legacyPath string) *FirstSeen { |
| + if _, err := os.Stat(path); os.IsNotExist(err) { |
| + st := LoadFirstSeen(legacyPath) |
| + st.path = path |
| + st.migrated = true |
| + return st |
| + } |
| + return LoadFirstSeen(path) |
| +} |
| + |
| // Observe records a channel head this box hasn't seen at that head before. |
| // Returns whether anything changed (the caller persists via Save). Never |
| // call it on a pointer that failed verification — the clock only ever ticks |
| @@ -166,13 +208,64 @@ func (st *FirstSeen) Observe(acct, app, channel, version string, now time.Time) |
| return changed |
| } |
| -// Save persists the clock back to the path it was loaded from. |
| +// Save persists the clock back to the path it was loaded from, via a |
| +// temp-file-in-the-same-dir + rename — the same co-written-file pattern |
| +// internal/appconfig/sync.go and syncexec.go already use for /data files |
| +// more than one uid writes (2026-08-03 review, issue #13): carlos-edge.service |
| +// runs as `carlos`, the root adopt one-shot runs as root, and BakeDir makes |
| +// both write this same path. An in-place O_TRUNC write needs write |
| +// permission on the EXISTING file, so whichever uid creates it first (0644, |
| +// owned by that uid) locks the other one out — EACCES with nothing to |
| +// repair the ownership (bootstrap only chowns /data once, at bootstrap). |
| +// rename(2), by contrast, only requires write permission on the containing |
| +// directory, which both uids have on /data, so either side can always |
| +// replace the file — and a crash mid-write leaves the old content intact |
| +// rather than a torn, half-written file. |
| +// |
| +// The temp name is minted fresh per attempt (os.CreateTemp's "channel- |
| +// first-seen-*.json"), never a fixed "<path>.tmp": two uids can each crash |
| +// between write and rename, and a fixed name would leave cross-uid residue — |
| +// a root-owned 0644 leftover that the carlos-uid loop then fails to |
| +// O_TRUNC-rewrite forever (EACCES, no repair path short of root's next |
| +// pass), aborting its whole Pass every tick until then. A unique name per |
| +// attempt means a crash never leaves a temp file another uid's next Save |
| +// must contend with. CreateTemp also mints its file 0600, which this |
| +// explicitly widens to 0644 with Chmod BEFORE the rename: a 0600 root:root |
| +// clock is unreadable by the carlos-uid loop, and LoadFirstSeen turns that |
| +// read failure into a silently EMPTY clock — not an error, just every |
| +// channel head baking from "now" again, freezing stable adoption for a |
| +// fresh 72h. Any failure path removes the temp file rather than leaving it |
| +// behind for the next Save to trip over. |
| func (st *FirstSeen) Save() error { |
| raw, err := json.MarshalIndent(st, "", " ") |
| if err != nil { |
| return err |
| } |
| - return os.WriteFile(st.path, append(raw, '\n'), 0o644) |
| + raw = append(raw, '\n') |
| + f, err := os.CreateTemp(filepath.Dir(st.path), "channel-first-seen-*.json") |
| + if err != nil { |
| + return err |
| + } |
| + tmp := f.Name() |
| + _, writeErr := f.Write(raw) |
| + closeErr := f.Close() |
| + if writeErr != nil { |
| + os.Remove(tmp) |
| + return writeErr |
| + } |
| + if closeErr != nil { |
| + os.Remove(tmp) |
| + return closeErr |
| + } |
| + if err := os.Chmod(tmp, 0o644); err != nil { |
| + os.Remove(tmp) |
| + return err |
| + } |
| + if err := os.Rename(tmp, st.path); err != nil { |
| + os.Remove(tmp) // don't leave a stray temp file behind |
| + return err |
| + } |
| + return nil |
| } |
| // EligibleAt is the moment a version first seen at a channel head may be |
diff --git a/internal/release/bake_test.go b/internal/release/bake_test.go| index 1217e5734..90077ebf6 100644 |
| --- a/internal/release/bake_test.go |
| +++ b/internal/release/bake_test.go |
| @@ -1,6 +1,7 @@ |
| package release |
| import ( |
| + "bytes" |
| "context" |
| "encoding/json" |
| "os" |
| @@ -244,3 +245,114 @@ func TestBakeClockReadsPreAccountRecordsAsOps(t *testing.T) { |
| t.Fatal("a pre-accounts record satisfied a different account's window") |
| } |
| } |
| + |
| +func TestLoadFirstSeenMigrating(t *testing.T) { |
| + now := time.Date(2026, 8, 3, 12, 0, 0, 0, time.UTC) |
| + |
| + t.Run("prefers the new path when it exists", func(t *testing.T) { |
| + dir := t.TempDir() |
| + newPath := filepath.Join(dir, "new.json") |
| + oldPath := filepath.Join(dir, "old.json") |
| + stNew := LoadFirstSeen(newPath) |
| + stNew.Observe("acct", "app", "stable", "v-new", now) |
| + if err := stNew.Save(); err != nil { |
| + t.Fatal(err) |
| + } |
| + stOld := LoadFirstSeen(oldPath) |
| + stOld.Observe("acct", "app", "stable", "v-old", now) |
| + if err := stOld.Save(); err != nil { |
| + t.Fatal(err) |
| + } |
| + st := LoadFirstSeenMigrating(newPath, oldPath) |
| + if _, ok := st.EligibleAt("acct", "app", "stable", "v-new", nil); !ok { |
| + t.Fatal("new path's observation missing") |
| + } |
| + if _, ok := st.EligibleAt("acct", "app", "stable", "v-old", nil); ok { |
| + t.Fatal("legacy content leaked in despite the new path existing") |
| + } |
| + }) |
| + |
| + t.Run("falls back to legacy content, saves to the new path, leaves legacy untouched", func(t *testing.T) { |
| + dir := t.TempDir() |
| + newPath := filepath.Join(dir, "new.json") |
| + oldPath := filepath.Join(dir, "old.json") |
| + stOld := LoadFirstSeen(oldPath) |
| + stOld.Observe("acct", "app", "stable", "v1", now) |
| + if err := stOld.Save(); err != nil { |
| + t.Fatal(err) |
| + } |
| + legacyRaw, err := os.ReadFile(oldPath) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + st := LoadFirstSeenMigrating(newPath, oldPath) |
| + if _, ok := st.EligibleAt("acct", "app", "stable", "v1", nil); !ok { |
| + t.Fatal("legacy observation not carried over") |
| + } |
| + if err := st.Save(); err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := os.Stat(newPath); err != nil { |
| + t.Fatalf("Save did not write the new path: %v", err) |
| + } |
| + afterRaw, err := os.ReadFile(oldPath) |
| + if err != nil || !bytes.Equal(legacyRaw, afterRaw) { |
| + t.Fatalf("legacy file must be byte-identical after migration (err %v)", err) |
| + } |
| + }) |
| + |
| + t.Run("both missing is a fresh clock at the new path", func(t *testing.T) { |
| + dir := t.TempDir() |
| + newPath := filepath.Join(dir, "new.json") |
| + st := LoadFirstSeenMigrating(newPath, filepath.Join(dir, "absent.json")) |
| + st.Observe("acct", "app", "stable", "v1", now) |
| + if err := st.Save(); err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := os.Stat(newPath); err != nil { |
| + t.Fatalf("Save must create the new path: %v", err) |
| + } |
| + }) |
| +} |
| + |
| +// TestFirstSeenSaveReplacesFileItCannotWriteInPlace stands in for the real |
| +// production race (2026-08-03 review, CRITICAL): carlos-edge.service writes |
| +// this path as user `carlos`, the root adopt one-shot writes it as root — |
| +// whichever creates the file first locks the other uid out, because an |
| +// in-place O_TRUNC write needs write permission on the FILE, and a 0644 file |
| +// owned by the other uid has none for anyone else. A file this process |
| +// cannot open for writing (0o444, no write bit for anyone, including its own |
| +// owner) reproduces the same "cannot write in place" shape without needing a |
| +// second uid: rename(2) only requires write permission on the containing |
| +// directory, never on the file being replaced, so a Save implemented as |
| +// temp-file+rename must still succeed here where a truncating write cannot. |
| +func TestFirstSeenSaveReplacesFileItCannotWriteInPlace(t *testing.T) { |
| + path := filepath.Join(t.TempDir(), "clock.json") |
| + if err := os.WriteFile(path, []byte("{}"), 0o444); err != nil { |
| + t.Fatal(err) |
| + } |
| + st := LoadFirstSeen(path) |
| + st.Observe("acct", "app", "stable", "v1", time.Now()) |
| + if err := st.Save(); err != nil { |
| + t.Fatalf("Save must replace via rename, not an in-place write: %v", err) |
| + } |
| + // The temp name is minted fresh per attempt (CreateTemp's |
| + // "channel-first-seen-*.json"), not a fixed "<path>.tmp" — glob for the |
| + // pattern rather than stat-ing one fixed name. |
| + if matches, err := filepath.Glob(filepath.Join(filepath.Dir(path), "channel-first-seen-*.json")); err != nil { |
| + t.Fatalf("glob for stray temp files: %v", err) |
| + } else if len(matches) != 0 { |
| + t.Fatalf("Save left a temp file behind: %v", matches) |
| + } |
| + raw, err := os.ReadFile(path) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + var reloaded FirstSeen |
| + if err := json.Unmarshal(raw, &reloaded); err != nil { |
| + t.Fatalf("Save produced invalid/partial content: %v", err) |
| + } |
| + if _, ok := reloaded.Channels[channelClockKey("acct", "app", "stable")]["v1"]; !ok { |
| + t.Fatal("observation missing after Save") |
| + } |
| +} |