enginesim: --public step 3 is its own rule; --grant refused on an unresolved minted row (#335 plan 1)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
4 files changed,
+122
−16
internal/sim/engines/classify_test.go+42 −13internal/sim/engines/rules_test.go+2 −1internal/sim/engines/witness_test.go+74 −2internal/sim/engines/world_test.go+4 −0
diff --git a/internal/sim/engines/classify_test.go b/internal/sim/engines/classify_test.go| index 7ba31d27a..50ac8a968 100644 |
| --- a/internal/sim/engines/classify_test.go |
| +++ b/internal/sim/engines/classify_test.go |
| @@ -189,12 +189,20 @@ func (w *world) grantLoaded(b *box, g string) string { |
| // grant is `carlos route --host <host> --grant doc-engine`: it records the |
| // grant, writes 1 on the group if the group now holds it, and returns once |
| -// the edge has loaded the change (or says it timed out). |
| +// the edge has loaded the change (or says it timed out). On a minted row |
| +// whose owner cannot be resolved it is refused: the CLI cannot find the |
| +// group to mark, and the row itself stays NULL and refused. |
| func (w *world) grant(b *box, host string) bool { |
| r := b.Rows[host] |
| if r == nil || r.Kind != "instance" || r.Grant { |
| return false |
| } |
| + if followsOwner(r) { |
| + if _, ok := b.resolveOwner(r); !ok { |
| + w.logf("box%d: --grant doc-engine on %s: refused: the row's owner is not recorded and cannot be found uniquely; the row stays NULL and refused", b.ID, host) |
| + return false |
| + } |
| + } |
| b.Wrote++ |
| r.Grant, r.GrantAt = true, b.Wrote |
| g := w.group(b, host) |
| @@ -342,10 +350,12 @@ func (w *world) residualAtUpgrade(b *box) string { |
| // §5.1, "Becoming public is never automatic"). Holding the adoption lock: |
| // 1. refuse unless the live manifest is readable and does not declare |
| // (public-checks-live), and no row of the group holds the grant; |
| -// 2. stop the whole group, and |
| -// 3. refuse unless every cgroup of the group is empty (public-stops-group); |
| +// 2. stop the whole group (public-stops-group); |
| +// 3. refuse unless every cgroup of the group is empty (public-confirms-empty); |
| // 4. write 0 and release the lock. |
| // |
| +// Step 3 is what catches a stop that did not complete (w.clearStopMisses). |
| +// |
| // w.clearHook, if set, runs after step 1 ("locked") and after step 2 |
| // ("stopped"). |
| func (w *world) operatorPublic(b *box, g string) bool { |
| @@ -367,21 +377,39 @@ func (w *world) operatorPublic(b *box, g string) bool { |
| } |
| w.hook(b, g, "locked") |
| if on("public-stops-group") { |
| - w.stopGroup(b, g) |
| - w.logf("box%d: --public %s: the group is stopped", b.ID, g) |
| - w.hook(b, g, "stopped") |
| - if w.running(b, g) { |
| - w.logf("box%d: --public %s: refused: a cgroup of the group is not empty", b.ID, g) |
| - return false |
| - } |
| - } else { |
| - w.hook(b, g, "stopped") |
| + w.clearStop(b, g) |
| + } |
| + w.hook(b, g, "stopped") |
| + if on("public-confirms-empty") && w.running(b, g) { |
| + w.logf("box%d: --public %s: refused: a cgroup of the group is not empty", b.ID, g) |
| + return false |
| } |
| w.writeMark(b, g, markPublic) |
| w.logf("box%d: --public %s: 0 written; the lock is released", b.ID, g) |
| return true |
| } |
| +// clearStop is step 2: stop every process of group g, except, when |
| +// w.clearStopMisses is set, the first one, whose stop did not complete. |
| +func (w *world) clearStop(b *box, g string) { |
| + var left *proc |
| + if w.clearStopMisses { |
| + for _, p := range b.Procs { |
| + if p.Group == g { |
| + left = p |
| + break |
| + } |
| + } |
| + } |
| + w.stopGroup(b, g) |
| + if left != nil { |
| + b.Procs = append(b.Procs, left) |
| + w.logf("box%d: --public %s: the stop did not complete: process %d (v%d) still runs", b.ID, g, left.ID, left.Ver) |
| + return |
| + } |
| + w.logf("box%d: --public %s: the group is stopped", b.ID, g) |
| +} |
| + |
| func (w *world) hook(b *box, g, stage string) { |
| if w.clearHook != nil { |
| w.clearHook(b, g, stage) |
| @@ -455,8 +483,9 @@ func init() { |
| } |
| } |
| } |
| + w.clearStopMisses = w.rng.Intn(4) == 0 |
| w.operatorPublic(b, g) |
| - w.clearHook = nil |
| + w.clearHook, w.clearStopMisses = nil, false |
| }}, |
| action{name: "adopt-awake", weight: 2, run: func(w *world) { |
| b := w.pickBox() |
diff --git a/internal/sim/engines/rules_test.go b/internal/sim/engines/rules_test.go| index a8a54911c..a022424d1 100644 |
| --- a/internal/sim/engines/rules_test.go |
| +++ b/internal/sim/engines/rules_test.go |
| @@ -64,6 +64,7 @@ var allRules = []string{ |
| "static-minted-public", // a minted static row is 0 by construction, never classified (R6-2) |
| "owner-nonempty-unique", // the owner fallback matches only non-empty backends, and only uniquely (R7-2) |
| "public-checks-live", // --public step 1: the live manifest is readable and does not declare |
| - "public-stops-group", // --public steps 2-3: stop the group, refuse unless its cgroups are empty (R7-1, R8-2) |
| + "public-stops-group", // --public step 2: stop the whole group (R7-1, R8-2) |
| "public-holds-lock", // --public holds the adoption lock: the live link cannot move under it |
| + "public-confirms-empty", // --public step 3: refuse unless every cgroup of the group is empty |
| } |
diff --git a/internal/sim/engines/witness_test.go b/internal/sim/engines/witness_test.go| index 103b1e94d..5eace5b84 100644 |
| --- a/internal/sim/engines/witness_test.go |
| +++ b/internal/sim/engines/witness_test.go |
| @@ -77,8 +77,10 @@ var witnesses = []witness{ |
| "R7-2: two TCP instances of one app, the plain one's minted row minted before owners were recorded; a fallback that matches empty sockets puts the row in the engine's group, which marks it 1, so a public row never answers; with the rule, a match that is not unique is refused and logged, and INV-AV does not count it", wOwnerNonEmptyUnique}, |
| {"public-checks-live", "public-checks-live", "INV-I1+INV-P", |
| "--public on a group whose live release declares; without step 1 the clear writes 0, and systemd's socket activation starts the declaring live build ungated (INV-I1) for a public request (INV-P)", wPublicChecksLive}, |
| - {"public-stops-group", "public-stops-group", "INV-I1+INV-P", |
| - "R7-1/R8-2: a generation rollout repointed the live link to a plain build and only scheduled the swap, so the declaring generation still serves; a clear that neither stops the group nor checks its cgroups writes 0 under it", wPublicStopsGroup}, |
| + {"public-stops-group", "public-stops-group,public-confirms-empty", "INV-I1+INV-P", |
| + "R7-1/R8-2: redundant alone: without the stop, step 3 finds the declaring generation still serving and refuses; with both gone, a clear after a rollout that repointed the live link to a plain build and only scheduled the swap writes 0 under the still-serving declaring generation", wPublicStopsGroup}, |
| + {"public-confirms-empty", "public-confirms-empty", "INV-I1+INV-P", |
| + "the clear's stop does not complete and leaves the declaring generation running; without step 3 the clear writes 0 under it (INV-I1) and a public request reaches it (INV-P)", wPublicConfirmsEmpty}, |
| {"public-holds-lock", "public-holds-lock", "INV-I1+INV-P", |
| "--public checks a plain live release; without the adoption lock, adoption repoints to a declaring build before the stop; the clear writes 0 over a declaring live link, and socket activation starts it ungated", wPublicHoldsLock}, |
| } |
| @@ -717,6 +719,76 @@ func wPublicStopsGroup(t *testing.T, w *world) { |
| w.check() |
| } |
| +// wPublicConfirmsEmpty: the engine runs a declaring generation; a rollout of |
| +// a plain build repoints the live link and only schedules the swap; the |
| +// operator runs --public and its stop leaves that generation running; a |
| +// public request arrives. |
| +func wPublicConfirmsEmpty(t *testing.T, w *world) { |
| + b := w.boxes[0] |
| + if !w.adopt(b, eng, vEngine, nil) { |
| + t.Fatalf("witness: adoption of the engine refused: %s", w.boxesState()) |
| + } |
| + w.check() |
| + if !w.repointAwake(b, eng, vPlain) { |
| + t.Fatalf("witness: the rollout's repoint was refused: %s", w.boxesState()) |
| + } |
| + w.check() |
| + w.clearStopMisses = true |
| + w.clearPublic(b, eng) |
| + w.clearStopMisses = false |
| + w.requestBy(b, engHost) |
| + w.check() |
| +} |
| + |
| +// TestGrantRefusedOnUnresolvedMintedRow: --grant doc-engine on a minted row |
| +// whose owner is not recorded and cannot be found uniquely (here two |
| +// instances of its app share its address) is refused; the row stays NULL at |
| +// the edge and refused, and no group is marked. |
| +func TestGrantRefusedOnUnresolvedMintedRow(t *testing.T) { |
| + w := newWitness() |
| + b := w.boxes[0] |
| + for _, h := range []string{tcpC, tcpD} { |
| + if !w.provision(b, h, tcpApp, "10.0.0.7:9103", vPlain) { |
| + t.Fatalf("provisioning %s refused: %s", h, w.boxesState()) |
| + } |
| + } |
| + w.mint(b, tcpC, mintedC, false) |
| + // and one whose recorded owner has no row |
| + b.Rows["gone"] = &row{Host: "gone", Kind: "instance", App: tcpApp, Backend: "10.0.0.7:9104", PathTo: map[string]string{}} |
| + w.mint(b, "gone", "gone.acct.apps.example", true) |
| + delete(b.Rows, "gone") |
| + w.reload(b) |
| + wrote := b.Wrote |
| + for _, h := range []string{mintedC, "gone.acct.apps.example"} { |
| + if w.grant(b, h) { |
| + t.Errorf("--grant on %s, whose owner cannot be resolved, succeeded", h) |
| + } |
| + if b.Rows[h].Grant { |
| + t.Errorf("--grant on %s recorded the grant", h) |
| + } |
| + if b.Serving[h] != markNull { |
| + t.Errorf("the edge serves %s %v, want NULL", h, b.Serving[h]) |
| + } |
| + if r := w.admitPublic(b, h, "/"); r != nil { |
| + t.Errorf("a public request to %s was admitted", h) |
| + } |
| + } |
| + if b.Wrote != wrote { |
| + t.Errorf("a refused grant changed the registry (%d to %d)", wrote, b.Wrote) |
| + } |
| + for _, g := range []string{tcpC, tcpD} { |
| + if m := w.groupMark(b, g); m != markPublic { |
| + t.Errorf("group %s is %v after a refused grant, want public", g, m) |
| + } |
| + } |
| + if len(w.violations) > 0 { |
| + t.Errorf("violations: %v", w.violations) |
| + } |
| + if t.Failed() { |
| + dump(t, w) |
| + } |
| +} |
| + |
| // wPublicHoldsLock: the engine was adopted to a plain build (the mark stays |
| // 1); the operator runs --public; after its checks pass and before it stops |
| // the group, adoption of a declaring build runs; then a public request |
diff --git a/internal/sim/engines/world_test.go b/internal/sim/engines/world_test.go| index 91a423520..83c5c7a41 100644 |
| --- a/internal/sim/engines/world_test.go |
| +++ b/internal/sim/engines/world_test.go |
| @@ -141,6 +141,10 @@ type world struct { |
| // ("locked": step 1 passed; "stopped": step 2 done), so a scenario or the |
| // walk can interleave there. |
| clearHook func(b *box, g, stage string) |
| + // clearStopMisses, when set, makes operatorPublic's stop leave one |
| + // process of the group behind (a unit or generation whose stop the |
| + // activator or control service failed to complete). |
| + clearStopMisses bool |
| verbose bool |
| } |