settings-1 task 8: one pointer digest ratchet per process, armed by root and the agent
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
5 files changed,
+180
−2
internal/host/agent.go+6 −2internal/host/pointerdigests.go+17 −0internal/host/pointerdigests_test.go+100 −0internal/release/ratchet_shared.go+27 −0internal/release/ratchet_test.go+30 −0
diff --git a/internal/host/agent.go b/internal/host/agent.go| index c713732d2..680988c13 100644 |
| --- a/internal/host/agent.go |
| +++ b/internal/host/agent.go |
| @@ -918,7 +918,8 @@ func materializeHook(reg *registry.Registry, dd string) func(context.Context, st |
| // The process's one proof gate (proofask.go), so a cold wake cannot |
| // adopt an unproven build of a listed app. |
| return release.MaterializeHost(ctx, reg, store, pub, optDir, dd, host, |
| - release.OnStageBlocked(stageReq), release.WithProofGate(agentProofGate())) |
| + release.OnStageBlocked(stageReq), release.WithProofGate(agentProofGate()), |
| + release.WithDigestRatchet(pointerRatchet(false, optDir, dd, os.Getenv))) |
| } |
| } |
| @@ -1000,7 +1001,8 @@ func adoptLoop(reg *registry.Registry, svc *activator.Service, sd *scheduleDeps, |
| release.RestartHibernating(restart), |
| release.WithStartUnit(makeStartRoute(reg, systemctlStartUnit)), |
| release.WithRollbackRestart(rollbackUnitRestart(reg, systemdUnits{}, optDir, os.Getenv)), |
| - release.WithProofGate(agentProofGate()), release.WithHostWrites(writes)); err != nil { |
| + release.WithProofGate(agentProofGate()), release.WithHostWrites(writes), |
| + release.WithDigestRatchet(pointerRatchet(false, optDir, dataDir(), os.Getenv))); err != nil { |
| log.Printf("agent: adopt pass: %v", err) |
| } |
| // The schedule converge's hourly floor (schedule spec §6.2), AFTER the |
| @@ -1673,6 +1675,7 @@ func fastPassOpts(dd string, stageReq func(host, ver string, err error), restart |
| release.OnStageBlocked(stageReq), |
| release.RestartHibernating(restart), |
| release.WithStartUnit(start), |
| + release.WithDigestRatchet(pointerRatchet(false, release.OptDir(), dd, os.Getenv)), |
| } |
| } |
| @@ -4147,6 +4150,7 @@ func runAdoptWithEnsureSystemUnits(args []string, buildClaims ClaimFactory, ensu |
| // a relayed stage is exactly the request that performs such a restart. |
| passOpts := []release.PassOption{release.WithCapabilities(caps.Ops{}), release.BakeDir(dataDir()), |
| release.WithStartUnit(makeStartRoute(reg, systemctlStartUnit)), release.WithHostWrites(claims.Writes), |
| + release.WithDigestRatchet(pointerRatchet(true, release.OptDir(), dataDir(), os.Getenv)), |
| release.WithRollbackRestart(rollbackUnitRestart(reg, systemdUnits{}, release.OptDir(), os.Getenv)), |
| // D3's gate: a fresh gate per root pass, so the policy file is read |
| // at the start of this pass (BeginPass) like every other. Its bake |
diff --git a/internal/host/pointerdigests.go b/internal/host/pointerdigests.go| new file mode 100644 |
| index 000000000..cafe317e5 |
| --- /dev/null |
| +++ b/internal/host/pointerdigests.go |
| @@ -0,0 +1,17 @@ |
| +package host |
| + |
| +import "github.com/carlosframework/platform/internal/release" |
| + |
| +// pointerRatchet is #253's ratchet for this process (spec §4.6), the one |
| +// SharedDigestRatchet keeps, so a fresh call per pass is the same object. |
| +// Root writes its marks under /opt (root-owned); the agent, sandboxed out of |
| +// /opt, writes under its data dir. Each reads both, so a mark either side |
| +// wrote protects the whole box. CARLOS_REQUIRE_POINTER_DIGEST applies to both. |
| +func pointerRatchet(root bool, optDir, dataDir string, getenv func(string) string) *release.DigestRatchet { |
| + rootDir, edgeDir := release.RootRatchetDir(optDir), release.EdgeRatchetDir(dataDir) |
| + require := release.RequirePointerDigest(getenv) |
| + if root { |
| + return release.SharedDigestRatchet(rootDir, []string{edgeDir}, require) |
| + } |
| + return release.SharedDigestRatchet(edgeDir, []string{rootDir}, require) |
| +} |
diff --git a/internal/host/pointerdigests_test.go b/internal/host/pointerdigests_test.go| new file mode 100644 |
| index 000000000..bfbcc562f |
| --- /dev/null |
| +++ b/internal/host/pointerdigests_test.go |
| @@ -0,0 +1,100 @@ |
| +package host |
| + |
| +import ( |
| + "context" |
| + "errors" |
| + "os" |
| + "path/filepath" |
| + "runtime" |
| + "testing" |
| + |
| + "github.com/carlosframework/platform/internal/registry" |
| + "github.com/carlosframework/platform/internal/release" |
| +) |
| + |
| +func runtimePlatform() string { return runtime.GOOS + "-" + runtime.GOARCH } |
| + |
| +// Astra I2 #1: the agent cannot persist its mark; the next pass, built the |
| +// way production builds it, still refuses the digestless replay. |
| +func TestOneRatchetPerProcessSurvivesAFailedPersist(t *testing.T) { |
| + if os.Geteuid() == 0 { |
| + t.Skip("root can write a 0555 directory") |
| + } |
| + ctx := context.Background() |
| + opt, data := t.TempDir(), t.TempDir() |
| + if err := os.MkdirAll(release.EdgeRatchetDir(data), 0o555); err != nil { |
| + t.Fatal(err) |
| + } |
| + store := release.NewDirStore(t.TempDir()) |
| + seed, pubB64, _ := release.GenerateKey() |
| + priv, _ := release.ParseSigningKey(seed, "t") |
| + pub, _ := release.ParsePublicKey(pubB64, "t") |
| + ship := func(v string) { |
| + m := release.Manifest{Schema: 1, App: "km", Version: v, Kind: "binary", |
| + Artifacts: map[string]release.Artifact{runtimePlatform(): {}}} |
| + if err := release.Ship(ctx, store, "bab", m, map[string][]byte{runtimePlatform(): []byte(v)}); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + ship("v1") |
| + if err := release.Promote(ctx, store, priv, "bab", "km", "canary/x", "v1"); err != nil { // legacy pointer |
| + t.Fatal(err) |
| + } |
| + legacy, _, _ := store.Get(ctx, release.ChannelKey("bab", "km", "canary/x")) |
| + if err := release.WriteDigestMarker(ctx, store, release.RootSigner(priv), "bab", "t"); err != nil { |
| + t.Fatal(err) |
| + } |
| + ship("v2") |
| + if err := release.Promote(ctx, store, priv, "bab", "km", "canary/x", "v2"); err != nil { // with digests |
| + t.Fatal(err) |
| + } |
| + reg, err := registry.Open(filepath.Join(t.TempDir(), "r.db")) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := reg.Upsert(registry.Route{Host: "a.example.com", Kind: "instance", App: "km", Account: "bab", Channel: "canary/x"}); err != nil { |
| + t.Fatal(err) |
| + } |
| + off := func(string) string { return "" } |
| + pass := func() { |
| + opts := []release.PassOption{release.WithDigestRatchet(pointerRatchet(false, opt, data, off))} // as fastPassOpts builds it, every pass |
| + if err := release.PassFilter(ctx, reg, store, pub, opt, nil, nil, opts...); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + pass() // the digest pointer: its mark cannot be written, so it is refused |
| + if v, _ := release.LiveVersion(opt, "a.example.com"); v != "" { |
| + t.Fatalf("installed %q on a pointer whose mark was not durable", v) |
| + } |
| + _, etag, _ := store.Get(ctx, release.ChannelKey("bab", "km", "canary/x")) |
| + if _, err := store.Put(ctx, release.ChannelKey("bab", "km", "canary/x"), legacy, etag); err != nil { |
| + t.Fatal(err) |
| + } |
| + pass() // the replay, on a new pass |
| + if v, _ := release.LiveVersion(opt, "a.example.com"); v != "" { |
| + t.Fatalf("a later pass in the same process installed the digestless replay %q", v) |
| + } |
| +} |
| + |
| +func TestPointerRatchetSharesMarksBetweenRootAndTheAgent(t *testing.T) { |
| + opt, data := t.TempDir(), t.TempDir() |
| + off := func(string) string { return "" } |
| + p := &release.ChannelPointer{App: "km", Channel: "stable", Version: "v1", Digest: release.ManifestDigest([]byte("m"))} |
| + if err := pointerRatchet(true, opt, data, off).Admit("bab", p); err != nil { |
| + t.Fatal(err) |
| + } |
| + legacy := &release.ChannelPointer{App: "km", Channel: "stable", Version: "v1"} |
| + if err := pointerRatchet(false, opt, data, off).Admit("bab", legacy); !errors.Is(err, release.ErrPointerWithoutDigests) { |
| + t.Fatalf("the agent ignored root's mark: %v", err) |
| + } |
| + on := func(k string) string { |
| + if k == release.RequirePointerDigestEnv { |
| + return "on" |
| + } |
| + return "" |
| + } |
| + fresh := &release.ChannelPointer{App: "km", Channel: "beta", Version: "v1"} |
| + if err := pointerRatchet(false, opt, data, on).Admit("bab", fresh); !errors.Is(err, release.ErrPointerWithoutDigests) { |
| + t.Fatalf("the flag was not read: %v", err) |
| + } |
| +} |
diff --git a/internal/release/ratchet_shared.go b/internal/release/ratchet_shared.go| new file mode 100644 |
| index 000000000..d3725027b |
| --- /dev/null |
| +++ b/internal/release/ratchet_shared.go |
| @@ -0,0 +1,27 @@ |
| +package release |
| + |
| +import "sync" |
| + |
| +var ( |
| + sharedMu sync.Mutex |
| + sharedRatchets = map[string]*DigestRatchet{} |
| +) |
| + |
| +// SharedDigestRatchet is the process's one ratchet for marks written under |
| +// own (Astra I2 #1): every pass, materialization, held stage and static read |
| +// in this process gets the same object, so a channel remembered after a mark |
| +// could not be persisted stays remembered. require, once on, stays on. |
| +func SharedDigestRatchet(own string, also []string, require bool) *DigestRatchet { |
| + sharedMu.Lock() |
| + defer sharedMu.Unlock() |
| + r, ok := sharedRatchets[own] |
| + if !ok { |
| + r = NewDigestRatchet(own, also, require) |
| + sharedRatchets[own] = r |
| + return r |
| + } |
| + if require { |
| + r.require.Store(true) // atomic: Admit may be reading it right now (Astra I3 #7) |
| + } |
| + return r |
| +} |
diff --git a/internal/release/ratchet_test.go b/internal/release/ratchet_test.go| index 4bd0fc965..5f0ef6926 100644 |
| --- a/internal/release/ratchet_test.go |
| +++ b/internal/release/ratchet_test.go |
| @@ -4,6 +4,7 @@ import ( |
| "errors" |
| "os" |
| "path/filepath" |
| + "sync" |
| "testing" |
| ) |
| @@ -121,3 +122,32 @@ func TestRatchetDirs(t *testing.T) { |
| t.Fatal("ratchet dirs moved") |
| } |
| } |
| + |
| +func TestSharedRatchetIsOnePerDirectory(t *testing.T) { |
| + d := t.TempDir() |
| + if SharedDigestRatchet(d, nil, false) != SharedDigestRatchet(d, nil, false) { |
| + t.Fatal("two calls for the same directory built two ratchets") |
| + } |
| +} |
| + |
| +// Astra I3 #7: lookups that widen require race with Admit's read on the one |
| +// shared object. Run under -race; with a plain bool field the detector fails it. |
| +func TestSharedRatchetLookupAndAdmitAreRaceFree(t *testing.T) { |
| + d := t.TempDir() |
| + legacy := &ChannelPointer{App: "km", Channel: "stable", Version: "v1"} |
| + var wg sync.WaitGroup |
| + for i := 0; i < 16; i++ { |
| + wg.Add(1) |
| + go func(i int) { |
| + defer wg.Done() |
| + for j := 0; j < 200; j++ { |
| + r := SharedDigestRatchet(d, nil, i%2 == 0) |
| + _ = r.Admit("bab", legacy) |
| + } |
| + }(i) |
| + } |
| + wg.Wait() |
| + if err := SharedDigestRatchet(d, nil, false).Admit("bab", legacy); !errors.Is(err, ErrPointerWithoutDigests) { |
| + t.Fatalf("require, widened by a lookup, was lost: %v", err) |
| + } |
| +} |