release: bake clock lives in the data dir — the sandboxed edge could never write /opt (#13)
carlos-edge.service runs ProtectSystem=strict with ReadWritePaths=/data /run/carlos, so the in-process adopt loops could never write /opt/carlos/channel-first-seen.json (1,244 errors/day on flagship), and the failed bake.Save() aborted the whole adopt pass on top of it. Adds LoadFirstSeenMigrating (reads path, falling back to legacyPath's content when path doesn't exist yet; always saves to path) and a BakeDir PassOption that points a Pass/PassFilter call at the data dir. Wires it into all three production callers in host/agent.go. The legacy /opt file is never written or deleted, so an older binary still reading it keeps a working clock.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
5 files changed,
+135
−3
internal/host/agent.go+3 −3internal/release/adopt.go+18 −0internal/release/adopt_test.go+28 −0internal/release/bake.go+16 −0internal/release/bake_test.go+70 −0
diff --git a/internal/host/agent.go b/internal/host/agent.go| index 7edbdcc9d..12914a8ad 100644 |
| --- a/internal/host/agent.go |
| +++ b/internal/host/agent.go |
| @@ -213,7 +213,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) |
| } |
| } |
| @@ -297,7 +297,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) |
| } |
| } |
| @@ -778,7 +778,7 @@ func RunAdopt(args []string) { |
| // 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 { |
| + 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..037bf260d 100644 |
| --- a/internal/release/adopt.go |
| +++ b/internal/release/adopt.go |
| @@ -11,6 +11,7 @@ import ( |
| "fmt" |
| "io" |
| "os" |
| + "path/filepath" |
| "runtime" |
| "time" |
| @@ -33,6 +34,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 +62,17 @@ 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. |
| +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,6 +200,9 @@ 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)) |
| + if cfg.bakeDir != "" { |
| + bake = LoadFirstSeenMigrating(filepath.Join(cfg.bakeDir, "channel-first-seen.json"), FirstSeenPath(optDir)) |
| + } |
| changed := false |
| for k, ptr := range pointers { |
| if ptr != nil && bake.Observe(k.acct, k.app, k.channel, ptr.Version, now) { |
diff --git a/internal/release/adopt_test.go b/internal/release/adopt_test.go| index ab541f310..ecc1f7c63 100644 |
| --- a/internal/release/adopt_test.go |
| +++ b/internal/release/adopt_test.go |
| @@ -76,6 +76,34 @@ 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) |
| + } |
| +} |
| + |
| 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..d58cd4585 100644 |
| --- a/internal/release/bake.go |
| +++ b/internal/release/bake.go |
| @@ -129,6 +129,22 @@ 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. |
| +func LoadFirstSeenMigrating(path, legacyPath string) *FirstSeen { |
| + if _, err := os.Stat(path); os.IsNotExist(err) { |
| + st := LoadFirstSeen(legacyPath) |
| + st.path = path |
| + 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 |
diff --git a/internal/release/bake_test.go b/internal/release/bake_test.go| index 1217e5734..485fb9a60 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,72 @@ 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) |
| + } |
| + }) |
| +} |