carlos / platform Public

host: ambient retention pass — slow loop pruning per hosted app, box pins protected

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell 4e7233a72b23ee8fe1c4334d884a24c8f73f7745 parent 1f6172c
3 files changed, +231 −0
  • internal/host/agent.go +38 −0
  • internal/host/retention.go +77 −0
  • internal/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)
+ }
+}