host: ambient retention pass — slow loop pruning per hosted app, box pins protected
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
3 files changed,
+231
−0
internal/host/agent.go+38 −0internal/host/retention.go+77 −0internal/host/retention_test.go+116 −0
diff --git a/internal/host/agent.go b/internal/host/agent.go| index 94a784712..b105edc71 100644 |
| --- a/internal/host/agent.go |
| +++ b/internal/host/agent.go |
| @@ -171,6 +171,7 @@ func RunAgent(args []string) { |
| go statusLoop(reg) |
| go domainsLoop(reg, peekAcmeCache(args)) |
| go instancesLoop(reg) |
| + go retentionLoop(reg) |
| go metricsLoop(cgm.Root()) |
| go configSyncLoop(reg, svc, dd) |
| go configGenLoop(reg, svc, dd) |
| @@ -1318,6 +1319,43 @@ func instancesLoop(reg *registry.Registry) { |
| } |
| } |
| +// retentionLoop runs the release-retention pass forever on the slow |
| +// reconcile cadence (5min + jitter, first tick after 1 minute) — never the |
| +// 2s fast pass: pruning is not urgent, and a policy written now is enforced |
| +// within minutes, which is the promise the CLI makes ("boxes prune within |
| +// ~10 minutes"). Thin and untested by convention (like domainsLoop / |
| +// instancesLoop); everything with behavior lives in retentionPass. Missing |
| +// deployment config or a missing/unparseable release pubkey disables the |
| +// loop with one log line — fail closed, domainsLoop's auto-purge shape: a |
| +// box that cannot verify pointers must not delete anything. |
| +func retentionLoop(reg *registry.Registry) { |
| + ctx := context.Background() |
| + store, err := openDeployStore(ctx) |
| + if err != nil { |
| + log.Printf("agent: retention loop disabled: %v", err) |
| + return |
| + } |
| + raw := strings.TrimSpace(os.Getenv("CARLOS_RELEASE_PUBKEY")) |
| + if raw == "" { |
| + log.Printf("agent: retention loop disabled: CARLOS_RELEASE_PUBKEY is not set — retention policies will not be enforced by this box") |
| + return |
| + } |
| + pub, err := release.ParsePublicKey(raw, "CARLOS_RELEASE_PUBKEY") |
| + if err != nil { |
| + log.Printf("agent: retention loop disabled: %v — retention policies will not be enforced by this box", err) |
| + return |
| + } |
| + log.Printf("agent: retention loop armed (5min + jitter)") |
| + delay := time.Minute |
| + for { |
| + time.Sleep(delay) |
| + delay = 5*time.Minute + rand.N(time.Minute) |
| + if err := retentionPass(ctx, store, pub, reg); err != nil { |
| + log.Printf("agent: retention pass: %v", err) |
| + } |
| + } |
| +} |
| + |
| // resolveStatusBucket applies the status/deployment bucket defaulting |
| // documented in this file's package comment: an explicit CARLOS_STATUS_BUCKET |
| // always wins, and when it's unset the status loop falls back to whatever |
diff --git a/internal/host/retention.go b/internal/host/retention.go| new file mode 100644 |
| index 000000000..85e669fe2 |
| --- /dev/null |
| +++ b/internal/host/retention.go |
| @@ -0,0 +1,77 @@ |
| +// Ambient release retention, the box half (issue #136, spec |
| +// 2026-08-10-releases-retention-design.md §6): for each app this box has |
| +// routes for, read the app's retention record from the bucket and delete |
| +// what release.PruneEligible says is safe — protecting, on top of the |
| +// bucket-derived safety list, the versions THIS box's registry still |
| +// records as adopted or previous. Boxes race each other harmlessly: every |
| +// delete is idempotent by store contract. |
| +// |
| +// Hosted-apps-only is a ruling, not an accident: an app with routes on no |
| +// box has nothing at stake, and whole-app trash→purge owns its end of life. |
| +package host |
| + |
| +import ( |
| + "context" |
| + "crypto/ed25519" |
| + "log" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/registry" |
| + "github.com/carlosframework/platform/internal/release" |
| +) |
| + |
| +// retentionPass runs one enforcement sweep. Per-app isolation: one app's |
| +// error is logged and skipped, never allowed to stop another app's |
| +// enforcement; a partial delete leaves the remainder for the next tick. |
| +func retentionPass(ctx context.Context, store release.Store, pub ed25519.PublicKey, reg *registry.Registry) error { |
| + routes, err := reg.List() |
| + if err != nil { |
| + return err |
| + } |
| + type appKey struct{ acct, app string } |
| + pins := map[appKey]map[string]bool{} |
| + for _, rt := range routes { |
| + if rt.Account == "" || rt.App == "" { |
| + continue |
| + } |
| + k := appKey{rt.Account, rt.App} |
| + if pins[k] == nil { |
| + pins[k] = map[string]bool{} |
| + } |
| + if rt.Version != "" { |
| + pins[k][rt.Version] = true |
| + } |
| + if rt.PrevVersion != "" { |
| + pins[k][rt.PrevVersion] = true |
| + } |
| + } |
| + for k, protected := range pins { |
| + ret, err := release.GetRetention(ctx, store, k.acct, k.app) |
| + if err != nil { |
| + log.Printf("agent: retention %s/%s: %v", k.acct, k.app, err) |
| + continue |
| + } |
| + if ret == nil { |
| + continue |
| + } |
| + eligible, err := release.PruneEligible(ctx, store, pub, k.acct, k.app, ret.Keep, time.Now(), protected) |
| + if err != nil { |
| + log.Printf("agent: retention %s/%s: %v", k.acct, k.app, err) |
| + continue |
| + } |
| + if len(eligible) == 0 { |
| + continue |
| + } |
| + deleted := 0 |
| + for _, v := range eligible { |
| + if err := release.DeleteVersion(ctx, store, k.acct, k.app, v); err != nil { |
| + log.Printf("agent: retention %s/%s: delete %s: %v", k.acct, k.app, v, err) |
| + break |
| + } |
| + deleted++ |
| + } |
| + log.Printf("agent: retention: pruned %d of %d eligible versions for %s/%s (keep %d)", |
| + deleted, len(eligible), k.acct, k.app, ret.Keep) |
| + } |
| + return nil |
| +} |
diff --git a/internal/host/retention_test.go b/internal/host/retention_test.go| new file mode 100644 |
| index 000000000..4cbb96f35 |
| --- /dev/null |
| +++ b/internal/host/retention_test.go |
| @@ -0,0 +1,116 @@ |
| +package host |
| + |
| +import ( |
| + "context" |
| + "crypto/ed25519" |
| + "os" |
| + "path/filepath" |
| + "testing" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/registry" |
| + "github.com/carlosframework/platform/internal/release" |
| +) |
| + |
| +// stampKey writes key into the DirStore and pins its mtime, versions_test's |
| +// modStamp recipe (that helper is package release; this is the host-side twin). |
| +func stampKey(t *testing.T, s *release.DirStore, dir, key string, mod time.Time) { |
| + t.Helper() |
| + if _, err := s.Put(context.Background(), key, []byte(`x`), ""); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := os.Chtimes(filepath.Join(dir, filepath.FromSlash(key)), mod, mod); err != nil { |
| + t.Fatal(err) |
| + } |
| +} |
| + |
| +func retentionKeys(t *testing.T) (ed25519.PrivateKey, ed25519.PublicKey) { |
| + t.Helper() |
| + seedB64, pubB64, err := release.GenerateKey() |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + priv, err := release.ParseSigningKey(seedB64, "test") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + pub, err := release.ParsePublicKey(pubB64, "test") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + return priv, pub |
| +} |
| + |
| +func TestRetentionPassPrunesEligibleAndProtectsBoxPins(t *testing.T) { |
| + ctx := context.Background() |
| + dir := t.TempDir() |
| + s := release.NewDirStore(dir) |
| + now := time.Now() |
| + base := now.Add(-30 * 24 * time.Hour) |
| + vname := func(i int) string { return "v" + string(rune('0'+i/10)) + string(rune('0'+i%10)) } |
| + for i := 1; i <= 25; i++ { |
| + stampKey(t, s, dir, release.ManifestKey("bab", "kass", vname(i)), base.Add(time.Duration(i)*time.Hour)) |
| + stampKey(t, s, dir, release.ArtifactKey("bab", "kass", vname(i), "linux-arm64"), base.Add(time.Duration(i)*time.Hour)) |
| + } |
| + _, pub := retentionKeys(t) |
| + if err := release.SetRetention(ctx, s, "bab", "kass", 20, "test", now); err != nil { |
| + t.Fatal(err) |
| + } |
| + reg, err := registry.Open(filepath.Join(t.TempDir(), "registry.db")) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + defer reg.Close() |
| + // This box runs kass at v03 (previous v02): both are pins, even though |
| + // both rank outside keep-20. |
| + if err := reg.Upsert(registry.Route{ |
| + Host: "kass.example.com", Kind: "instance", App: "kass", Account: "bab", |
| + Version: "v03", PrevVersion: "v02", Status: "ready", |
| + }); err != nil { |
| + t.Fatal(err) |
| + } |
| + |
| + if err := retentionPass(ctx, s, pub, reg); err != nil { |
| + t.Fatal(err) |
| + } |
| + |
| + // keep-20 protects v06..v25; pins protect v02, v03 → v01, v04, v05 gone. |
| + for _, gone := range []string{"v01", "v04", "v05"} { |
| + if _, _, err := s.Get(ctx, release.ManifestKey("bab", "kass", gone)); err == nil { |
| + t.Fatalf("%s still present, want pruned", gone) |
| + } |
| + } |
| + for _, kept := range []string{"v02", "v03", "v06", "v25"} { |
| + if _, _, err := s.Get(ctx, release.ManifestKey("bab", "kass", kept)); err != nil { |
| + t.Fatalf("%s pruned, want kept: %v", kept, err) |
| + } |
| + } |
| +} |
| + |
| +func TestRetentionPassSkipsAppsWithoutAPolicy(t *testing.T) { |
| + ctx := context.Background() |
| + dir := t.TempDir() |
| + s := release.NewDirStore(dir) |
| + base := time.Now().Add(-30 * 24 * time.Hour) |
| + for i := 1; i <= 25; i++ { |
| + v := "v" + string(rune('0'+i/10)) + string(rune('0'+i%10)) |
| + stampKey(t, s, dir, release.ManifestKey("bab", "kass", v), base.Add(time.Duration(i)*time.Hour)) |
| + } |
| + _, pub := retentionKeys(t) |
| + reg, err := registry.Open(filepath.Join(t.TempDir(), "registry.db")) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + defer reg.Close() |
| + if err := reg.Upsert(registry.Route{ |
| + Host: "kass.example.com", Kind: "instance", App: "kass", Account: "bab", Status: "ready", |
| + }); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := retentionPass(ctx, s, pub, reg); err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, _, err := s.Get(ctx, release.ManifestKey("bab", "kass", "v01")); err != nil { |
| + t.Fatalf("no policy, yet v01 was pruned: %v", err) |
| + } |
| +} |