| index 98548bbd6..a928ede84 100644 |
| --- a/docs/superpowers/plans/2026-10-06-box-identity-detect.md |
| +++ b/docs/superpowers/plans/2026-10-06-box-identity-detect.md |
| @@ -8,7 +8,7 @@ |
| |
| **Tech Stack:** Go 1.25, `release.Store` (S3 and DirStore), `internal/status`, `internal/host`, `internal/edgesfile`, `internal/console`, `internal/relay`, `internal/metrics`. |
| |
| -**Spec:** `docs/superpowers/specs/2026-10-06-box-identity-detect-design.md` (rev 5). §2.1 (the verdict table), §2.2 (the gate, absolute deadline, `retireAfter`), §2.3 (recovery and the retire order), §2.6 (the namespace rule, inside the decision) and §3 (reader rules, monotonic completeness, four reads before any return) are normative. Line numbers are against the branch after its merge of main (`fb59cb23`). Region HA's `box_id` (`internal/host/edgesfile.go:277`, `internal/console/boxchoice.go`) is a different field with a different meaning and is **not touched**; this plan adds `machine`. |
| +**Spec:** `docs/superpowers/specs/2026-10-06-box-identity-detect-design.md` (rev 6). §2.1 (the verdict table), §2.2 (the gate, absolute deadline, `retireAfter`), §2.3 (recovery and the retire order), §2.6 (the namespace rule, inside the decision) and §3 (reader rules, monotonic completeness, four reads before any return) are normative. Line numbers are against the branch after its merge of main (`fb59cb23`). Region HA's `box_id` (`internal/host/edgesfile.go:277`, `internal/console/boxchoice.go`) is a different field with a different meaning and is **not touched**; this plan adds `machine`. |
| |
| **Landing** (`AGENTS.md:108-130`): the task commits are not independently shippable (Task 2 arms snapshot refusal before Task 4 gates the other publishers and before Task 8's recovery tooling exists). When every task is done and reviewed: squash to one commit on the branch, push, `amadan branch describe carlos/platform design-box-identity -body -`, wait for `amadan ci status carlos/platform -branch design-box-identity` to pass on that tip, then `amadan branch merge carlos/platform design-box-identity -expect <tip> -message "..."`. |
| |
| @@ -16,7 +16,7 @@ |
| |
| - No object key, prefix, Route53 `SetIdentifier` or region-HA `box_id` changes (spec §1). |
| - `machine` and `instance_id` never appear in `status/fleet.json`, `status/index.html`, any account page, the instances API response, or an alert mail body (spec §1, §3, §7). |
| -- A label is never reassigned automatically; only `carlos status --retire-label` deletes a label's objects, only when its snapshot is older than `retireAfter = 3*status.SnapshotPeriod + mutationTTL + requestGrace` (17 minutes), edges file first and snapshot last, with a create-only tombstone when the snapshot is absent (spec §2.3). |
| +- A label is never reassigned automatically; only `carlos status --retire-label` deletes a label's objects, only when its snapshot is older than `retireAfter = 3*status.SnapshotPeriod + mutationTTL + requestGrace` (17 minutes), and only after replacing that snapshot with a tombstone by CAS on the etag it read (create-only when absent); then the edges file, then the tombstone last. A label with a verified region HA box record is freed only for the instance the record names (spec §2.3, §4). |
| - `owned` is recorded only after a successful conditional write; each decision attempt starts fresh; a failed `Put` is `unknown` (spec §2.1). |
| - The namespace rule (spec §2.6) runs inside `decideIdentity` on every call. |
| - Every `Put`, `Delete` and `DeleteIfMatch` under a label-keyed key goes through `host.Gated`, which refuses past the verdict's expiry and sets an absolute deadline `min(now, expiry) + mutationTTL`, `mutationTTL = 60 * time.Second` (spec §2.2). |
| @@ -1069,6 +1069,14 @@ func Gated(s release.Store, label string) release.Store { |
| } |
| |
| func (g *gatedStore) admit(ctx context.Context) (context.Context, context.CancelFunc, error) { |
| + // A caller paused across its own deadline (spec 2.2, B5-3): nothing is |
| + // sent on its behalf. |
| + if err := ctx.Err(); err != nil { |
| + return nil, nil, err |
| + } |
| + if d, has := ctx.Deadline(); has && !g.now().Before(d) { |
| + return nil, nil, context.DeadlineExceeded |
| + } |
| deadline, ok := admitted(g.dir(), g.me(), g.label, g.target(), g.now()) |
| if !ok { |
| return nil, nil, ErrNotPublishable |
| @@ -1076,6 +1084,11 @@ func (g *gatedStore) admit(ctx context.Context) (context.Context, context.Cancel |
| ctx, cancel := context.WithDeadline(ctx, deadline) |
| return ctx, cancel, nil |
| } |
| +``` |
| + |
| +Task 3's gate test gains one case: a `ctx` whose deadline is already in the past is refused with `context.DeadlineExceeded` before the identity file is read (count `readIdentity` calls through `dir()`). |
| + |
| +```go |
| |
| func (g *gatedStore) Put(ctx context.Context, key string, body []byte, ifMatch string) (string, error) { |
| ctx, cancel, err := g.admit(ctx) |
| @@ -1265,18 +1278,53 @@ func TestDirectoryDeploymentIsConfiguredAndDoesNotExpire(t *testing.T) { |
| } |
| |
| func TestCollectSnapshotCarriesRoutesAliasesAndVersions(t *testing.T) { |
| + // Every route-derived field spec 2.1 names must round-trip through the |
| + // decision's write (B5-5): counts, a sidecar, a stamped version, an |
| + // alias reading and a database size. |
| reg := restartTestRegistry(t) |
| seedRoute(t, reg, "a.example") |
| - b, routes, err := collectSnapshot(reg, status.Probes{MachineID: "m", InstanceID: "i-m"}, time.Now()) |
| - if err != nil || len(routes) == 0 { |
| + if err := reg.SetVersion("a.example", "abc1234"); err != nil { // the registry's version stamp, as the adopt pass does |
| + t.Fatal(err) |
| + } |
| + seedRoute(t, reg, "worker.example") |
| + if err := reg.SetSidecar("worker.example", true); err != nil { // the column registry.Route.IsSidecar reads |
| + t.Fatal(err) |
| + } |
| + if err := reg.UpsertAlias(registry.Alias{Host: "www.example", Route: "a.example", DNSStatus: "pointing"}); err != nil { |
| + t.Fatal(err) |
| + } |
| + probes := status.Probes{MachineID: "m", InstanceID: "i-m", RouteDBBytes: map[string]int64{"a.example": 4096}} |
| + b, routes, err := collectSnapshot(reg, probes, time.Now()) |
| + if err != nil || len(routes) != 2 { |
| t.Fatalf("collect: %v routes=%d", err, len(routes)) |
| } |
| - if b.Routes.Resident+b.Routes.Static+b.Routes.Hibernating == 0 || b.Machine != status.Machine("m") { |
| - t.Fatalf("the snapshot must carry the registry's routes and this machine: %+v", b) |
| + s := release.NewDirStore(t.TempDir()) |
| + if _, err := status.DecideLabel(context.Background(), s, b, b.GeneratedAt); err != nil { |
| + t.Fatal(err) |
| + } |
| + raw, _, _ := s.Get(context.Background(), "status/boxes/"+b.Label+".json") |
| + var got status.Box |
| + _ = json.Unmarshal(raw, &got) |
| + switch { |
| + case got.Machine != status.Machine("m"): |
| + t.Fatalf("machine: %+v", got) |
| + case got.Routes.Resident+got.Routes.Static+got.Routes.Hibernating != 1 || got.Sidecars != 1: |
| + t.Fatalf("one route and one sidecar: %+v", got) |
| + case got.Metrics == nil || got.Metrics.RouteVersions["a.example"] != "abc1234": |
| + t.Fatalf("version: %+v", got.Metrics) |
| + case got.Metrics.Aliases["www.example"].DNSStatus != "pointing": |
| + t.Fatalf("alias reading: %+v", got.Metrics.Aliases) |
| + case got.Metrics.RouteDBBytes["a.example"] != 4096: |
| + t.Fatalf("db size: %+v", got.Metrics.RouteDBBytes) |
| } |
| } |
| ``` |
| |
| +(`SetVersion`, `SetSidecar` and `UpsertAlias` stand for whatever the registry's real write methods for the version column, the sidecar column and `route_aliases` are called; the existing `statusTick` tests in `agent_test.go` seed the same columns and are the reference.) |
| + |
| +```go |
| +``` |
| + |
| ```go |
| // internal/host/edgesfile_test.go |
| func TestSeedEdgesFileGateKeepsOnlyOurOwnSeed(t *testing.T) { |
| @@ -1384,8 +1432,8 @@ func TestEveryPublisherStopsWhenTheLabelIsLost(t *testing.T) { |
| return edgesFilePass(ctx, s, edgesTestRegistry(t), edgesFile{Box: me.Label, Machine: me.ID}, "1.2.3.4", &last) |
| }}, |
| {"metrics flush", func(ctx context.Context, s release.Store) error { |
| - sm := newSampler(nil) // the sampler the existing metricsFlush tests build |
| - sm.record(now, metrics.Sample{At: now}) |
| + sm := &sampler{} // metrics_loop.go:60; seed buf the way the existing metricsFlush tests do |
| + sm.buf = append(sm.buf, metrics.Sample{At: now}) |
| return metricsFlush(ctx, s, me.Label, sm, now) |
| }}, |
| {"alert state", func(ctx context.Context, s release.Store) error { |
| @@ -1408,20 +1456,50 @@ func TestEveryPublisherStopsWhenTheLabelIsLost(t *testing.T) { |
| return s.Delete(ctx, key) |
| }}, |
| } |
| + // usage, error shipping and analytics go through the same shape: their |
| + // per-tick functions (usageFlush, the errship flush, the analytics |
| + // meter's Flush) are added to entries with a seeded sink, the way the |
| + // existing tests for each build one. |
| for _, e := range entries { |
| t.Run(e.name, func(t *testing.T) { |
| - g := Gated(release.NewDirStore(t.TempDir()), me.Label) |
| + inner := &mutationCounter{Store: release.NewDirStore(t.TempDir())} |
| + g := Gated(inner, me.Label) |
| own(status.VerdictOwned) |
| - if err := e.write(context.Background(), g); err != nil { |
| - t.Fatalf("owned: %v", err) |
| + if err := e.write(context.Background(), g); err != nil || inner.n == 0 { |
| + t.Fatalf("owned: %v, %d underlying mutations", err, inner.n) |
| } |
| + before := inner.n |
| own(status.VerdictForeign) |
| if err := e.write(context.Background(), g); !errors.Is(err, ErrNotPublishable) { |
| t.Fatalf("foreign: %v, want ErrNotPublishable", err) |
| } |
| + if inner.n != before { |
| + t.Fatalf("no underlying mutation may reach the store after revocation: %d -> %d", before, inner.n) |
| + } |
| }) |
| } |
| } |
| + |
| +// mutationCounter counts Put, Delete and DeleteIfMatch that reach the store. |
| +type mutationCounter struct { |
| + release.Store |
| + n int |
| +} |
| + |
| +func (m *mutationCounter) Put(ctx context.Context, key string, body []byte, ifMatch string) (string, error) { |
| + m.n++ |
| + return m.Store.Put(ctx, key, body, ifMatch) |
| +} |
| + |
| +func (m *mutationCounter) Delete(ctx context.Context, key string) error { |
| + m.n++ |
| + return m.Store.Delete(ctx, key) |
| +} |
| + |
| +func (m *mutationCounter) DeleteIfMatch(ctx context.Context, key, etag string) error { |
| + m.n++ |
| + return release.DeleteIfMatch(ctx, m.Store, key, etag) |
| +} |
| ``` |
| |
| ```go |
| @@ -1431,11 +1509,19 @@ func TestMeterFlushRetriesAndPreservationAreRefused(t *testing.T) { |
| now := time.Unix(1_700_000_000, 0) |
| m := NewMeter("annie", func() time.Time { return now }, nil) |
| m.SetAllocsAll(map[Identity]int{{Account: "a", App: "x"}: 1}) |
| - if err := m.Flush(context.Background(), &refuseAfterGet{Store: inner}); !errors.Is(err, errRefused) { |
| - t.Fatalf("flush = %v, want the refusal surfaced", err) |
| + // First attempt: a real CAS conflict (another writer landed a chunk |
| + // between the read and the write), so meter.go:167's retry runs; the |
| + // retry's Put is then refused (ownership flipped). B5-5. |
| + s := &conflictThenRefuse{Store: inner, key: MeterKey("annie", metrics.HourOf(now))} |
| + if err := m.Flush(context.Background(), s); !errors.Is(err, errRefused) { |
| + t.Fatalf("flush = %v, want the refusal surfaced on the retry", err) |
| + } |
| + if s.puts != 2 { |
| + t.Fatalf("one conflicting attempt and one refused retry, got %d puts", s.puts) |
| } |
| - if keys, _ := inner.List(context.Background(), "metrics/relay/"); len(keys) != 0 { |
| - t.Fatalf("nothing may be written after refusal, got %v", keys) |
| + body, _, _ := inner.Get(context.Background(), s.key) |
| + if !strings.Contains(string(body), "other-writer") { |
| + t.Fatal("the other writer's chunk must stand; nothing of ours may have landed") |
| } |
| key := MeterKey("annie", metrics.HourOf(now)) |
| if _, err := inner.Put(context.Background(), key, []byte("{bad"), ""); err != nil { |
| @@ -1449,6 +1535,25 @@ func TestMeterFlushRetriesAndPreservationAreRefused(t *testing.T) { |
| |
| var errRefused = errors.New("refused") |
| |
| +// conflictThenRefuse: the first Put to key loses a CAS race to a chunk |
| +// written behind the meter's back; every later Put is refused. |
| +type conflictThenRefuse struct { |
| + release.Store |
| + key string |
| + puts int |
| +} |
| + |
| +func (c *conflictThenRefuse) Put(ctx context.Context, key string, body []byte, ifMatch string) (string, error) { |
| + c.puts++ |
| + if c.puts == 1 && key == c.key { |
| + if _, err := c.Store.Put(ctx, key, []byte(`{"v":1,"box":"other-writer","samples":[]}`), ifMatch); err != nil { |
| + return "", err |
| + } |
| + return "", release.ErrCASConflict |
| + } |
| + return "", errRefused |
| +} |
| + |
| type refuseAfterGet struct { |
| release.Store |
| got bool |
| @@ -1625,7 +1730,7 @@ func TestRefuserLogsAndDeliversOncePerTransition(t *testing.T) { |
| func TestRefuserDeliversThroughAlertSinks(t *testing.T) { |
| var posted []string |
| post := func(url string, body []byte) error { posted = append(posted, string(body)); return nil } |
| - sinks := newAlertSinks("https://hook.example/x", post, nil, nil, &alertCounters{}) // alertsink.go:89's parameters; nil SNS |
| + sinks := newAlertSinks("https://hook.example/x", post, "", nil, &alertCounters{}) // alertsink.go:89: webhook URL, poster, SNS topic (""), SNS client (nil), counters |
| rules := metrics.RulesFromEnv(envFunc(map[string]string{})) |
| r := &refuser{logf: func(string, ...any) {}, deliver: func(e metrics.Event) { sinks.deliver(context.Background(), rules, time.Now(), e) }} |
| d := status.Decision{Verdict: status.VerdictForeign, Label: "edsger-knitting", Machine: status.Machine("b"), HolderMachine: status.Machine("a"), DecidedAt: time.Now()} |
| @@ -2346,11 +2451,21 @@ func TestResolveLabelStoresFollowsEachWritersRule(t *testing.T) { |
| if st.statusWhere != "s3://s/fleets/pi/" || st.deployWhere != "s3://d/" || !opened["s"] || !opened["d"] { |
| t.Fatalf("locations = %q %q", st.statusWhere, st.deployWhere) |
| } |
| - // The prefixed status store really is prefixed: a snapshot written through it lands under the prefix. |
| + // The prefixed status store really is prefixed: a snapshot written through |
| + // it lands under fleets/pi/ in the opener's store for bucket "s" (B5-5). |
| + stores := map[string]release.Store{} |
| + open = func(_ context.Context, bucket string) (release.Store, error) { |
| + if stores[bucket] == nil { |
| + stores[bucket] = release.NewDirStore(t.TempDir()) |
| + } |
| + return stores[bucket], nil |
| + } |
| + st, _ = resolveLabelStores(context.Background(), envFunc(map[string]string{"CARLOS_DEPLOYMENT_BUCKET": "d", "CARLOS_STATUS_BUCKET": "s", "CARLOS_STATUS_PREFIX": "fleets/pi/"}), open) |
| _ = status.WriteFleetBox(context.Background(), st.status, status.Box{GeneratedAt: time.Now(), Label: "desk"}) |
| - // (resolveLabelStores wraps with release.NewPrefixStore; the test's opener returns a fresh DirStore per bucket, |
| - // so list the inner store through a second resolve with no prefix to see the full key.) |
| - st2, _ := resolveLabelStores(context.Background(), envFunc(map[string]string{"CARLOS_DEPLOYMENT_BUCKET": "d"}), func(_ context.Context, b string) (release.Store, error) { return release.NewDirStore("/nonexistent"), nil }) |
| + if _, _, err := stores["s"].Get(context.Background(), "fleets/pi/status/boxes/desk.json"); err != nil { |
| + t.Fatalf("the snapshot must land under the prefix in bucket s: %v", err) |
| + } |
| + st2, _ := resolveLabelStores(context.Background(), envFunc(map[string]string{"CARLOS_DEPLOYMENT_BUCKET": "d"}), open) |
| if st2.statusWhere != "s3://d/" || st2.deployWhere != "s3://d/" { |
| t.Fatalf("default: status store is the deployment bucket at the root: %q %q", st2.statusWhere, st2.deployWhere) |
| } |
| @@ -2452,10 +2567,93 @@ func TestRetireLabelRefusesWhenTheObjectChangedUnderIt(t *testing.T) { |
| if !errors.Is(err, release.ErrCASConflict) { |
| t.Fatalf("an etag change between read and delete must surface as a CAS conflict, got %v", err) |
| } |
| + body, _, gerr := s.Get(ctx, "status/boxes/"+holder.Label+".json") |
| + if gerr != nil || !strings.Contains(string(body), status.TombstoneMachine) { |
| + t.Fatal("the tombstone (the lock) must still be in place after a lost edges race, for the operator to re-run") |
| + } |
| +} |
| + |
| +// B5-1: the holder renews its stale snapshot between retire's read and its |
| +// tombstone write; the CAS loses and nothing is deleted, both live objects |
| +// preserved. |
| +func TestRetireLabelStopsWhenTheHolderRenewsBetweenReadAndTombstone(t *testing.T) { |
| + st, s := oneStore(t) |
| + ctx := context.Background() |
| + now := time.Now() |
| + holder := status.CollectBox(nil, status.Probes{MachineID: "a", InstanceID: "i-a"}, now) |
| + _ = status.WriteBox(ctx, s, holder) |
| + _, _ = s.Put(ctx, edgesfile.Prefix+holder.Label+".json", []byte(`{"v":1,"box":"`+holder.Label+`","eip":"1.2.3.4","hosts":{}}`), "") |
| + later := now.Add(retireAfter + time.Second) |
| + renew := &renewBetween{Store: s, key: "status/boxes/" + holder.Label + ".json", renewed: status.CollectBox(nil, status.Probes{MachineID: "a", InstanceID: "i-a"}, later)} |
| + st.status, st.deploy = renew, renew |
| + var out bytes.Buffer |
| + if err := retireLabel(ctx, st, holder.Label, later, &out); !errors.Is(err, release.ErrCASConflict) { |
| + t.Fatalf("the tombstone write must lose to the renewal: %v", err) |
| + } |
| + if _, _, err := s.Get(ctx, edgesfile.Prefix+holder.Label+".json"); err != nil { |
| + t.Fatal("the live holder's edges file must be untouched") |
| + } |
| + body, _, _ := s.Get(ctx, "status/boxes/"+holder.Label+".json") |
| + var got status.Box |
| + _ = json.Unmarshal(body, &got) |
| + if got.Machine != holder.Machine || !got.GeneratedAt.Equal(later) { |
| + t.Fatalf("the renewed snapshot must stand: %+v", got) |
| + } |
| +} |
| + |
| +// renewBetween replaces key with renewed after retire's first read of it, |
| +// standing in for the holder's own DecideLabel renewing a stale snapshot. |
| +type renewBetween struct { |
| + release.Store |
| + key string |
| + renewed status.Box |
| + done bool |
| +} |
| + |
| +func (r *renewBetween) Get(ctx context.Context, key string) ([]byte, string, error) { |
| + body, etag, err := r.Store.Get(ctx, key) |
| + if err == nil && key == r.key && !r.done { |
| + r.done = true |
| + raw, _ := json.Marshal(r.renewed) |
| + _, _ = r.Store.Put(ctx, key, raw, etag) |
| + } |
| + return body, etag, err |
| +} |
| + |
| +func (r *renewBetween) DeleteIfMatch(ctx context.Context, key, etag string) error { |
| + return release.DeleteIfMatch(ctx, r.Store, key, etag) |
| +} |
| + |
| +// B5-2: a label with a region HA box record bound to another instance is |
| +// never freed for a different machine. |
| +func TestRetireLabelRefusesABoundLabelForAnotherInstance(t *testing.T) { |
| + st, s := oneStore(t) |
| + ctx := context.Background() |
| + now := time.Now() |
| + holder := status.CollectBox(nil, status.Probes{MachineID: "a", InstanceID: "i-a"}, now) |
| + _ = status.WriteBox(ctx, s, holder) |
| + publishTestBoxRecord(t, s, holder.Label, "i-a") // a record and bind for instance i-a, built with boxid's test helpers |
| + _ = status.WriteEvidence(ctx, s, status.Evidence{V: 1, Label: holder.Label, Machine: status.Machine("b"), InstanceID: "i-b", Verdict: status.VerdictForeign, HolderInstanceID: "i-a", SeenAt: now}) |
| + var out bytes.Buffer |
| + err := retireLabel(ctx, st, holder.Label, now.Add(retireAfter+time.Second), &out) |
| + if err == nil || !strings.Contains(err.Error(), "bound to instance i-a") { |
| + t.Fatalf("a bound label must not be freed for i-b: %v", err) |
| + } |
| if _, _, gerr := s.Get(ctx, "status/boxes/"+holder.Label+".json"); gerr != nil { |
| - t.Fatal("the snapshot (the lock) must still be there after a lost race") |
| + t.Fatal("nothing deleted") |
| + } |
| + // The same label, reclaimed by the instance the record names, is allowed. |
| + _ = status.WriteEvidence(ctx, s, status.Evidence{V: 1, Label: holder.Label, Machine: status.Machine("a2"), InstanceID: "i-a", Verdict: status.VerdictForeign, HolderInstanceID: "i-b", SeenAt: now}) |
| + out.Reset() |
| + if err := retireLabel(ctx, st, holder.Label, now.Add(retireAfter+time.Second), &out); err != nil { |
| + t.Fatalf("returning a bound label to its own instance is allowed: %v", err) |
| } |
| } |
| +``` |
| + |
| +(`publishTestBoxRecord` builds a record whose signed identity document names the given instance, using the fixtures `internal/boxid/boxid_test.go` already has for a signed document and the embedded certificate; copy that fixture's constructor.) |
| + |
| +```go |
| |
| // putBetween rewrites key after the first Get so the etag no longer matches, |
| // and FORWARDS DeleteIfMatch so the condition is actually exercised (B4-7). |
| @@ -2493,6 +2691,9 @@ func TestRetireLabelLoneEdgesFileUsesATombstone(t *testing.T) { |
| if !spy.sawTombstone { |
| t.Fatal("a lone edges file is retired under a create-only tombstone snapshot") |
| } |
| + if spy.deletes[0] != edgesfile.Prefix+"annie-welding.json" { |
| + t.Fatalf("edges first, tombstone last: %v", spy.deletes) |
| + } |
| if _, _, err := s.Get(ctx, edgesfile.Prefix+"annie-welding.json"); !errors.Is(err, os.ErrNotExist) { |
| t.Fatal("the lone edges file must be gone") |
| } |
| @@ -2507,6 +2708,7 @@ func TestRetireLabelLoneEdgesFileUsesATombstone(t *testing.T) { |
| type tombstoneSpy struct { |
| release.Store |
| sawTombstone bool |
| + deletes []string |
| } |
| |
| func (t *tombstoneSpy) Put(ctx context.Context, key string, body []byte, ifMatch string) (string, error) { |
| @@ -2517,7 +2719,11 @@ func (t *tombstoneSpy) Put(ctx context.Context, key string, body []byte, ifMatch |
| } |
| |
| func (t *tombstoneSpy) DeleteIfMatch(ctx context.Context, key, etag string) error { |
| - return release.DeleteIfMatch(ctx, t.Store, key, etag) |
| + if err := release.DeleteIfMatch(ctx, t.Store, key, etag); err != nil { |
| + return err |
| + } |
| + t.deletes = append(t.deletes, key) |
| + return nil |
| } |
| |
| func TestRetireLabelStopsWhenABoxAcquiresBetweenTheReads(t *testing.T) { |
| @@ -2613,15 +2819,22 @@ Expected: FAIL, undefined. |
| |
| - [ ] **Step 3: Implement `status_identity.go`** |
| |
| -`resolveLabelStores(ctx, getenv, open)`: status store is `open(host.ResolveStatusBucket(getenv))` wrapped with `release.NewPrefixStore` when `CARLOS_STATUS_PREFIX` is set, `statusWhere = "s3://<bucket>/<prefix>"`; the deployment store is `open(CARLOS_DEPLOYMENT_BUCKET)` or `release.NewDirStore(CARLOS_DEPLOYMENT_DIR)` with `deployWhere` accordingly; `RunStatus` passes `release.NewS3Store` as `open`. Both commands print both locations first. |
| +`resolveLabelStores(ctx, getenv, open)`: status store is `open(host.ResolveStatusBucket(getenv))` wrapped with `release.NewPrefixStore` when `CARLOS_STATUS_PREFIX` is set, `statusWhere = "s3://<bucket>/<prefix>"`; the deployment store is `open(CARLOS_DEPLOYMENT_BUCKET)` or `release.NewDirStore(CARLOS_DEPLOYMENT_DIR)` with `deployWhere` accordingly; `RunStatus` passes an adapter, `func(ctx context.Context, b string) (release.Store, error) { return release.NewS3Store(ctx, b) }`, because `NewS3Store` returns `*S3Store` (`store.go:557`). Both commands print both locations first. The root package has no `envFunc`; `status_identity_test.go` defines `func envFunc(m map[string]string) func(string) string { return func(k string) string { return m[k] } }`. |
| |
| `checkLabel`: `label, machine := status.Label(machineID), status.Machine(machineID)`; print `label <label>, machine <machine>`; `Get` the snapshot from `st.status` and the edges file from `st.deploy`; print each as absent or `machine`, `instance_id`, `fresh`/`stale` (snapshot via `BoxStale`), `machine`, `box_id`, `eip` (edges); print each unexpired evidence entry naming the label with holder and refuser instance ids; print whether `boxid.Key(label)` and `boxid.BindKey(label)` exist (`control/boxes/`, informational); print `claim references: not checked (hostclaim is not on main)`. Exit 0 when both objects are absent or every present object's `machine == machine`; exit 1 otherwise, printing `verdict: foreign` or `verdict: unproven` (an object with no `machine`). |
| |
| -`retireLabel`: read the snapshot (`st.status`) with etag. Present: parse; if `now.Sub(GeneratedAt) < retireAfter` return `label <label>: snapshot is <age> old, younger than retireAfter (<retireAfter>); a mutation may still be in flight, refusing`. Absent: write the tombstone create-only (`status.Box{GeneratedAt: time.Time{}, Label: label, Machine: status.TombstoneMachine}`, `Put(key, body, "")`); on `ErrCASConflict` print `a box took label <label> between the reads; nothing deleted` and return the error; remember the tombstone's etag; print `snapshot: already gone (tombstone written)`. Read the edges file (`st.deploy`) with etag; present: print its `machine`/`box_id`/`eip` and `release.DeleteIfMatch` it (a failed condition returns the error, snapshot untouched); absent: print `edges file: already gone`. Finally `release.DeleteIfMatch` the snapshot or tombstone on its etag. `ErrConditionalDeleteUnsupported` is returned as is. The doc comment states the claim dependency (spec §4). |
| +`retireLabel` (spec §2.3, codex B5-1, B5-2), in this order: |
| + |
| +1. Region HA check: `boxid.Load(ctx, st.deploy, label)`; when a record exists and verifies (`boxid.Verifier`, the same way `eligibleBoxes`'s callers load it), compare its `InstanceID` with the refuser's instance id from the unexpired evidence naming the label; if there is no such refuser or the ids differ, return `label <label> is bound to instance <id> by its box record (control/boxes/<label>.json) and can only return to that instance; give the new box a fresh machine id instead`. |
| +2. Read the snapshot (`st.status`) with its etag. Present: parse; if `now.Sub(GeneratedAt) < retireAfter` return `label <label>: snapshot is <age> old, younger than retireAfter (<retireAfter>); a mutation may still be in flight, refusing`. Then **take the lock**: `Put(key, tombstone, etag)` where `tombstone` is `status.Box{GeneratedAt: time.Time{}, Label: label, Machine: status.TombstoneMachine}` and `etag` is the one read (`""` when the snapshot was absent, so the create is create-only). On `ErrCASConflict` print `the holder renewed or a box took label <label> between the read and the write; nothing deleted` and return the error. Remember the tombstone's etag. Print `snapshot: replaced by a tombstone` or `snapshot: already gone (tombstone written)`. |
| +3. Read the edges file (`st.deploy`) with its etag; present: print its `machine`/`box_id`/`eip` and `release.DeleteIfMatch` it (a failed condition returns the error with the tombstone in place; the operator re-runs); absent: print `edges file: already gone`. |
| +4. `release.DeleteIfMatch` the tombstone on its etag, last. |
| + |
| +`ErrConditionalDeleteUnsupported` is returned as is. The doc comment states the claim dependency (spec §4). |
| |
| `RunStatus`: `checkFlag := fs.String("check-label", "", "show who holds the label this machine id gives; exit 1 if another machine does")`, `retireFlag := fs.String("retire-label", "", "free a stale label after a collision (spec 2.3)")`; `statusUsage` gains both lines. `printLiveBoxes(w, boxes, evidence, now)`: a label named by unexpired evidence prints ` CONTESTED (holder <instance>, refuser <instance>)`. |
| |
| -`HostRows` loads evidence (a nil store or a failed load leaves rows uncontested) and sets `Contested`, `HolderInstance`, `RefuserInstance`; `fleet.html`'s Host cell renders `{{if .Contested}}<span class="chip chip-stale">contested</span> holder {{.HolderInstance}}, refuser {{.RefuserInstance}}{{end}}`. |
| +`HostRows` projects all three contention rules (spec §3, codex B5-6): it loads evidence, the observation file and the edges files (through `edgesfile.Parse`), and marks a row `Contested` when the label is named by unexpired evidence, has an active `contested_until`, or its edges file's `machine` differs from the snapshot's; `HolderInstance` is the snapshot's `Metrics.InstanceID`, `RefuserInstance` the evidence's when there is one, and `Reason` one of `evidence`, `writer change`, `edges mismatch`. A nil store or a failed load leaves rows uncontested. `fleet.html`'s Host cell renders `{{if .Contested}}<span class="chip chip-stale">contested</span> {{.Reason}}{{if .HolderInstance}}, holder {{.HolderInstance}}{{end}}{{if .RefuserInstance}}, refuser {{.RefuserInstance}}{{end}}{{end}}`. `printLiveBoxes` prints the same three sources. `TestHostRowsCarryContestedAndBothInstances` gains two sibling cases, one with only an observation `contested_until` and one with only an edges mismatch, each asserting `Contested` and `Reason`. |
| |
| `docs/site/cli.md` `### carlos status`, after the `--live` example: |
| |
| @@ -2704,7 +2917,7 @@ func TestDryRunNeedlesIncludeIdentityAndTheRunPublishesEvidence(t *testing.T) { |
| func TestInstanceListNeverCarriesMachineOrInstanceID(t *testing.T) { |
| fx, token := instanceFixture(t, "ie") |
| enableInstances(t, fx, "titogo", titoManifest()) |
| - st, _, body := rawReq(t, http.MethodPost, instancesURL(fx, OpsAccountID, "titogo"), token, `{"name":"acme"}`) |
| + st, _, body := rawReq(t, http.MethodPost, instancesURL(fx, OpsAccountID, "titogo"), token, `{"host":"acme.go.tito.io","region":"ie"}`) // apiinstances.go:642 requires host |
| if st != http.StatusCreated && st != http.StatusOK { |
| t.Fatalf("create: %d %s", st, body) |
| } |
| @@ -2794,8 +3007,14 @@ func TestDecideLabelSimulation(t *testing.T) { |
| if rng.Intn(4) == 0 { |
| ms[n-1].id = ms[0].id // a clone |
| } |
| - holder := map[string]string{} // label -> instance holding it, "" = unknown after an old overwrite |
| + holder := map[string]string{} // label -> instance holding it, "" = unknown after an old overwrite |
| lastOwnedAt := map[string]time.Time{} |
| + retired := 0 |
| + defer func() { |
| + if seed == 0 && retired == 0 { |
| + t.Log("seed 0 retired nothing; the deterministic retirement tests below cover the path") |
| + } |
| + }() |
| const retireAfter = 3*status.SnapshotPeriod + 2*time.Minute |
| for tick := 0; tick < 60; tick++ { |
| now = now.Add(time.Minute) |
| @@ -2806,12 +3025,18 @@ func TestDecideLabelSimulation(t *testing.T) { |
| if rng.Intn(15) == 0 { |
| ms[rng.Intn(n)].old = !ms[rng.Intn(n)].old // a roll |
| } |
| - if rng.Intn(40) == 0 { // an operator retires a stale label |
| - for label, at := range lastOwnedAt { |
| - if now.Sub(at) > retireAfter { |
| - key := "status/boxes/" + label + ".json" |
| - if _, etag, err := inner.Get(context.Background(), key); err == nil { |
| - _ = release.DeleteIfMatch(context.Background(), inner, key, etag) |
| + if rng.Intn(40) == 0 { // an operator retires a stale label, judged from the STORED snapshot (B5-7) |
| + for label := range lastOwnedAt { |
| + key := "status/boxes/" + label + ".json" |
| + raw, etag, err := inner.Get(context.Background(), key) |
| + if err != nil { |
| + continue |
| + } |
| + var cur status.Box |
| + _ = json.Unmarshal(raw, &cur) |
| + if now.Sub(cur.GeneratedAt) > retireAfter { |
| + if release.DeleteIfMatch(context.Background(), inner, key, etag) == nil { |
| + retired++ |
| delete(holder, label) |
| delete(lastOwnedAt, label) |
| } |
| @@ -2852,6 +3077,47 @@ func TestDecideLabelSimulation(t *testing.T) { |
| } |
| } |
| |
| +// Deterministic retirement cases (B5-7): a writer that stopped is retired |
| +// once its stored snapshot is older than retireAfter; a legacy writer that |
| +// keeps refreshing is never retired, however long ago a new writer last |
| +// owned the label. |
| +func TestRetirementJudgesTheStoredSnapshot(t *testing.T) { |
| + s := release.NewDirStore(t.TempDir()) |
| + ctx := context.Background() |
| + now := time.Now() |
| + const retireAfter = 3*status.SnapshotPeriod + 2*time.Minute |
| + stopped := boxFor("stopped", "i-s", now) |
| + if _, err := status.DecideLabel(ctx, s, stopped, now); err != nil { |
| + t.Fatal(err) |
| + } |
| + legacyLabel := status.Label("legacy") |
| + key := "status/boxes/" + legacyLabel + ".json" |
| + later := now.Add(retireAfter + time.Minute) |
| + raw, _ := json.Marshal(status.Box{GeneratedAt: later, Label: legacyLabel, Metrics: &status.BoxMetrics{InstanceID: "i-l"}}) |
| + if _, err := s.Put(ctx, key, raw, ""); err != nil { |
| + t.Fatal(err) |
| + } |
| + retire := func(label string) bool { |
| + k := "status/boxes/" + label + ".json" |
| + body, etag, err := s.Get(ctx, k) |
| + if err != nil { |
| + return false |
| + } |
| + var cur status.Box |
| + _ = json.Unmarshal(body, &cur) |
| + if later.Sub(cur.GeneratedAt) <= retireAfter { |
| + return false |
| + } |
| + return release.DeleteIfMatch(ctx, s, k, etag) == nil |
| + } |
| + if !retire(stopped.Label) { |
| + t.Fatal("a stopped writer's old snapshot is retired") |
| + } |
| + if retire(legacyLabel) { |
| + t.Fatal("a legacy writer that keeps refreshing is never retired") |
| + } |
| +} |
| + |
| // A deterministic old-overwrite-then-upgrade case: permitted, and owned. |
| func TestOldWriterThatOverwroteThenUpgradedIsOwned(t *testing.T) { |
| s := release.NewDirStore(t.TempDir()) |
| @@ -2917,9 +3183,12 @@ Under the fleet status section, after the `CARLOS_FLEET_BOX` bullet: |
| - Before a new box joins: `carlos status --check-label <machine-id>` |
| (read the id with `cat /etc/machine-id` over SSM after first boot, |
| before bootstrapping the agent). Exit 1 means another machine holds |
| - that label: give the box a new machine id |
| - (`rm /etc/machine-id && systemd-machine-id-setup && reboot`) and it |
| - comes back under a new label. Nothing else is needed. |
| + that label: give the box a fresh machine id and prove it changed |
| + (`old=$(cat /etc/machine-id); systemd-id128 new | tr -d '\n' > |
| + /etc/machine-id; rm -f /var/lib/dbus/machine-id; test "$(cat |
| + /etc/machine-id)" != "$old" && reboot`; `systemd-machine-id-setup` |
| + alone may reuse an id), then `--check-label` on the new id must exit |
| + 0. It comes back under a new label. Nothing else is needed. |
| - If an established box was displaced during a roll (it refuses and the |
| newer box holds its label): `--check-label` on the newer box (stop if |
| it names an open claim operation); `systemctl stop carlos-edge.service` |