settings-4 task 9: boxes report build-state builds; retention protects them and pins, and fails closed
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
11 files changed,
+1007
−9
internal/host/agent.go+26 −3internal/host/agent_test.go+27 −0internal/host/retention.go+218 −1internal/host/retention_settings_test.go+476 −0internal/host/retention_test.go+3 −3internal/host/versionsweep.go+46 −0internal/host/versionsweep_test.go+75 −0internal/status/contract_test.go+12 −1internal/status/dryrun.go+33 −0internal/status/snapshot.go+31 −1internal/status/status_test.go+60 −0
diff --git a/internal/host/agent.go b/internal/host/agent.go| index d848e20d2..9fc30fb6c 100644 |
| --- a/internal/host/agent.go |
| +++ b/internal/host/agent.go |
| @@ -645,7 +645,11 @@ func runAgent(args []string, buildClaims ClaimFactory) { |
| if errSink != nil { |
| go errshipLoop(errSink, status.Label(readMachineID())) |
| } |
| - go statusLoop(reg, svc) |
| + // The build-state report (settings spec rev 11 §4.4) names the rows |
| + // keepsBuildState selects, with host claims as the build-state store |
| + // sees them (cfg.HostWrites.Enabled, set by configureWakeClaims). |
| + buildStateClaims := cfg != nil && cfg.HostWrites.Enabled |
| + go statusLoop(reg, svc, func() (map[string]status.BuildStatePins, error) { return buildStatePins(reg, buildStateClaims) }) |
| // The memory early-warning (memory-pressure spec §2.4): per box, gated |
| // by CARLOS_HOST_METRICS — one log line and nothing else where unset. |
| go memwatchLoop() |
| @@ -3037,12 +3041,21 @@ func retentionLoop(reg *registry.Registry) { |
| log.Printf("agent: retention loop disabled: %v — retention policies will not be enforced by this box", err) |
| return |
| } |
| + // The box reports (settings spec rev 11 §4.4: the build-state builds |
| + // every box names) live in the status store, which is not always the |
| + // deployment store above (CARLOS_STATUS_BUCKET wins). Without it no app |
| + // can be pruned safely, so the loop does not arm. |
| + reports, _, err := openStatusStore(ctx, os.Getenv) |
| + if err != nil { |
| + log.Printf("agent: retention loop disabled: the box reports cannot be read (%v). retention policies will not be enforced by this box", err) |
| + return |
| + } |
| log.Printf("agent: retention loop armed (5min + jitter) — prunes only while this box holds the housekeeper lease") |
| // The pass runs under the HOLDING's context, not this loop's: a sweep |
| // that is halfway through its deletes when renewal fails must stop, not |
| // finish against a lock another box now owns (housekeeper.go). |
| prune := housekeeperDuty{"retention pass", func(ctx context.Context) error { |
| - return retentionPass(ctx, store, pub, reg) |
| + return retentionPass(ctx, store, reports, pub, reg) |
| }} |
| delay := time.Minute |
| for { |
| @@ -3228,7 +3241,7 @@ func upstreamStates() map[string][]status.UpstreamState { |
| // logging that every 5 minutes forever would bury the log in noise. Any |
| // OTHER acquire error (a real S3/credential problem) is logged, same as any |
| // other tick failure. |
| -func statusLoop(reg *registry.Registry, svc *activator.Service) { |
| +func statusLoop(reg *registry.Registry, svc *activator.Service, buildStates func() (map[string]status.BuildStatePins, error)) { |
| ctx := context.Background() |
| // bucket is read for the lease key alone (lease.Acquire below), which a |
| // fleet box never reaches: shouldSkipPublisherElection short-circuits the |
| @@ -3420,6 +3433,16 @@ func statusLoop(reg *registry.Registry, svc *activator.Service) { |
| p.Rollout = rolloutStatusProbe() |
| // Hosts held for a dev proof (D3): private Metrics only. |
| p.ProofWaits = proofWaitProbe() |
| + // The build-state builds of the instances this box hosts (settings |
| + // spec rev 11 §4.4), for the housekeeper's retention. A failed |
| + // collection is reported as such: the housekeeper prunes nothing |
| + // while it is fresh. Private Metrics only: hosts. |
| + if pins, err := buildStates(); err != nil { |
| + log.Printf("agent: status tick: build states: %v", err) |
| + p.BuildStatesErr = err.Error() |
| + } else { |
| + p.BuildStates = pins |
| + } |
| // A fleet box (CARLOS_FLEET set) never gets here as a contender: it |
| // self-reports only, never attempting the election at all — no |
diff --git a/internal/host/agent_test.go b/internal/host/agent_test.go| index c5bb22dfa..c29ab77c7 100644 |
| --- a/internal/host/agent_test.go |
| +++ b/internal/host/agent_test.go |
| @@ -1894,6 +1894,10 @@ func TestRetentionLoopIsAPlatformBoxJob(t *testing.T) { |
| t.Setenv("CARLOS_DEPLOYMENT_BUCKET", "") |
| t.Setenv("CARLOS_DEPLOYMENT_DIR", t.TempDir()) |
| t.Setenv("CARLOS_RELEASE_PUBKEY", base64.StdEncoding.EncodeToString(pub)) |
| + // The box reports retention reads (settings spec rev 11 §4.4) are in |
| + // the status store. Opening an S3 store makes no call, and the pass |
| + // runs only under the housekeeper lease, which a test never holds. |
| + t.Setenv("CARLOS_STATUS_BUCKET", "carlos-status") |
| // retentionLoop never returns once armed, so the arming log line is |
| // what proves it: run it in its own goroutine (it parks in a |
| @@ -1935,6 +1939,29 @@ func TestRetentionLoopIsAPlatformBoxJob(t *testing.T) { |
| }) |
| } |
| +// Settings spec rev 11 §4.4: without the box reports no app can be pruned |
| +// safely, so a deployment with no status store (CARLOS_DEPLOYMENT_DIR, which |
| +// publishes none) does not arm retention at all. |
| +func TestRetentionLoopNeedsTheBoxReports(t *testing.T) { |
| + pub, _, err := ed25519.GenerateKey(nil) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + logs := captureLog(t) |
| + t.Setenv("CARLOS_FLEET", "") |
| + t.Setenv("CARLOS_DEPLOYMENT_BUCKET", "") |
| + t.Setenv("CARLOS_STATUS_BUCKET", "") |
| + t.Setenv("CARLOS_DEPLOYMENT_DIR", t.TempDir()) |
| + t.Setenv("CARLOS_RELEASE_PUBKEY", base64.StdEncoding.EncodeToString(pub)) |
| + |
| + retentionLoop(testRegistry(t)) // must return: it does not arm |
| + |
| + got := logs() |
| + if !strings.Contains(got, "agent: retention loop disabled: the box reports cannot be read") || strings.Contains(got, "retention loop armed") { |
| + t.Fatalf("log = %q, want the loop disabled for want of box reports", got) |
| + } |
| +} |
| + |
| // writerFunc adapts a func to io.Writer — the log sink above needs its own |
| // mutex because the loop under test keeps running (and may keep logging) |
| // after the test returns, which a bare bytes.Buffer would race on. |
diff --git a/internal/host/retention.go b/internal/host/retention.go| index 6b31240ba..1daeaa424 100644 |
| --- a/internal/host/retention.go |
| +++ b/internal/host/retention.go |
| @@ -13,21 +13,37 @@ package host |
| import ( |
| "context" |
| "crypto/ed25519" |
| + "errors" |
| + "fmt" |
| "log" |
| + "strings" |
| "time" |
| + "github.com/carlosframework/platform/internal/provision" |
| "github.com/carlosframework/platform/internal/registry" |
| "github.com/carlosframework/platform/internal/release" |
| + "github.com/carlosframework/platform/internal/status" |
| ) |
| // 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 { |
| +// |
| +// reports is the status store the boxes publish to (openStatusStore's |
| +// bucket, not store's): settings spec rev 11 §4.4's build-state builds come |
| +// from there. A pass that cannot read the reports prunes nothing at all. |
| +func retentionPass(ctx context.Context, store, reports release.Store, pub ed25519.PublicKey, reg *registry.Registry) error { |
| routes, err := reg.List() |
| if err != nil { |
| return err |
| } |
| + if reports == nil { |
| + return errors.New("no status store to read the box reports from: pruning nothing") |
| + } |
| + boxes, complete, err := loadAllBoxes(ctx, reports) |
| + if err != nil { |
| + return fmt.Errorf("reading the box reports: pruning nothing this pass: %w", err) |
| + } |
| type appKey struct{ acct, app string } |
| pins := map[appKey]map[string]bool{} |
| for _, rt := range routes { |
| @@ -44,6 +60,11 @@ func retentionPass(ctx context.Context, store release.Store, pub ed25519.PublicK |
| if rt.PrevVersion != "" { |
| pins[k][rt.PrevVersion] = true |
| } |
| + // What a failed first wake falls back to (zero-downtime spec §8.5, |
| + // zdd-5 obligation 9), however long ago it last served. |
| + if rt.GoodVersion != "" { |
| + pins[k][rt.GoodVersion] = true |
| + } |
| } |
| for k, protected := range pins { |
| ret, err := release.GetRetention(ctx, store, k.acct, k.app) |
| @@ -54,6 +75,14 @@ func retentionPass(ctx context.Context, store release.Store, pub ed25519.PublicK |
| if ret == nil { |
| continue |
| } |
| + extra, err := settingsProtected(ctx, store, boxes, complete, time.Now(), k.acct, k.app) |
| + if err != nil { |
| + log.Printf("agent: retention %s/%s: pruning nothing this pass: %v", k.acct, k.app, err) |
| + continue |
| + } |
| + for v := range extra { |
| + protected[v] = true |
| + } |
| 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) |
| @@ -75,3 +104,191 @@ func retentionPass(ctx context.Context, store release.Store, pub ed25519.PublicK |
| } |
| return nil |
| } |
| + |
| +// buildStatePins is this box's contribution to retention (settings spec rev |
| +// 11 §4.4): the build-state builds of every row keepsBuildState selects (the |
| +// rows refreshAll keeps a copy of), from the registry copies the agent |
| +// refreshes at reconcile and before every wake. claims is whether host |
| +// claims are on, as keepsBuildState takes it. Every such row is reported, |
| +// with or without builds, so a missing or unreadable copy shows as |
| +// incomplete rather than as nothing to protect. A failed list is an error: |
| +// the caller reports it (BoxMetrics.BuildStatesErr), never an empty map. |
| +func buildStatePins(reg routeLister, claims bool) (map[string]status.BuildStatePins, error) { |
| + routes, err := reg.List() |
| + if err != nil { |
| + return nil, fmt.Errorf("listing routes for build states: %w", err) |
| + } |
| + out := map[string]status.BuildStatePins{} |
| + for _, rt := range routes { |
| + if !keepsBuildState(rt, claims) { |
| + continue |
| + } |
| + p := status.BuildStatePins{Account: release.RouteAccount(rt), App: rt.App, Builds: []string{}} |
| + c, err := release.ParseBuildStateCopy(rt.BuildState) |
| + p.Complete = err == nil && c.Known && c.ReadErr == "" |
| + if err == nil { |
| + p.Builds = buildStateBuilds(c.State) |
| + } |
| + out[rt.Host] = p |
| + } |
| + if len(out) == 0 { |
| + return nil, nil |
| + } |
| + return out, nil |
| +} |
| + |
| +// buildStateBuilds is the builds a build state names: last_executed, and a |
| +// resolved request's target when it differs. A failed or completed request's |
| +// T is named too: it may still be what the instance runs or recovers onto. |
| +func buildStateBuilds(st *release.BuildState) []string { |
| + builds := []string{} |
| + if st == nil { |
| + return builds |
| + } |
| + if st.LastExecuted != "" { |
| + builds = append(builds, st.LastExecuted) |
| + } |
| + if r := st.Resolved; r != nil && r.Target != "" && r.Target != st.LastExecuted { |
| + builds = append(builds, r.Target) |
| + } |
| + return builds |
| +} |
| + |
| +// buildStateReportMaxAge is §4.4's bound: an app is not pruned while any box |
| +// hosting one of its instances has been silent longer than this. |
| +const buildStateReportMaxAge = 6 * time.Hour |
| + |
| +// loadAllBoxes reads every box report from the status store: the |
| +// deployment's (status/boxes/) and every fleet's (fleets/<name>/status/boxes/, |
| +// enumerated with ListDirs). complete is false if any report could not be |
| +// read; a listing that fails is an error. |
| +func loadAllBoxes(ctx context.Context, store release.Store) ([]status.Box, bool, error) { |
| + boxes, complete, err := status.LoadBoxesComplete(ctx, store) |
| + if err != nil { |
| + return nil, false, err |
| + } |
| + fleets, err := store.ListDirs(ctx, "fleets/") |
| + if err != nil { |
| + return nil, false, fmt.Errorf("listing fleets: %w", err) |
| + } |
| + for _, f := range fleets { |
| + fb, ok, err := status.LoadBoxesComplete(ctx, release.NewPrefixStore(store, f)) |
| + if err != nil { |
| + return nil, false, fmt.Errorf("reading the reports of %s: %w", f, err) |
| + } |
| + complete = complete && ok |
| + boxes = append(boxes, fb...) |
| + } |
| + return boxes, complete, nil |
| +} |
| + |
| +// appRecords reads every provision record of acct/app, or fails. It lists |
| +// the keys itself (provision.ListRecords caps on max and skips a record it |
| +// cannot read, ruling R-T9-STORE): a key that is a record by ListRecords' |
| +// own rule (instances/<host>.json, one level) and does not read, parse or |
| +// agree with its path is an error, so the caller prunes nothing. |
| +func appRecords(ctx context.Context, store release.Store, acct, app string) ([]provision.Record, error) { |
| + prefix := provision.RecordsPrefix(acct, app) |
| + keys, err := store.List(ctx, prefix) |
| + if err != nil { |
| + return nil, fmt.Errorf("listing the records of %s/%s: %w", acct, app, err) |
| + } |
| + var recs []provision.Record |
| + for _, key := range keys { |
| + rest, ok := strings.CutPrefix(key, prefix) |
| + if !ok || rest == "" || strings.Contains(rest, "/") { |
| + continue |
| + } |
| + host, ok := strings.CutSuffix(rest, ".json") |
| + if !ok || host == "" { |
| + continue |
| + } |
| + rec, err := provision.GetRecord(ctx, store, acct, app, host) |
| + if err != nil { |
| + return nil, fmt.Errorf("reading the record of %s: %w", host, err) |
| + } |
| + recs = append(recs, *rec) |
| + } |
| + return recs, nil |
| +} |
| + |
| +// settingsProtected is settings spec rev 11 §4.4's addition to an app's |
| +// protected set: every pin a provision record names, and every build any |
| +// box report names for one of the app's instances. It refuses (the caller |
| +// prunes nothing for the app) when a record is unreadable, when the reports |
| +// could not all be read, when a fresh report says its box could not collect |
| +// its build states, when a provisioned instance that keeps a build state is |
| +// named by no report, or when the newest report naming one of the app's |
| +// hosts is older than six hours or says that host's build state is unread. |
| +// |
| +// The newest report for a host decides freshness and completeness, so a box |
| +// the instance moved off does not block pruning forever. The builds of |
| +// every report naming the host are protected, older ones included: that can |
| +// only keep more. |
| +func settingsProtected(ctx context.Context, store release.Store, boxes []status.Box, complete bool, now time.Time, acct, app string) (map[string]bool, error) { |
| + recs, err := appRecords(ctx, store, acct, app) |
| + if err != nil { |
| + return nil, err |
| + } |
| + if !complete { |
| + return nil, errors.New("the box reports could not all be read") |
| + } |
| + out := map[string]bool{} |
| + for _, r := range recs { |
| + if r.Pin != "" { |
| + out[r.Pin] = true |
| + } |
| + } |
| + type newest struct { |
| + at time.Time |
| + pins status.BuildStatePins |
| + box string |
| + } |
| + byHost := map[string]newest{} |
| + for _, b := range boxes { |
| + if b.Metrics == nil { |
| + continue |
| + } |
| + // A box that could not collect its build states may host any of |
| + // this app's instances (Astra I2 #5). |
| + if b.Metrics.BuildStatesErr != "" && now.Sub(b.GeneratedAt) <= buildStateReportMaxAge { |
| + return nil, fmt.Errorf("%s could not collect its build states: %s", b.Label, b.Metrics.BuildStatesErr) |
| + } |
| + for host, p := range b.Metrics.BuildStates { |
| + if p.Account != acct || p.App != app { |
| + continue |
| + } |
| + for _, v := range p.Builds { |
| + if v != "" { |
| + out[v] = true |
| + } |
| + } |
| + if cur, ok := byHost[host]; !ok || b.GeneratedAt.After(cur.at) { |
| + byHost[host] = newest{at: b.GeneratedAt, pins: p, box: b.Label} |
| + } |
| + } |
| + } |
| + // Every provisioned instance of the app that keeps a build state must |
| + // be named by some report (Astra I2 #5): a missing entry is unknown, |
| + // never "nothing to protect". For a valid record this is |
| + // keepsBuildState's instance rule (activator.ActivatorManaged): an |
| + // instance with no unit that hibernates has no addr (the record door |
| + // refuses addr with hibernate), so reconcile mints it a socket. |
| + for _, r := range recs { |
| + if r.KindOrDefault() != "instance" || r.Unit != "" || !r.HibernateOrDefault() { |
| + continue |
| + } |
| + if _, ok := byHost[r.Host]; !ok { |
| + return nil, fmt.Errorf("no box report names the build state of %s", r.Host) |
| + } |
| + } |
| + for host, n := range byHost { |
| + if now.Sub(n.at) > buildStateReportMaxAge { |
| + return nil, fmt.Errorf("%s, which hosts %s, has not reported since %s", n.box, host, n.at.UTC().Format(time.RFC3339)) |
| + } |
| + if !n.pins.Complete { |
| + return nil, fmt.Errorf("%s could not read the build state of %s", n.box, host) |
| + } |
| + } |
| + return out, nil |
| +} |
diff --git a/internal/host/retention_settings_test.go b/internal/host/retention_settings_test.go| new file mode 100644 |
| index 000000000..93f75f2a2 |
| --- /dev/null |
| +++ b/internal/host/retention_settings_test.go |
| @@ -0,0 +1,476 @@ |
| +package host |
| + |
| +import ( |
| + "context" |
| + "errors" |
| + "path/filepath" |
| + "reflect" |
| + "sort" |
| + "testing" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/provision" |
| + "github.com/carlosframework/platform/internal/registry" |
| + "github.com/carlosframework/platform/internal/release" |
| + "github.com/carlosframework/platform/internal/status" |
| +) |
| + |
| +func report(label string, at time.Time, pins map[string]status.BuildStatePins) status.Box { |
| + return status.Box{Label: label, GeneratedAt: at, Metrics: &status.BoxMetrics{BuildStates: pins}} |
| +} |
| + |
| +func kmPins(builds ...string) status.BuildStatePins { |
| + return status.BuildStatePins{Account: "bab", App: "km", Builds: builds, Complete: true} |
| +} |
| + |
| +// Astra I1 #6: a host whose build state its box could not read stops |
| +// pruning for the app, even in a fresh, readable report. |
| +func TestAnIncompleteBuildStatePrunesNothing(t *testing.T) { |
| + s := recordsStore(t, provision.Record{Schema: 1, Host: "b.example.com", App: "km", Account: "bab", Channel: "stable"}) |
| + inc := kmPins() |
| + inc.Complete = false |
| + boxes := []status.Box{report("uk-1", now0.Add(-time.Minute), map[string]status.BuildStatePins{"b.example.com": inc})} |
| + if _, err := settingsProtected(context.Background(), s, boxes, true, now0, "bab", "km"); err == nil { |
| + t.Fatal("an unreadable build state did not stop pruning") |
| + } |
| +} |
| + |
| +func TestAnUnreadBuildStateIsReportedIncomplete(t *testing.T) { |
| + _, _, reg := testBuildStates(t) // a.example.com, never refreshed: copy unknown |
| + pins, err := buildStatePins(reg, false) |
| + if p, ok := pins["a.example.com"]; err != nil || !ok || p.Complete { |
| + t.Fatalf("report = %+v, %v, %v; want the host listed and incomplete", p, ok, err) |
| + } |
| + if err := reg.SetBuildState("a.example.com", release.BuildStateCopy{Known: true, ReadErr: "timeout"}.Column()); err != nil { |
| + t.Fatal(err) |
| + } |
| + if pins, _ := buildStatePins(reg, false); pins["a.example.com"].Complete { |
| + t.Fatal("a failed refresh was reported complete") |
| + } |
| + if err := reg.SetBuildState("a.example.com", "{"); err != nil { |
| + t.Fatal(err) |
| + } |
| + if pins, _ := buildStatePins(reg, false); pins["a.example.com"].Complete { |
| + t.Fatal("an unparseable copy was reported complete") |
| + } |
| +} |
| + |
| +func recordsStore(t *testing.T, recs ...provision.Record) release.Store { |
| + t.Helper() |
| + s := release.NewDirStore(t.TempDir()) |
| + for _, r := range recs { |
| + if err := provision.CreateRecord(context.Background(), s, r); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + return s |
| +} |
| + |
| +var now0 = time.Date(2026, 10, 7, 12, 0, 0, 0, time.UTC) |
| + |
| +// Spec rev 11 §4.4: pins from records, build-state builds from box reports. |
| +func TestPinsAndReportedBuildStatesAreProtected(t *testing.T) { |
| + s := recordsStore(t, |
| + provision.Record{Schema: 1, Host: "a.example.com", App: "km", Account: "bab", Channel: "stable", Pin: "v1"}, |
| + provision.Record{Schema: 1, Host: "b.example.com", App: "km", Account: "bab", Channel: "stable", Region: "uk"}) |
| + boxes := []status.Box{report("uk-1", now0.Add(-time.Hour), map[string]status.BuildStatePins{ |
| + "a.example.com": kmPins(), "b.example.com": kmPins("v2", "v3")})} |
| + got, err := settingsProtected(context.Background(), s, boxes, true, now0, "bab", "km") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + for _, v := range []string{"v1", "v2", "v3"} { |
| + if !got[v] { |
| + t.Errorf("%s is not protected: %v", v, got) |
| + } |
| + } |
| +} |
| + |
| +func TestABoxSilentForSixHoursPrunesNothing(t *testing.T) { |
| + s := recordsStore(t, provision.Record{Schema: 1, Host: "b.example.com", App: "km", Account: "bab", Channel: "stable"}) |
| + boxes := []status.Box{report("uk-1", now0.Add(-6*time.Hour-time.Minute), map[string]status.BuildStatePins{"b.example.com": kmPins("v2")})} |
| + if _, err := settingsProtected(context.Background(), s, boxes, true, now0, "bab", "km"); err == nil { |
| + t.Fatal("a box hosting the app that has not reported in 6h did not stop pruning") |
| + } |
| +} |
| + |
| +func TestANewerReportForTheHostWins(t *testing.T) { |
| + s := recordsStore(t, provision.Record{Schema: 1, Host: "b.example.com", App: "km", Account: "bab", Channel: "stable"}) |
| + boxes := []status.Box{ |
| + report("uk-1", now0.Add(-48*time.Hour), map[string]status.BuildStatePins{"b.example.com": kmPins("v1")}), // the box it moved off |
| + report("ie-1", now0.Add(-time.Minute), map[string]status.BuildStatePins{"b.example.com": kmPins("v2")}), |
| + } |
| + got, err := settingsProtected(context.Background(), s, boxes, true, now0, "bab", "km") |
| + if err != nil || !got["v2"] { |
| + t.Fatalf("got %v, %v; want v2 from the newest report and no refusal", got, err) |
| + } |
| + // §4.4 protects every build named in a report: the older report's v1 is |
| + // kept too, which can only keep more. |
| + if !got["v1"] { |
| + t.Errorf("v1, named by the older report, is not protected: %v", got) |
| + } |
| +} |
| + |
| +// The newest report decides completeness too: an older box's incomplete |
| +// entry for a host that has since moved does not block, and a newer |
| +// incomplete one does even when an older one was complete. |
| +func TestTheNewestReportDecidesCompleteness(t *testing.T) { |
| + s := recordsStore(t, provision.Record{Schema: 1, Host: "b.example.com", App: "km", Account: "bab", Channel: "stable"}) |
| + inc := kmPins() |
| + inc.Complete = false |
| + moved := []status.Box{ |
| + report("uk-1", now0.Add(-2*time.Hour), map[string]status.BuildStatePins{"b.example.com": inc}), |
| + report("ie-1", now0.Add(-time.Minute), map[string]status.BuildStatePins{"b.example.com": kmPins("v2")}), |
| + } |
| + if _, err := settingsProtected(context.Background(), s, moved, true, now0, "bab", "km"); err != nil { |
| + t.Fatalf("an older box's incomplete entry blocked: %v", err) |
| + } |
| + reversed := []status.Box{ |
| + report("uk-1", now0.Add(-time.Minute), map[string]status.BuildStatePins{"b.example.com": inc}), |
| + report("ie-1", now0.Add(-2*time.Hour), map[string]status.BuildStatePins{"b.example.com": kmPins("v2")}), |
| + } |
| + if _, err := settingsProtected(context.Background(), s, reversed, true, now0, "bab", "km"); err == nil { |
| + t.Fatal("the newest report's incomplete entry did not stop pruning") |
| + } |
| +} |
| + |
| +func TestOtherAppsReportsDoNotBlock(t *testing.T) { |
| + s := recordsStore(t, provision.Record{Schema: 1, Host: "a.example.com", App: "km", Account: "bab", Channel: "stable"}) |
| + boxes := []status.Box{ |
| + report("uk-1", now0.Add(-72*time.Hour), map[string]status.BuildStatePins{ |
| + "x.example.com": {Account: "bab", App: "blog", Builds: []string{"b1"}}}), |
| + report("ie-1", now0.Add(-time.Minute), map[string]status.BuildStatePins{"a.example.com": kmPins("v1")}), |
| + } |
| + if _, err := settingsProtected(context.Background(), s, boxes, true, now0, "bab", "km"); err != nil { |
| + t.Fatalf("another app's silent box blocked km: %v", err) |
| + } |
| +} |
| + |
| +// Astra I2 #5: every provisioned instance of the app needs an entry. A |
| +// fresh, readable report that simply omits the host (its map left out) |
| +// prunes nothing, not "nothing to protect". |
| +func TestAnOmittedHostPrunesNothing(t *testing.T) { |
| + s := recordsStore(t, provision.Record{Schema: 1, Host: "b.example.com", App: "km", Account: "bab", Channel: "stable"}) |
| + boxes := []status.Box{report("uk-1", now0.Add(-time.Minute), nil)} |
| + if _, err := settingsProtected(context.Background(), s, boxes, true, now0, "bab", "km"); err == nil { |
| + t.Fatal("a host no report names was treated as having no builds to protect") |
| + } |
| + // A report naming the host under another account is not this app's. |
| + other := []status.Box{report("uk-1", now0.Add(-time.Minute), map[string]status.BuildStatePins{ |
| + "b.example.com": {Account: "zzz", App: "km", Builds: []string{"v1"}, Complete: true}})} |
| + if _, err := settingsProtected(context.Background(), s, other, true, now0, "bab", "km"); err == nil { |
| + t.Fatal("another account's entry for the host counted as this app's") |
| + } |
| +} |
| + |
| +// Only a record that keeps a build state needs a report: a unit-backed or |
| +// always-on instance, or a static route, has none to report. |
| +func TestRecordsWithNoBuildStateNeedNoReport(t *testing.T) { |
| + off := false |
| + s := recordsStore(t, |
| + provision.Record{Schema: 1, Host: "u.example.com", App: "km", Account: "bab", Channel: "stable", Unit: "km.service"}, |
| + provision.Record{Schema: 1, Host: "o.example.com", App: "km", Account: "bab", Channel: "stable", Hibernate: &off, Pin: "v7"}, |
| + provision.Record{Schema: 1, Host: "s.example.com", App: "km", Account: "bab", Channel: "stable", Kind: "static"}) |
| + got, err := settingsProtected(context.Background(), s, nil, true, now0, "bab", "km") |
| + if err != nil { |
| + t.Fatalf("records that keep no build state blocked: %v", err) |
| + } |
| + if !got["v7"] { |
| + t.Errorf("the pin of a record that keeps no build state is not protected: %v", got) |
| + } |
| +} |
| + |
| +// Astra I2 #5: a box that could not list its routes says so, and a fresh |
| +// report saying so stops pruning even when an older report from another |
| +// box still names the host (it may have moved onto the failing box). |
| +func TestACollectionFailurePrunesNothing(t *testing.T) { |
| + s := recordsStore(t, provision.Record{Schema: 1, Host: "b.example.com", App: "km", Account: "bab", Channel: "stable"}) |
| + failed := report("uk-1", now0.Add(-time.Minute), nil) |
| + failed.Metrics.BuildStatesErr = "registry: database is locked" |
| + boxes := []status.Box{ |
| + report("ie-1", now0.Add(-2*time.Hour), map[string]status.BuildStatePins{"b.example.com": kmPins("v1")}), |
| + failed, |
| + } |
| + if _, err := settingsProtected(context.Background(), s, boxes, true, now0, "bab", "km"); err == nil { |
| + t.Fatal("a box that could not collect its build states did not stop pruning") |
| + } |
| +} |
| + |
| +// registry.List opens its own connection, so a closed registry still lists: |
| +// the failing list is a fake. |
| +func TestTheCollectorReportsItsFailure(t *testing.T) { |
| + reg := fakeLister{err: errors.New("database is locked")} |
| + if pins, err := buildStatePins(reg, false); err == nil || pins != nil { |
| + t.Fatalf("a failed registry list returned %v, %v; want an error", pins, err) |
| + } |
| +} |
| + |
| +func TestAnUnreadableRecordOrReportPrunesNothing(t *testing.T) { |
| + s := recordsStore(t, provision.Record{Schema: 1, Host: "a.example.com", App: "km", Account: "bab", Channel: "stable"}) |
| + if _, err := s.Put(context.Background(), provision.RecordKey("bab", "km", "z.example.com"), []byte("{"), ""); err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := settingsProtected(context.Background(), s, nil, true, now0, "bab", "km"); err == nil { |
| + t.Fatal("an unreadable record did not stop pruning") |
| + } |
| + clean := recordsStore(t, provision.Record{Schema: 1, Host: "a.example.com", App: "km", Account: "bab", Channel: "stable"}) |
| + if _, err := settingsProtected(context.Background(), clean, nil, false, now0, "bab", "km"); err == nil { |
| + t.Fatal("an incomplete read of box reports did not stop pruning") |
| + } |
| +} |
| + |
| +// Ruling R-T9-STORE: provision.ListRecords(..., 0) reports truncated whenever |
| +// a key exists, and it skips a record it cannot read. Every record is read, |
| +// and any failure (a record whose body names another host, a failed Get, a |
| +// failed listing) prunes nothing. |
| +func TestEveryRecordIsReadAndAnyFailurePrunesNothing(t *testing.T) { |
| + ctx := context.Background() |
| + recs := []provision.Record{ |
| + {Schema: 1, Host: "a.example.com", App: "km", Account: "bab", Channel: "stable", Pin: "v1"}, |
| + {Schema: 1, Host: "b.example.com", App: "km", Account: "bab", Channel: "stable", Pin: "v2"}, |
| + {Schema: 1, Host: "c.example.com", App: "km", Account: "bab", Channel: "stable", Pin: "v3"}, |
| + } |
| + all := map[string]status.BuildStatePins{"a.example.com": kmPins(), "b.example.com": kmPins(), "c.example.com": kmPins()} |
| + boxes := []status.Box{report("uk-1", now0.Add(-time.Minute), all)} |
| + s := recordsStore(t, recs...) |
| + got, err := settingsProtected(ctx, s, boxes, true, now0, "bab", "km") |
| + if err != nil { |
| + t.Fatalf("three good records refused: %v", err) |
| + } |
| + if !got["v1"] || !got["v2"] || !got["v3"] { |
| + t.Fatalf("not every record's pin is protected: %v", got) |
| + } |
| + |
| + // A body that disagrees with its own path. |
| + moved := recordsStore(t, recs...) |
| + raw, _, err := moved.Get(ctx, provision.RecordKey("bab", "km", "a.example.com")) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := moved.Put(ctx, provision.RecordKey("bab", "km", "d.example.com"), raw, ""); err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := settingsProtected(ctx, moved, boxes, true, now0, "bab", "km"); err == nil { |
| + t.Fatal("a record that disagrees with its path did not stop pruning") |
| + } |
| + |
| + // A Get that fails. |
| + getFails := failingGetStore{Store: recordsStore(t, recs...), bad: provision.RecordKey("bab", "km", "b.example.com"), err: errors.New("SlowDown")} |
| + if _, err := settingsProtected(ctx, getFails, boxes, true, now0, "bab", "km"); err == nil { |
| + t.Fatal("a record that could not be read did not stop pruning") |
| + } |
| + |
| + // A listing that fails. |
| + listFails := failingListStore{Store: recordsStore(t, recs...), prefix: provision.RecordsPrefix("bab", "km"), err: errors.New("AccessDenied")} |
| + if _, err := settingsProtected(ctx, listFails, boxes, true, now0, "bab", "km"); err == nil { |
| + t.Fatal("a failed record listing did not stop pruning") |
| + } |
| +} |
| + |
| +func TestTheBoxReportsItsBuildStates(t *testing.T) { |
| + _, _, reg := testBuildStates(t) |
| + if err := reg.SetBuildState("a.example.com", release.BuildStateCopy{Known: true, Replica: true, |
| + State: &release.BuildState{LastExecuted: "v1", Resolved: &release.ResolvedRequest{ID: 2, Target: "v2"}}}.Column()); err != nil { |
| + t.Fatal(err) |
| + } |
| + pins, err := buildStatePins(reg, false) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + got := pins["a.example.com"] |
| + // testBuildStates' row has no account: RouteAccount's fallback is ops, |
| + // whose sqid is "bab", the account the housekeeper keys the app by. |
| + if got.Account != "bab" || got.App != "km" || !got.Complete || len(got.Builds) != 2 || got.Builds[0] != "v1" || got.Builds[1] != "v2" { |
| + t.Fatalf("report = %+v", got) |
| + } |
| +} |
| + |
| +// Task 4's review: the report names exactly the rows keepsBuildState |
| +// selects, the rows refreshAll keeps a copy of. A unit-backed hibernating |
| +// instance is never refreshed, so listing it would read as incomplete |
| +// forever; an exec-backed worker is refreshed with host claims off, and |
| +// never with them on. |
| +func TestTheReportNamesExactlyTheRowsThatKeepABuildState(t *testing.T) { |
| + reg, err := registry.Open(filepath.Join(t.TempDir(), "r.db")) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + defer reg.Close() |
| + rows := []registry.Route{ |
| + {Host: "a.example.com", Kind: "instance", App: "km", Account: "bab", Hibernate: true, Socket: "/run/carlos/a.sock"}, |
| + {Host: "u.example.com", Kind: "instance", App: "km", Account: "bab", Hibernate: true, Socket: "/run/carlos/u.sock", Unit: "km.service"}, |
| + {Host: "w.example.com", Kind: "sidecar", App: "km", Account: "bab", Socket: "/run/carlos/w.sock"}, |
| + {Host: "t.example.com", Kind: "instance", App: "km", Account: "bab", Hibernate: true, Socket: "/run/carlos/t.sock", Tunnel: "fleet-1"}, |
| + {Host: "r.example.com", Kind: "instance", App: "km", Account: "bab", Socket: "/run/carlos/r.sock"}, |
| + {Host: "p.example.com", Kind: "instance", App: "edge", Hibernate: true, Socket: "/run/carlos/p.sock", Platform: true}, |
| + } |
| + for _, rt := range rows { |
| + if err := reg.Upsert(rt); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + routes, err := reg.List() |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + for _, claims := range []bool{false, true} { |
| + pins, err := buildStatePins(reg, claims) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + var got, want []string |
| + for h := range pins { |
| + got = append(got, h) |
| + } |
| + for _, rt := range routes { |
| + if keepsBuildState(rt, claims) { |
| + want = append(want, rt.Host) |
| + } |
| + } |
| + sort.Strings(got) |
| + sort.Strings(want) |
| + if !reflect.DeepEqual(got, want) { |
| + t.Errorf("claims %v: report names %v, keepsBuildState selects %v", claims, got, want) |
| + } |
| + wantHosts := []string{"a.example.com", "w.example.com"} |
| + if claims { |
| + wantHosts = []string{"a.example.com"} |
| + } |
| + if !reflect.DeepEqual(got, wantHosts) { |
| + t.Errorf("claims %v: report names %v, want %v", claims, got, wantHosts) |
| + } |
| + } |
| +} |
| + |
| +// loadAllBoxes reads the deployment's reports and every fleet's, and says |
| +// when any could not be read. |
| +func TestLoadAllBoxesReadsEveryFleet(t *testing.T) { |
| + ctx := context.Background() |
| + s := release.NewDirStore(t.TempDir()) |
| + if err := status.WriteBox(ctx, s, report("uk-1", now0, map[string]status.BuildStatePins{"a.example.com": kmPins("v1")})); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := status.WriteBox(ctx, release.NewPrefixStore(s, "fleets/acme/"), report("acme-1", now0, map[string]status.BuildStatePins{"b.example.com": kmPins("v2")})); err != nil { |
| + t.Fatal(err) |
| + } |
| + boxes, complete, err := loadAllBoxes(ctx, s) |
| + if err != nil || !complete { |
| + t.Fatalf("loadAllBoxes = %v, %v", complete, err) |
| + } |
| + var labels []string |
| + for _, b := range boxes { |
| + labels = append(labels, b.Label) |
| + } |
| + sort.Strings(labels) |
| + if !reflect.DeepEqual(labels, []string{"acme-1", "uk-1"}) { |
| + t.Fatalf("read %v, want both the deployment's box and the fleet's", labels) |
| + } |
| + |
| + if _, err := s.Put(ctx, "fleets/beta/status/boxes/beta-1.json", []byte("{"), ""); err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, complete, err := loadAllBoxes(ctx, s); err != nil || complete { |
| + t.Fatalf("a corrupt fleet report read as complete (%v, %v)", complete, err) |
| + } |
| + |
| + fleetsFail := failingListStore{Store: s, prefix: "fleets/", err: errors.New("AccessDenied")} |
| + if _, _, err := loadAllBoxes(ctx, fleetsFail); err == nil { |
| + t.Fatal("a failed fleets listing was not an error") |
| + } |
| +} |
| + |
| +// retentionFixture is TestRetentionPassPrunesEligibleAndProtectsBoxPins' |
| +// shape with 27 versions and keep 20, so v01..v07 are the candidates, and |
| +// a provisioned, hibernating kass.example.com on this box at v07 (previous |
| +// v06, good v05) whose record pins v04. |
| +func retentionFixture(t *testing.T) (context.Context, *release.DirStore, *registry.Registry, func(string) bool) { |
| + t.Helper() |
| + 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 <= 27; 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)) |
| + } |
| + if err := release.SetRetention(ctx, s, "bab", "kass", 20, "test", now); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := provision.CreateRecord(ctx, s, provision.Record{Schema: 1, Host: "kass.example.com", App: "kass", Account: "bab", Channel: "stable", Pin: "v04"}); err != nil { |
| + t.Fatal(err) |
| + } |
| + reg, err := registry.Open(filepath.Join(t.TempDir(), "registry.db")) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + t.Cleanup(func() { reg.Close() }) |
| + if err := reg.Upsert(registry.Route{ |
| + Host: "kass.example.com", Kind: "instance", App: "kass", Account: "bab", Hibernate: true, Socket: "/run/carlos/kass.sock", |
| + Version: "v07", PrevVersion: "v06", GoodVersion: "v05", Status: "ready", |
| + }); err != nil { |
| + t.Fatal(err) |
| + } |
| + present := func(v string) bool { |
| + _, _, err := s.Get(ctx, release.ManifestKey("bab", "kass", v)) |
| + return err == nil |
| + } |
| + return ctx, s, reg, present |
| +} |
| + |
| +// The pass itself: this box's Version, PrevVersion and GoodVersion (zdd-5 |
| +// obligation 9), the record's pin, and the reported last_executed and |
| +// resolved T are all kept; only what nothing names is pruned. |
| +func TestRetentionPassProtectsPinsGoodVersionAndReportedBuilds(t *testing.T) { |
| + ctx, s, reg, present := retentionFixture(t) |
| + _, pub := retentionKeys(t) |
| + reports := release.NewDirStore(t.TempDir()) |
| + if err := status.WriteBox(ctx, reports, report("uk-1", time.Now().Add(-time.Minute), map[string]status.BuildStatePins{ |
| + "kass.example.com": {Account: "bab", App: "kass", Builds: []string{"v03", "v02"}, Complete: true}})); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := retentionPass(ctx, s, reports, pub, reg); err != nil { |
| + t.Fatal(err) |
| + } |
| + if present("v01") { |
| + t.Error("v01 survived; nothing names it") |
| + } |
| + for _, v := range []string{"v02", "v03", "v04", "v05", "v06", "v07"} { |
| + if !present(v) { |
| + t.Errorf("%s was pruned", v) |
| + } |
| + } |
| +} |
| + |
| +// With no report naming the provisioned instance, or no readable reports at |
| +// all, the pass prunes nothing for the app. |
| +func TestRetentionPassPrunesNothingWithoutTheReports(t *testing.T) { |
| + t.Run("no report names the host", func(t *testing.T) { |
| + ctx, s, reg, present := retentionFixture(t) |
| + _, pub := retentionKeys(t) |
| + if err := retentionPass(ctx, s, release.NewDirStore(t.TempDir()), pub, reg); err != nil { |
| + t.Fatal(err) |
| + } |
| + if !present("v01") { |
| + t.Fatal("v01 was pruned with no report naming kass.example.com's build state") |
| + } |
| + }) |
| + t.Run("the reports cannot be listed", func(t *testing.T) { |
| + ctx, s, reg, present := retentionFixture(t) |
| + _, pub := retentionKeys(t) |
| + reports := failingListStore{Store: release.NewDirStore(t.TempDir()), prefix: "status/boxes/", err: errors.New("AccessDenied")} |
| + _ = retentionPass(ctx, s, reports, pub, reg) |
| + if !present("v01") { |
| + t.Fatal("v01 was pruned with the box reports unreadable") |
| + } |
| + }) |
| + t.Run("no status store", func(t *testing.T) { |
| + ctx, s, reg, present := retentionFixture(t) |
| + _, pub := retentionKeys(t) |
| + _ = retentionPass(ctx, s, nil, pub, reg) |
| + if !present("v01") { |
| + t.Fatal("v01 was pruned with no status store to read reports from") |
| + } |
| + }) |
| +} |
diff --git a/internal/host/retention_test.go b/internal/host/retention_test.go| index 174049ca8..ae9547ae2 100644 |
| --- a/internal/host/retention_test.go |
| +++ b/internal/host/retention_test.go |
| @@ -70,7 +70,7 @@ func TestRetentionPassPrunesEligibleAndProtectsBoxPins(t *testing.T) { |
| t.Fatal(err) |
| } |
| - if err := retentionPass(ctx, s, pub, reg); err != nil { |
| + if err := retentionPass(ctx, s, release.NewDirStore(t.TempDir()), pub, reg); err != nil { |
| t.Fatal(err) |
| } |
| @@ -123,7 +123,7 @@ func TestRetentionPassResolvesEmptyAccountRoutesViaRouteAccount(t *testing.T) { |
| t.Fatal(err) |
| } |
| - if err := retentionPass(ctx, s, pub, reg); err != nil { |
| + if err := retentionPass(ctx, s, release.NewDirStore(t.TempDir()), pub, reg); err != nil { |
| t.Fatal(err) |
| } |
| @@ -164,7 +164,7 @@ func TestRetentionPassSkipsAppsWithoutAPolicy(t *testing.T) { |
| }); err != nil { |
| t.Fatal(err) |
| } |
| - if err := retentionPass(ctx, s, pub, reg); err != nil { |
| + if err := retentionPass(ctx, s, release.NewDirStore(t.TempDir()), pub, reg); err != nil { |
| t.Fatal(err) |
| } |
| if _, _, err := s.Get(ctx, release.ManifestKey("bab", "kass", "v01")); err != nil { |
diff --git a/internal/host/versionsweep.go b/internal/host/versionsweep.go| index 6ee76ffc8..53a023125 100644 |
| --- a/internal/host/versionsweep.go |
| +++ b/internal/host/versionsweep.go |
| @@ -1,10 +1,12 @@ |
| package host |
| import ( |
| + "fmt" |
| "log" |
| "os" |
| "time" |
| + "github.com/carlosframework/platform/internal/activator" |
| "github.com/carlosframework/platform/internal/registry" |
| "github.com/carlosframework/platform/internal/release" |
| ) |
| @@ -74,6 +76,18 @@ func sweepStagedVersions(reg routeLister, optDir, genPath string, grace time.Dur |
| pinned[r.GoodVersion] = true |
| } |
| } |
| + // The builds the instances' settings and build states name (ruling |
| + // R-T9-STORE): a held instance's recovery onto last_executed runs its |
| + // staged copy here (EvidenceStaged), so that copy is spoken for however |
| + // long ago it last ran. |
| + settings, err := settingsPins(routes) |
| + if err != nil { |
| + log.Printf("adopt: staged-version sweep skipped: %v", err) |
| + return |
| + } |
| + for v := range settings { |
| + pinned[v] = true |
| + } |
| // Every live or pending generation the agent published (spec §8.5, |
| // ruling R19 item 1): a worker's captured build stays pinned after the |
| // live link and the row have both moved on. Neither file present is no |
| @@ -112,3 +126,35 @@ func sweepStagedVersions(reg routeLister, optDir, genPath string, grace time.Dur |
| func sweepStagedVersionsPass(reg routeLister) { |
| sweepStagedVersions(reg, release.OptDir(), release.GenerationsPath(dataDir()), versionSweepGrace(os.Getenv), time.Now()) |
| } |
| + |
| +// settingsPins is every build the rows' update settings and build-state |
| +// copies name: a pin, last_executed and a resolved request's T. It reads |
| +// the registry columns root reads (ParseUpdateSettingsLenient, |
| +// ParseBuildStateCopy); a copy that kept its last known state after a |
| +// failed refresh still names it. A column that does not parse, or a held |
| +// instance (settings other than default) whose build state is not read |
| +// yet, is an incomplete pin set: its last_executed is unknown, so the |
| +// sweep deletes nothing. |
| +func settingsPins(routes []registry.Route) (map[string]bool, error) { |
| + out := map[string]bool{} |
| + for _, r := range routes { |
| + u, err := release.ParseUpdateSettingsLenient(r.UpdateSettings) |
| + if err != nil { |
| + return nil, fmt.Errorf("update settings of %s: %w", r.Host, err) |
| + } |
| + if u.Pin != "" { |
| + out[u.Pin] = true |
| + } |
| + c, err := release.ParseBuildStateCopy(r.BuildState) |
| + if err != nil { |
| + return nil, fmt.Errorf("build state of %s: %w", r.Host, err) |
| + } |
| + if !c.Known && !u.Default() && activator.ActivatorManaged(r) { |
| + return nil, fmt.Errorf("the build state of held %s is not read yet", r.Host) |
| + } |
| + for _, v := range buildStateBuilds(c.State) { |
| + out[v] = true |
| + } |
| + } |
| + return out, nil |
| +} |
diff --git a/internal/host/versionsweep_test.go b/internal/host/versionsweep_test.go| index 493173085..6960c0e83 100644 |
| --- a/internal/host/versionsweep_test.go |
| +++ b/internal/host/versionsweep_test.go |
| @@ -270,3 +270,78 @@ func TestSweepStagedVersionsKeepsTheGoodBuild(t *testing.T) { |
| t.Error("gone survived; nothing names it and it is older than the grace") |
| } |
| } |
| + |
| +// settingsRow is an activator-managed instance with the given build-state |
| +// copy and update-settings columns. |
| +func settingsRow(host, buildState, settings string) registry.Route { |
| + return registry.Route{Host: host, Kind: "instance", App: "blog", Account: "acct1", Hibernate: true, Socket: "/run/carlos/" + host + ".sock", |
| + Version: "v9", BuildState: buildState, UpdateSettings: settings} |
| +} |
| + |
| +// Ruling R-T9-STORE: the box-local sweep keeps every build a build state |
| +// names (last_executed and a resolved request's T) and every pin, so a held |
| +// instance's staged recovery copy (EvidenceStaged) is never evicted. |
| +func TestSweepStagedVersionsKeepsBuildStateBuildsAndPins(t *testing.T) { |
| + opt := t.TempDir() |
| + for _, v := range []string{"v1", "v2", "v3", "v4", "v9"} { |
| + stageSweepVersion(t, opt, "acct1", v, "blog", 48*time.Hour) |
| + } |
| + copyCol := release.BuildStateCopy{Known: true, Replica: true, |
| + State: &release.BuildState{LastExecuted: "v1", Resolved: &release.ResolvedRequest{ID: 2, Target: "v2"}}}.Column() |
| + reg := fakeLister{routes: []registry.Route{ |
| + settingsRow("a.example.com", copyCol, release.UpdateSettings{Updates: release.UpdatesManual, Channel: "stable"}.Column()), |
| + settingsRow("b.example.com", release.BuildStateCopy{Known: true}.Column(), release.UpdateSettings{Updates: release.UpdatesAuto, Channel: "stable", Pin: "v3"}.Column()), |
| + }} |
| + sweepStagedVersions(reg, opt, noGenerations(t), 6*time.Hour, time.Now()) |
| + for _, v := range []string{"v1", "v2", "v3", "v9"} { |
| + if !staged(t, opt, v) { |
| + t.Errorf("%s was removed", v) |
| + } |
| + } |
| + if staged(t, opt, "v4") { |
| + t.Error("v4 survived; nothing names it") |
| + } |
| +} |
| + |
| +// A copy that keeps its last known state after a failed refresh still pins |
| +// it: the build it names did not stop being the instance's. |
| +func TestSweepStagedVersionsKeepsALastKnownState(t *testing.T) { |
| + opt := t.TempDir() |
| + stageSweepVersion(t, opt, "acct1", "v1", "blog", 48*time.Hour) |
| + col := release.BuildStateCopy{Known: true, ReadErr: "timeout", State: &release.BuildState{LastExecuted: "v1"}}.Column() |
| + sweepStagedVersions(fakeLister{routes: []registry.Route{settingsRow("a.example.com", col, "")}}, opt, noGenerations(t), 6*time.Hour, time.Now()) |
| + if !staged(t, opt, "v1") { |
| + t.Error("the last known last_executed was removed after a failed refresh") |
| + } |
| +} |
| + |
| +// Any column the sweep cannot read is an incomplete pin set: an unparseable |
| +// build-state copy or update-settings column, or a held instance whose build |
| +// state is not read yet (its last_executed is unknown). Delete nothing. |
| +func TestSweepStagedVersionsDeletesNothingWhenSettingsPinsAreUnknown(t *testing.T) { |
| + manual := release.UpdateSettings{Updates: release.UpdatesManual, Channel: "stable"}.Column() |
| + cases := map[string]registry.Route{ |
| + "unparseable build state": settingsRow("a.example.com", "{", ""), |
| + "unparseable update settings": settingsRow("a.example.com", "", "{"), |
| + "held, build state unread": settingsRow("a.example.com", "", manual), |
| + "pinned, build state unread": settingsRow("a.example.com", "", release.UpdateSettings{Updates: release.UpdatesAuto, Channel: "stable", Pin: "v1"}.Column()), |
| + } |
| + for name, rt := range cases { |
| + t.Run(name, func(t *testing.T) { |
| + opt := t.TempDir() |
| + stageSweepVersion(t, opt, "acct1", "v1", "blog", 48*time.Hour) |
| + sweepStagedVersions(fakeLister{routes: []registry.Route{rt}}, opt, noGenerations(t), 6*time.Hour, time.Now()) |
| + if !staged(t, opt, "v1") { |
| + t.Error("swept with a pin set it could not complete") |
| + } |
| + }) |
| + } |
| + // Default settings with an unread copy: nothing is held on its |
| + // last_executed, so the sweep goes on. |
| + opt := t.TempDir() |
| + stageSweepVersion(t, opt, "acct1", "v1", "blog", 48*time.Hour) |
| + sweepStagedVersions(fakeLister{routes: []registry.Route{settingsRow("a.example.com", "", "")}}, opt, noGenerations(t), 6*time.Hour, time.Now()) |
| + if staged(t, opt, "v1") { |
| + t.Error("a default-settings row with an unread copy stopped the sweep") |
| + } |
| +} |
diff --git a/internal/status/contract_test.go b/internal/status/contract_test.go| index e29ee4dca..ca8855275 100644 |
| --- a/internal/status/contract_test.go |
| +++ b/internal/status/contract_test.go |
| @@ -82,7 +82,7 @@ var allowedBoxKeys = map[string][]string{ |
| "": {"generated_at", "label", "region", "routes", "load", "capacity", "replication", "adoption_running", "config_path_running", "restart_path_running", "expired_canaries", "metrics"}, |
| "routes": {"static", "resident", "hibernating", "awake", "asleep"}, |
| "replication": {"healthy", "lag_seconds"}, |
| - "metrics": {"mem_total_bytes", "mem_avail_bytes", "load1", "disk_free_bytes", "release", "tunnel", "route_versions", "upstreams", "layout", "config_refusals", "config_refusals_total", "release_refusals", "proof_waits", "config_guard"}, |
| + "metrics": {"mem_total_bytes", "mem_avail_bytes", "load1", "disk_free_bytes", "release", "tunnel", "route_versions", "upstreams", "layout", "config_refusals", "config_refusals_total", "release_refusals", "proof_waits", "config_guard", "build_states", "build_states_err"}, |
| "metrics.route_versions": {wildcardAnyKey}, |
| "metrics.upstreams": {wildcardAnyKey}, |
| "metrics.upstreams.*[]": {"addr", "up", "since", "fails", "last_error"}, |
| @@ -99,6 +99,12 @@ var allowedBoxKeys = map[string][]string{ |
| // proof_waits (D3) names the host and build the box is holding for a dev |
| // proof, in the private block only, pinned to the field level too. |
| "metrics.proof_waits[]": {"host", "version", "sha256", "prover", "reason", "since"}, |
| + // build_states (settings spec rev 11 §4.4) is host -> the builds that |
| + // host's build state names, in the private block only, for the |
| + // housekeeper's retention. Keys are hostnames; the values are pinned to |
| + // the field level. |
| + "metrics.build_states": {wildcardAnyKey}, |
| + "metrics.build_states.*": {"account", "app", "builds", "complete"}, |
| } |
| // allowedFleetKeys are THE schema for Fleet's JSON output. Same friction as |
| @@ -344,6 +350,11 @@ func fixtureProbes() status.Probes { |
| {Host: "contract-proof-wait.internal", Version: "v3", SHA256: "e47fcc1e0123456789abcdef", Prover: "dev", |
| Reason: "no proof from dev", Since: time.Now().Add(-time.Hour)}, |
| }, |
| + // One host's build-state builds, so metrics.build_states is exercised |
| + // by the allowlist walk too. |
| + BuildStates: map[string]status.BuildStatePins{ |
| + "contract-held.internal": {Account: "bab", App: "km", Builds: []string{"v1", "v2"}, Complete: true}, |
| + }, |
| ConfigGuard: "bypassed (CARLOS_NO_SEALED_CONFIG)", |
| MemFrac: &memFrac, |
| DiskFrac: &diskFrac, |
diff --git a/internal/status/dryrun.go b/internal/status/dryrun.go| index 83dc32d8e..5e198c134 100644 |
| --- a/internal/status/dryrun.go |
| +++ b/internal/status/dryrun.go |
| @@ -140,6 +140,18 @@ const dryRunRolloutHost = "rollout-host.internal" |
| var dryRunRolloutNeedles = []string{dryRunRolloutHost} |
| +// dryRunBuildStateHost and dryRunBuildStateBuild are the build-state needles |
| +// (settings spec rev 11 §4.4): Box.Metrics.BuildStates names the hosts this |
| +// box runs and the builds their build states name, so the same scope split |
| +// applies. REQUIRED in status/boxes/*.json and BANNED in every other |
| +// published object. |
| +const ( |
| + dryRunBuildStateHost = "build-state-host.internal" |
| + dryRunBuildStateBuild = "v-dryrun-build-state" |
| +) |
| + |
| +var dryRunBuildStateNeedles = []string{dryRunBuildStateHost, dryRunBuildStateBuild} |
| + |
| // ScanForLeaks reports which of banned's strings appear anywhere in j. It is |
| // the single implementation both DryRun's self-check and |
| // contract_test.go's scanForLeaks test helper run — the test package can't |
| @@ -246,6 +258,10 @@ func dryRunFixtures() ([]registry.Route, Probes) { |
| {Account: "dryrun", App: "app", Build: "v-dryrun", Host: dryRunRolloutHost, Mode: "overlap", Reason: ReasonHealth, PromotedAt: time.Now(), At: time.Now()}, |
| }, |
| }, |
| + // One host's build-state builds, for the build_states scope check. |
| + BuildStates: map[string]BuildStatePins{ |
| + dryRunBuildStateHost: {Account: "dryrun", App: "app", Builds: []string{dryRunBuildStateBuild}, Complete: true}, |
| + }, |
| // The probe table, keyed by a route host and carrying a backend |
| // address — see dryRunUpstreamNeedles. One up and one down upstream, |
| // so the down one's reason string is exercised too. |
| @@ -410,6 +426,9 @@ func DryRun(dir string, out io.Writer) error { |
| // rolloutKeySeen is the same positive half for the rollout report |
| // (dryRunRolloutNeedles). |
| rolloutKeySeen := false |
| + // buildStateKeySeen is the same positive half for the build-state report |
| + // (dryRunBuildStateNeedles). |
| + buildStateKeySeen := false |
| for _, key := range keys { |
| body, _, err := s.Get(ctx, key) |
| @@ -513,6 +532,17 @@ func DryRun(dir string, out io.Writer) error { |
| return fmt.Errorf("dry run: %s leaked a rollout fixture %v — Rollout must never reach the public feed", key, leaks) |
| } |
| + // dryRunBuildStateNeedles: the eighth scope split, on the same |
| + // pattern. The build-state report names hosts and builds. |
| + if strings.HasPrefix(key, "status/boxes/") { |
| + if leaks := ScanForLeaks(body, dryRunBuildStateNeedles); len(leaks) == 0 { |
| + return fmt.Errorf("dry run: %s: expected build_states fixtures %v, found none. BuildStates not attached?", key, dryRunBuildStateNeedles) |
| + } |
| + buildStateKeySeen = true |
| + } else if leaks := ScanForLeaks(body, dryRunBuildStateNeedles); len(leaks) > 0 { |
| + return fmt.Errorf("dry run: %s leaked a build_states fixture %v. BuildStates must never reach the public feed", key, leaks) |
| + } |
| + |
| // Page assets (index.html, status.css) aren't JSON and simply fail |
| // to decode here — silently skipped, since the band-digit check |
| // only means anything for objects that actually carry Load/Capacity |
| @@ -549,6 +579,9 @@ func DryRun(dir string, out io.Writer) error { |
| if !rolloutKeySeen { |
| return fmt.Errorf("dry run: no status/boxes/*.json object was scanned — rollout scope check never ran") |
| } |
| + if !buildStateKeySeen { |
| + return fmt.Errorf("dry run: no status/boxes/*.json object was scanned. The build_states scope check never ran") |
| + } |
| fmt.Fprintln(out, "dry-run: no leaks") |
| return nil |
diff --git a/internal/status/snapshot.go b/internal/status/snapshot.go| index 424863e9b..487e36553 100644 |
| --- a/internal/status/snapshot.go |
| +++ b/internal/status/snapshot.go |
| @@ -178,6 +178,11 @@ type Probes struct { |
| // ConfigGuard is "" in the normal mode and a words-only note when the |
| // config guard's explicit bypass is in effect on this box (amadan #213). |
| ConfigGuard string |
| + // BuildStates is the build-state builds of the instances this box hosts |
| + // (settings spec rev 11 §4.4), and BuildStatesErr says the box could not |
| + // collect them. Private Metrics only: they name hosts. |
| + BuildStates map[string]BuildStatePins |
| + BuildStatesErr string |
| } |
| // RowLayout is one instance-kind route's layout, as the box's layout pass |
| @@ -449,6 +454,17 @@ type BoxMetrics struct { |
| // binary that ignores them; the console refuses those settings until |
| // every box reports UpdateSettingsProtocol. |
| UpdateSettings int `json:"update_settings,omitempty"` |
| + // BuildStates is, per instance this box hosts that keeps a build state, |
| + // the builds that build state names: last_executed and a resolved |
| + // request's target (settings spec rev 11 §4.4). The housekeeper protects |
| + // them, because it cannot read the regional data buckets the objects |
| + // live in. Host-keyed and private like RouteVersions; omitted when empty. |
| + BuildStates map[string]BuildStatePins `json:"build_states,omitempty"` |
| + // BuildStatesErr is set when this box could not collect BuildStates at |
| + // all (its registry list failed). The housekeeper prunes nothing while a |
| + // fresh report says so (Astra I2 #5): an absent map is never "nothing to |
| + // protect". |
| + BuildStatesErr string `json:"build_states_err,omitempty"` |
| // RouteVersions is host -> registry.Route.Version for every non-platform |
| // route this box carries with a Version the adopt pass has stamped |
| // (issue #78's staged_version chip) — a hostname-keyed map, which is |
| @@ -535,6 +551,17 @@ type BoxMetrics struct { |
| // once it applies update settings and protects the builds they name. |
| const UpdateSettingsProtocol = 1 |
| +// BuildStatePins is one instance's build-state builds in a box report. |
| +// Complete is false when this box could not read the instance's build state |
| +// (never read, a failed refresh, an unparseable copy): the housekeeper then |
| +// prunes nothing for the app (Astra I1 #6). |
| +type BuildStatePins struct { |
| + Account string `json:"account"` |
| + App string `json:"app"` |
| + Builds []string `json:"builds"` |
| + Complete bool `json:"complete"` |
| +} |
| + |
| // AliasReading is one alias hostname's status as CollectBox saw it this |
| // tick: Route is the route it points at (registry.RouteAlias.Host), DNSStatus |
| // and CertExpiresAt are the box-stamped health columns (Task 2/5), carried |
| @@ -926,7 +953,7 @@ func CollectBox(routes []registry.Route, p Probes, now time.Time) Box { |
| p.InstanceID != "" || p.Platform != "" || p.SystemChannel != "" || p.System != nil || |
| len(routeVersions) > 0 || len(aliasReadings) > 0 || len(p.Upstreams) > 0 || |
| len(routeDBBytes) > 0 || p.Layout != nil || len(p.ConfigRefusals) > 0 || len(p.ReleaseRefusals) > 0 || len(p.ProofWaits) > 0 || p.Rollout != nil || p.ConfigGuard != "" || |
| - p.Alerts != nil || p.GeoIP != nil { |
| + p.Alerts != nil || p.GeoIP != nil || len(p.BuildStates) > 0 || p.BuildStatesErr != "" { |
| box.Metrics = &BoxMetrics{ |
| ConsoleProcesses: p.ConsoleProcesses, |
| ActivationID: p.ActivationID, ActivationComplete: p.ActivationComplete, |
| @@ -963,6 +990,9 @@ func CollectBox(routes []registry.Route, p Probes, now time.Time) Box { |
| ProofWaits: append([]ProofWait(nil), p.ProofWaits...), |
| Rollout: p.Rollout, |
| ConfigGuard: p.ConfigGuard, |
| + // The caller builds a fresh map every tick. |
| + BuildStates: p.BuildStates, |
| + BuildStatesErr: p.BuildStatesErr, |
| } |
| } |
diff --git a/internal/status/status_test.go b/internal/status/status_test.go| index 57c0d07eb..2801921fa 100644 |
| --- a/internal/status/status_test.go |
| +++ b/internal/status/status_test.go |
| @@ -1615,3 +1615,63 @@ func TestCollectBoxCarriesTheConfigGuardBypass(t *testing.T) { |
| t.Errorf("a box in the normal mode attached metrics: %+v", quiet.Metrics) |
| } |
| } |
| + |
| +// TestCollectBoxCarriesBuildStates (settings spec rev 11 §4.4): a box names |
| +// the build-state builds of the instances it hosts in its private Metrics |
| +// block, so the housekeeper can protect them. The hostnames and builds never |
| +// reach the public fleet.json, and a box with none carries no key at all. |
| +func TestCollectBoxCarriesBuildStates(t *testing.T) { |
| + pins := map[string]status.BuildStatePins{ |
| + "held-secret.example.com": {Account: "bab", App: "km", Builds: []string{"v1-secret-build", "v2-secret-build"}, Complete: true}, |
| + } |
| + routes := []registry.Route{{Host: "held-secret.example.com", Kind: "instance", Hibernate: true}} |
| + b := status.CollectBox(routes, status.Probes{MachineID: "m", BuildStates: pins}, time.Now()) |
| + if b.Metrics == nil { |
| + t.Fatal("metrics block missing: build states alone should attach it") |
| + } |
| + if got := b.Metrics.BuildStates; !reflect.DeepEqual(got, pins) { |
| + t.Fatalf("build_states = %+v, want %+v", got, pins) |
| + } |
| + |
| + s := teststore(t) |
| + ctx := context.Background() |
| + if err := status.WriteBox(ctx, s, b); err != nil { |
| + t.Fatal(err) |
| + } |
| + boxes, complete, err := status.LoadBoxesComplete(ctx, s) |
| + if err != nil || !complete || len(boxes) != 1 || boxes[0].Metrics == nil || !reflect.DeepEqual(boxes[0].Metrics.BuildStates, pins) { |
| + t.Fatalf("read back %+v, %v, %v; want the build states", boxes, complete, err) |
| + } |
| + f, err := status.BuildFleet(ctx, s, routes, time.Now(), 5*time.Minute) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + fj, err := json.Marshal(f) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + for _, needle := range []string{"held-secret.example.com", "v1-secret-build", "build_states"} { |
| + if strings.Contains(string(fj), needle) { |
| + t.Errorf("fleet.json leaked %q: %s", needle, fj) |
| + } |
| + } |
| + |
| + raw, _ := json.Marshal(status.CollectBox(routes, status.Probes{MachineID: "m", MemTotalBytes: new(int64)}, time.Now())) |
| + if strings.Contains(string(raw), "build_states") { |
| + t.Errorf("a box with no build states publishes build_states: %s", raw) |
| + } |
| +} |
| + |
| +// A box that could not collect its build states says so (Astra I2 #5): the |
| +// error alone attaches the Metrics block, so the housekeeper reads "could not |
| +// collect", never an absent map it might take for "nothing to protect". |
| +func TestCollectBoxCarriesABuildStatesFailure(t *testing.T) { |
| + b := status.CollectBox(nil, status.Probes{MachineID: "m", BuildStatesErr: "listing routes for build states: database is locked"}, time.Now()) |
| + if b.Metrics == nil || b.Metrics.BuildStatesErr != "listing routes for build states: database is locked" { |
| + t.Fatalf("metrics = %+v, want the collection failure", b.Metrics) |
| + } |
| + raw, _ := json.Marshal(b) |
| + if !strings.Contains(string(raw), `"build_states_err"`) { |
| + t.Fatalf("the failure is not published: %s", raw) |
| + } |
| +} |