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) { t.Parallel() 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, &boxReports{status: release.NewDirStore(t.TempDir())}, 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) } } } // TestRetentionPassResolvesEmptyAccountRoutesViaRouteAccount covers the // review finding: a route row with an EMPTY Account column (unstamped by // the domains pass) must still resolve to release.OpsAccount ("bab"), the // same resolution every other box-side release path uses // (release.RouteAccount, domains/reconcile.go's resolveRouteAccount) — // never be silently skipped, and never key its pins under "". func TestRetentionPassResolvesEmptyAccountRoutesViaRouteAccount(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(release.OpsAccount, "kass", vname(i)), base.Add(time.Duration(i)*time.Hour)) } _, pub := retentionKeys(t) if err := release.SetRetention(ctx, s, release.OpsAccount, "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() // Account column left empty — the state every route is in until the // domains pass stamps it (reconcile.go: "A route with an empty account // column resolves to ops until 1b writes the account the BUCKET // asserts"). v03/v02 are this box's pins, both outside keep-20. if err := reg.Upsert(registry.Route{ Host: "kass.example.com", Kind: "instance", App: "kass", Version: "v03", PrevVersion: "v02", Status: "ready", }); err != nil { t.Fatal(err) } if err := retentionPass(ctx, s, &boxReports{status: release.NewDirStore(t.TempDir())}, pub, reg); err != nil { t.Fatal(err) } // The bab/kass policy must have been enforced — an empty Account // column must not cause the app to be silently skipped. if _, _, err := s.Get(ctx, release.ManifestKey(release.OpsAccount, "kass", "v01")); err == nil { t.Fatalf("v01 still present, want pruned (empty-account route was not resolved to %s)", release.OpsAccount) } if _, _, err := s.Get(ctx, release.ManifestKey(release.OpsAccount, "kass", "v04")); err == nil { t.Fatalf("v04 still present, want pruned") } // v02/v03 are the empty-account route's Version/PrevVersion pins — // must survive despite ranking outside keep-20. for _, kept := range []string{"v02", "v03"} { if _, _, err := s.Get(ctx, release.ManifestKey(release.OpsAccount, "kass", kept)); err != nil { t.Fatalf("%s pruned, want kept as an empty-account route's pin: %v", kept, err) } } } // The good build is the fallback a planned wake boots when the new build // fails (zero-downtime spec §8.5), so retention keeps it even when no // route installs it as Version or PrevVersion any more (zdd-readiness // Task 13, Astra R1 T12). Pruned, the fallback's artifact is gone exactly // when a failed release needs it. func TestRetentionPassKeepsTheGoodBuild(t *testing.T) { t.Parallel() 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() // Installed v03 over v02, but the last build that served healthily is // v01: all three rank outside keep-20. if err := reg.Upsert(registry.Route{ Host: "kass.example.com", Kind: "instance", App: "kass", Account: "bab", Version: "v03", PrevVersion: "v02", GoodVersion: "v01", Status: "ready", }); err != nil { t.Fatal(err) } if err := retentionPass(ctx, s, &boxReports{status: release.NewDirStore(t.TempDir())}, pub, reg); err != nil { t.Fatal(err) } for _, kept := range []string{"v01", "v02", "v03"} { if _, _, err := s.Get(ctx, release.ManifestKey("bab", "kass", kept)); err != nil { t.Fatalf("%s pruned, want kept as a pin: %v", kept, err) } } if _, _, err := s.Get(ctx, release.ArtifactKey("bab", "kass", "v01", "linux-arm64")); err != nil { t.Fatalf("the good build's artifact was pruned: %v", err) } for _, gone := range []string{"v04", "v05"} { if _, _, err := s.Get(ctx, release.ManifestKey("bab", "kass", gone)); err == nil { t.Fatalf("%s still present, want pruned", gone) } } } 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, &boxReports{status: release.NewDirStore(t.TempDir())}, 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) } }