enginesim: classification, minted groups, the operator clear (#335 plan 1)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
7 files changed,
+1002
−51
internal/sim/engines/classify_test.go+491 −0internal/sim/engines/edge_test.go+79 −17internal/sim/engines/invariants_test.go+72 −0internal/sim/engines/launch_test.go+49 −13internal/sim/engines/rules_test.go+13 −0internal/sim/engines/witness_test.go+260 −13internal/sim/engines/world_test.go+38 −8
diff --git a/internal/sim/engines/classify_test.go b/internal/sim/engines/classify_test.go| new file mode 100644 |
| index 000000000..7ba31d27a |
| --- /dev/null |
| +++ b/internal/sim/engines/classify_test.go |
| @@ -0,0 +1,491 @@ |
| +package engines |
| + |
| +import ( |
| + "fmt" |
| + "sort" |
| + "strings" |
| +) |
| + |
| +// Classification, minted groups and the operator clear (spec §5.1 "The |
| +// column", "Platform-minted rows", "Becoming public is never automatic", |
| +// "Classification before serving"; §6.3). |
| + |
| +// ---------- minted groups ---------- |
| + |
| +// followsOwner: the row is a platform-minted row that takes its execution |
| +// owner's mark and barrier. Only minted instance rows do: a minted static or |
| +// service row is 0 by construction (static-minted-public). |
| +func followsOwner(r *row) bool { |
| + if !r.Platform || !on("minted-follow-owner") { |
| + return false |
| + } |
| + return r.Kind == "instance" || (r.Kind == "static" && !on("static-minted-public")) |
| +} |
| + |
| +// resolveOwner is the edge's (and the agent's) owner for a minted row, under |
| +// the rules in force. |
| +func (b *box) resolveOwner(r *row) (string, bool) { |
| + return b.resolveOwnerWith(r, on("owner-nonempty-unique")) |
| +} |
| + |
| +// resolveOwnerWith: the recorded owner, if its row exists; else the |
| +// non-platform instance row with the same account and app and the same |
| +// backend. strict is the spec's fallback: empty values never match, and a |
| +// match that is not unique counts as no match. Without it, empty values |
| +// match and the first match wins. |
| +func (b *box) resolveOwnerWith(r *row, strict bool) (string, bool) { |
| + if r.Owner != "" { |
| + if o := b.Rows[r.Owner]; o != nil && !o.Platform { |
| + return r.Owner, true |
| + } |
| + return "", false |
| + } |
| + var found []string |
| + for _, h := range hostsOf(b) { |
| + c := b.Rows[h] |
| + if c.Platform || c.Kind != "instance" || c.App != r.App { |
| + continue |
| + } |
| + cs, ca, rs, ra := socketOf(c), addrOf(c), socketOf(r), addrOf(r) |
| + if strict && ((cs != "" && cs == rs) || (ca != "" && ca == ra)) || |
| + !strict && (cs == rs || ca == ra) { |
| + found = append(found, h) |
| + } |
| + } |
| + if len(found) == 1 || (!strict && len(found) > 0) { |
| + return found[0], true |
| + } |
| + return "", false |
| +} |
| + |
| +// socketOf and addrOf split a backend into the registry's Socket and Addr. |
| +func socketOf(r *row) string { |
| + if strings.HasPrefix(r.Backend, "/") { |
| + return r.Backend |
| + } |
| + return "" |
| +} |
| + |
| +func addrOf(r *row) string { |
| + if r.Backend != "" && !strings.HasPrefix(r.Backend, "/") { |
| + return r.Backend |
| + } |
| + return "" |
| +} |
| + |
| +func hostsOf(b *box) []string { |
| + hosts := make([]string, 0, len(b.Rows)) |
| + for h := range b.Rows { |
| + hosts = append(hosts, h) |
| + } |
| + sort.Strings(hosts) |
| + return hosts |
| +} |
| + |
| +// truthGroup is the group a row really serves: a minted row's source, else |
| +// its own host. The oracle reads it; the protocol never does. |
| +func truthGroup(b *box, host string) string { |
| + if r := b.Rows[host]; r != nil && r.Platform && r.Source != "" { |
| + return r.Source |
| + } |
| + return host |
| +} |
| + |
| +// mint is the domains reconciler minting a platform row from src: it copies |
| +// the source's kind, backend and app (no version), and, being a row of the |
| +// same group, the group's mark. recordOwner is whether it writes |
| +// routes.owner (rows minted before the column was added have none until the |
| +// reconciler's next pass). |
| +func (w *world) mint(b *box, src, host string, recordOwner bool) { |
| + s := b.Rows[src] |
| + m := w.groupMark(b, w.group(b, src)) |
| + r := &row{Host: host, Kind: s.Kind, App: s.App, Backend: s.Backend, Platform: true, Source: src, PathTo: map[string]string{}} |
| + if recordOwner { |
| + r.Owner = src |
| + } |
| + b.Rows[host] = r |
| + b.Mark[host] = m |
| + b.Wrote++ |
| + w.logf("box%d: registry change %d: minted %s %s from %s (owner %q, mark %v)", b.ID, b.Wrote, r.Kind, host, src, r.Owner, m) |
| +} |
| + |
| +// reconcileOwners is the reconciler's backfill pass: it records the owner of |
| +// every minted instance row that has none. The row takes its owner group's |
| +// mark (one group, one mark). |
| +func (w *world) reconcileOwners(b *box) { |
| + for _, h := range hostsOf(b) { |
| + r := b.Rows[h] |
| + if !r.Platform || r.Kind != "instance" || r.Owner != "" || b.Rows[r.Source] == nil { |
| + continue |
| + } |
| + m := w.groupMark(b, w.group(b, r.Source)) |
| + r.Owner = r.Source |
| + b.Mark[h] = m |
| + b.Wrote++ |
| + w.logf("box%d: registry change %d: owner of %s recorded as %s (mark %v)", b.ID, b.Wrote, h, r.Owner, m) |
| + } |
| +} |
| + |
| +// provision creates instance route host (a new route: NULL, unclassified), |
| +// then its first adoption classifies it before it is served: a declaring |
| +// build through the gate, a readable plain one as 0 (spec §5.1, "A route the |
| +// agent creates later ... is classified by its first adoption"). |
| +func (w *world) provision(b *box, host, app, backend string, ver int) bool { |
| + b.Rows[host] = &row{Host: host, Kind: "instance", App: app, Backend: backend, PathTo: map[string]string{}} |
| + b.Wrote++ |
| + w.logf("box%d: registry change %d: provisioned %s (%s, %s), NULL until its first adoption", b.ID, b.Wrote, host, app, backend) |
| + ok := w.adopt(b, host, ver, nil) |
| + if w.groupMark(b, host) == markNull { |
| + if m := w.classify(b, host); m != markNull { |
| + w.writeMark(b, host, m) |
| + } |
| + } |
| + return ok |
| +} |
| + |
| +// addStaticApp creates static app name with its minted platform row and a |
| +// static canary on its own channel (domains/reconcile.go:368). |
| +func (w *world) addStaticApp(b *box, name string) (minted, canary string) { |
| + b.Rows[name] = &row{Host: name, Kind: "static", App: "acct/" + name, PathTo: map[string]string{}} |
| + b.Mark[name] = markPublic // 0 by construction |
| + b.Wrote++ |
| + w.logf("box%d: registry change %d: static app %s", b.ID, b.Wrote, name) |
| + minted, canary = name+".acct.apps.example", "canary-"+name+".acct.apps.example" |
| + w.mint(b, name, minted, true) |
| + w.mint(b, name, canary, true) |
| + return minted, canary |
| +} |
| + |
| +// ---------- grants ---------- |
| + |
| +// groupHasGrant: a row of group g holds the doc-engine grant. With |
| +// grant-marks-group, any row of the group; without it, only the owner's own |
| +// row (a grant on a minted row is ignored, R6-1). |
| +func (w *world) groupHasGrant(b *box, g string) bool { |
| + if !on("grant-marks-group") { |
| + r := b.Rows[g] |
| + return r != nil && r.Grant |
| + } |
| + for _, h := range w.groupHosts(b, g) { |
| + if b.Rows[h].Grant { |
| + return true |
| + } |
| + } |
| + return false |
| +} |
| + |
| +// grantLoaded is the oracle's grant: a row whose true group is g holds the |
| +// grant and the edge has loaded the change that recorded it. It returns that |
| +// row, or "". |
| +func (w *world) grantLoaded(b *box, g string) string { |
| + for _, h := range hostsOf(b) { |
| + r := b.Rows[h] |
| + if r.Grant && truthGroup(b, h) == g && b.Fenced >= r.GrantAt { |
| + return h |
| + } |
| + } |
| + return "" |
| +} |
| + |
| +// 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). |
| +func (w *world) grant(b *box, host string) bool { |
| + r := b.Rows[host] |
| + if r == nil || r.Kind != "instance" || r.Grant { |
| + return false |
| + } |
| + b.Wrote++ |
| + r.Grant, r.GrantAt = true, b.Wrote |
| + g := w.group(b, host) |
| + w.logf("box%d: registry change %d: --grant doc-engine on %s (group %s)", b.ID, b.Wrote, host, g) |
| + if w.groupHasGrant(b, g) && w.groupMark(b, g) != markInternal { |
| + w.writeMark(b, g, markInternal) |
| + } |
| + want := b.Wrote |
| + for i := 0; i < fenceWaitTicks && b.Fenced < want; i++ { |
| + w.reload(b) |
| + } |
| + if b.Fenced < want { |
| + w.logf("box%d: --grant on %s: the edge has not loaded it; timed out", b.ID, host) |
| + return false |
| + } |
| + return true |
| +} |
| + |
| +// revoke is --revoke doc-engine: the grant goes; the mark stays (becoming |
| +// public is never automatic). |
| +func (w *world) revoke(b *box, host string) { |
| + r := b.Rows[host] |
| + if r == nil || !r.Grant { |
| + return |
| + } |
| + b.Wrote++ |
| + r.Grant = false |
| + w.logf("box%d: registry change %d: --revoke doc-engine on %s; the mark stays", b.ID, b.Wrote, host) |
| +} |
| + |
| +// ---------- classification before serving ---------- |
| + |
| +// classify is the upgrade's classification of group g: 1 if a row of the |
| +// group holds the grant, the live manifest declares, or the manifest of any |
| +// staged version declares (classify-staged); 0 if the live manifest is |
| +// readable and none of those holds; else NULL. |
| +func (w *world) classify(b *box, g string) mark { |
| + if w.groupHasGrant(b, g) { |
| + return markInternal |
| + } |
| + live, liveOK := w.builds[b.Live[g]] |
| + liveOK = liveOK && b.Live[g] != 0 && live.ManifestOK |
| + if liveOK && live.Declares { |
| + return markInternal |
| + } |
| + if on("classify-staged") { |
| + for _, v := range b.Staged[g] { |
| + if bd := w.builds[v]; bd.ManifestOK && bd.Declares { |
| + return markInternal |
| + } |
| + } |
| + } |
| + if liveOK { |
| + return markPublic |
| + } |
| + return markNull |
| +} |
| + |
| +// classifiedGroups is every group with a row the classification covers: an |
| +// instance row, or a row that follows an owner. |
| +func (w *world) classifiedGroups(b *box) []string { |
| + seen := map[string]bool{} |
| + var out []string |
| + for _, h := range hostsOf(b) { |
| + r := b.Rows[h] |
| + if r.Kind != "instance" && !followsOwner(r) { |
| + continue |
| + } |
| + if g := w.group(b, h); !seen[g] { |
| + seen[g] = true |
| + out = append(out, g) |
| + } |
| + } |
| + return out |
| +} |
| + |
| +// upgrade is the agent's first start on the build that adds routes.internal. |
| +// The column arrives NULL on every instance route; before any edge listener |
| +// opens, the agent classifies every NULL group. The edge then starts on the |
| +// classified map: in-flight requests did not survive the restart, and the |
| +// fence covers everything written. Running processes are untouched. |
| +func (w *world) upgrade(b *box) { |
| + gs := w.classifiedGroups(b) |
| + w.logf("box%d: upgrade: routes.internal arrives NULL on %v", b.ID, gs) |
| + for _, g := range gs { |
| + w.writeMark(b, g, markNull) |
| + } |
| + for _, g := range gs { |
| + if m := w.classify(b, g); m != markNull { |
| + w.writeMark(b, g, m) |
| + } else { |
| + w.logf("box%d: upgrade: %s stays NULL (live manifest unreadable): refused publicly, named every pass", b.ID, g) |
| + } |
| + } |
| + b.loadEdge() |
| + b.Epoch++ |
| + if len(b.Admitted) > 0 { |
| + w.logf("box%d: upgrade: %d in-flight requests ended with the restart", b.ID, len(b.Admitted)) |
| + } |
| + b.Admitted = nil |
| + b.Fenced = b.Wrote |
| + w.logf("box%d: upgrade: listeners open on the classified map, epoch %d, fenced %d", b.ID, b.Epoch, b.Fenced) |
| +} |
| + |
| +// residualAtUpgrade names the spec's stated residual if an upgrade of b now |
| +// would produce it: a running declaring build whose manifest cannot be read, |
| +// in a group the (correct) classification would mark 0 because the live |
| +// manifest reads and does not declare. The default world does not generate |
| +// it; Task 5's flag does. |
| +func (w *world) residualAtUpgrade(b *box) string { |
| + for _, g := range w.groups(b) { |
| + var unreadable *proc |
| + for _, p := range b.Procs { |
| + if bd := w.builds[p.Ver]; p.Group == g && bd.Declares && !bd.ManifestOK { |
| + unreadable = p |
| + } |
| + } |
| + if unreadable == nil { |
| + continue |
| + } |
| + granted := false |
| + for _, h := range hostsOf(b) { |
| + granted = granted || (b.Rows[h].Grant && truthGroup(b, h) == g) |
| + } |
| + live, ok := w.builds[b.Live[g]] |
| + if granted || b.Live[g] == 0 || !ok || !live.ManifestOK || live.Declares { |
| + continue |
| + } |
| + staged := false |
| + for _, v := range b.Staged[g] { |
| + if bd := w.builds[v]; bd.ManifestOK && bd.Declares { |
| + staged = true |
| + } |
| + } |
| + if !staged { |
| + return fmt.Sprintf("%s runs v%d (declares, manifest unreadable) while live v%d reads as plain", g, unreadable.Ver, b.Live[g]) |
| + } |
| + } |
| + return "" |
| +} |
| + |
| +// ---------- the operator clear ---------- |
| + |
| +// operatorPublic is `carlos route --host <host> --public` on group g (spec |
| +// §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); |
| +// 4. write 0 and release the lock. |
| +// |
| +// w.clearHook, if set, runs after step 1 ("locked") and after step 2 |
| +// ("stopped"). |
| +func (w *world) operatorPublic(b *box, g string) bool { |
| + w.logf("box%d: --public %s: taking the adoption lock", b.ID, g) |
| + if on("public-holds-lock") { |
| + b.Locked = true |
| + defer func() { b.Locked = false }() |
| + } |
| + if on("public-checks-live") { |
| + live, ok := w.builds[b.Live[g]] |
| + if b.Live[g] == 0 || !ok || !live.ManifestOK || live.Declares { |
| + w.logf("box%d: --public %s: refused: the live release v%d is unreadable or declares doc_engine", b.ID, g, b.Live[g]) |
| + return false |
| + } |
| + } |
| + if w.groupHasGrant(b, g) { |
| + w.logf("box%d: --public %s: refused: a row of the group holds the doc-engine grant", b.ID, g) |
| + return false |
| + } |
| + 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.writeMark(b, g, markPublic) |
| + w.logf("box%d: --public %s: 0 written; the lock is released", b.ID, g) |
| + return true |
| +} |
| + |
| +func (w *world) hook(b *box, g, stage string) { |
| + if w.clearHook != nil { |
| + w.clearHook(b, g, stage) |
| + } |
| +} |
| + |
| +// ---------- the random walk ---------- |
| + |
| +// pickInstanceRow is any instance row of b, minted included, or "". |
| +func (w *world) pickInstanceRow(b *box) string { |
| + var hs []string |
| + for _, h := range hostsOf(b) { |
| + if b.Rows[h].Kind == "instance" { |
| + hs = append(hs, h) |
| + } |
| + } |
| + if len(hs) == 0 { |
| + return "" |
| + } |
| + return hs[w.rng.Intn(len(hs))] |
| +} |
| + |
| +// maxRows bounds a box's table in the walk. |
| +const maxRows = 24 |
| + |
| +func init() { |
| + actions = append(actions, |
| + action{name: "upgrade", weight: 1, run: func(w *world) { |
| + b := w.pickBox() |
| + if why := w.residualAtUpgrade(b); why != "" { |
| + w.logf("box%d: upgrade not generated: the stated residual (%s)", b.ID, why) |
| + return |
| + } |
| + w.upgrade(b) |
| + }}, |
| + action{name: "grant", weight: 1, run: func(w *world) { |
| + b := w.pickBox() |
| + if h := w.pickInstanceRow(b); h != "" { |
| + w.grant(b, h) |
| + } |
| + }}, |
| + action{name: "revoke", weight: 1, run: func(w *world) { |
| + b := w.pickBox() |
| + if h := w.pickInstanceRow(b); h != "" { |
| + w.revoke(b, h) |
| + } |
| + }}, |
| + action{name: "operator-public", weight: 2, run: func(w *world) { |
| + b := w.pickBox() |
| + g := w.pickGroup(b) |
| + if g == "" { |
| + return |
| + } |
| + switch w.rng.Intn(4) { |
| + case 1: // an adoption tries to run during the clear |
| + w.clearHook = func(hb *box, hg, stage string) { |
| + if stage == "locked" { |
| + w.adopt(hb, hg, w.pickVer(), nil) |
| + } |
| + } |
| + case 2: // an awake repoint tries to run during the clear |
| + w.clearHook = func(hb *box, hg, stage string) { |
| + if stage == "locked" { |
| + w.repointAwake(hb, hg, w.pickVer()) |
| + } |
| + } |
| + case 3: // a swap's incoming generation races the stop |
| + w.clearHook = func(hb *box, hg, stage string) { |
| + if stage == "stopped" { |
| + w.launch(hb, hg, w.pickVer(), "swap") |
| + } |
| + } |
| + } |
| + w.operatorPublic(b, g) |
| + w.clearHook = nil |
| + }}, |
| + action{name: "adopt-awake", weight: 2, run: func(w *world) { |
| + b := w.pickBox() |
| + if g := w.pickGroup(b); g != "" { |
| + w.repointAwake(b, g, w.pickVer()) |
| + } |
| + }}, |
| + action{name: "add-tcp-instance", weight: 1, run: func(w *world) { |
| + b := w.pickBox() |
| + if len(b.Rows) >= maxRows { |
| + return |
| + } |
| + w.nextID++ |
| + host := fmt.Sprintf("t%d", w.nextID) |
| + app := []string{"acct/shop", "acct/tcp"}[w.rng.Intn(2)] |
| + addr := fmt.Sprintf("10.0.%d.1:%d", b.ID, 9100+w.nextID) // unique: no ambiguous owner in the walk |
| + w.provision(b, host, app, addr, w.pickVer()) |
| + w.mint(b, host, host+".acct.apps.example", w.rng.Intn(2) == 0) |
| + }}, |
| + action{name: "add-static-app", weight: 1, run: func(w *world) { |
| + b := w.pickBox() |
| + if len(b.Rows) >= maxRows { |
| + return |
| + } |
| + w.nextID++ |
| + w.addStaticApp(b, fmt.Sprintf("s%d", w.nextID)) |
| + }}, |
| + action{name: "reconcile-owners", weight: 1, run: func(w *world) { |
| + w.reconcileOwners(w.pickBox()) |
| + }}, |
| + ) |
| +} |
diff --git a/internal/sim/engines/edge_test.go b/internal/sim/engines/edge_test.go| index b6404892d..0a79413a5 100644 |
| --- a/internal/sim/engines/edge_test.go |
| +++ b/internal/sim/engines/edge_test.go |
| @@ -11,6 +11,12 @@ import "sort" |
| // loadEdge swaps the registry into the edge's map: rows by value (a later |
| // registry edit does not leak into the served map) and their marks. |
| +// |
| +// The edge resolves owners at load (spec §5.1, "Platform-minted rows"): a |
| +// row that follows an owner carries the resolved owner in its copy's Owner; |
| +// one whose owner cannot be found, or is found ambiguously, is served NULL |
| +// (refused) until the reconciler records its owner. Any other row's copy has |
| +// no Owner, so edgeGroup is its own host. |
| func (b *box) loadEdge() { |
| b.EdgeRows = make(map[string]row, len(b.Rows)) |
| b.Serving = make(map[string]mark, len(b.Rows)) |
| @@ -20,8 +26,16 @@ func (b *box) loadEdge() { |
| for p, to := range r.PathTo { |
| c.PathTo[p] = to |
| } |
| - b.EdgeRows[h] = c |
| b.Serving[h] = b.Mark[h] |
| + c.Owner = "" |
| + if followsOwner(r) { |
| + if o, ok := b.resolveOwner(r); ok { |
| + c.Owner = o |
| + } else { |
| + b.Serving[h] = markNull |
| + } |
| + } |
| + b.EdgeRows[h] = c |
| } |
| } |
| @@ -57,15 +71,16 @@ func (w *world) groupMark(b *box, g string) mark { |
| } |
| // writeMark writes mark m on every registry row of group g: one registry |
| -// change, so Wrote counts it. A change that sets the group to 1 is the one a |
| -// fence must reach (SetAt). |
| +// change, so Wrote counts it. A change that closes an open group (to 1, or to |
| +// NULL, which the edge refuses alike) is the one a fence must reach (SetAt). |
| +// 1 written over a closed NULL moves nothing: the edge already refuses it. |
| func (w *world) writeMark(b *box, g string, m mark) { |
| - before := w.groupMark(b, g) |
| + wasClosed := w.closed(b, g) |
| b.Wrote++ |
| for _, h := range w.groupHosts(b, g) { |
| b.Mark[h] = m |
| } |
| - if m == markInternal && before != markInternal { |
| + if !wasClosed && m != markPublic { |
| b.SetAt[g] = b.Wrote |
| } |
| w.logf("box%d: registry change %d: %s marked %v", b.ID, b.Wrote, g, m) |
| @@ -73,8 +88,28 @@ func (w *world) writeMark(b *box, g string, m mark) { |
| // fenced: the edge's fenced counter has reached the registry change that set |
| // group g's mark to 1, and no later change has cleared it (I1's "fenced"). |
| +// |
| +// NULL counts as closed here: the edge refuses NULL like 1 (spec §5.1, "The |
| +// column"), and the upgrade deliberately leaves NULL a group whose live |
| +// manifest it cannot read, whatever runs in it ("Classification before |
| +// serving"). A group closed only by NULL is fenced once the edge has loaded |
| +// the change that closed it. |
| func (w *world) fenced(b *box, g string) bool { |
| - return w.groupMark(b, g) == markInternal && b.Fenced >= b.SetAt[g] |
| + return w.closed(b, g) && b.Fenced >= b.SetAt[g] |
| +} |
| + |
| +// closed: group g has a row and none of its registry rows is 0. |
| +func (w *world) closed(b *box, g string) bool { |
| + hosts := w.groupHosts(b, g) |
| + if len(hosts) == 0 { |
| + return false |
| + } |
| + for _, h := range hosts { |
| + if b.Mark[h] == markPublic { |
| + return false |
| + } |
| + } |
| + return true |
| } |
| // reload is the edge's registry poll. A failed reload swaps nothing and |
| @@ -96,7 +131,7 @@ func (w *world) reload(b *box) bool { |
| b.loadEdge() |
| changed := map[string]bool{} |
| for h, m := range b.Serving { |
| - if m == markInternal && old[h] != markInternal { |
| + if (m == markInternal && old[h] != markInternal) || (m == markNull && old[h] == markPublic) { |
| changed[h] = true |
| } |
| } |
| @@ -129,45 +164,67 @@ func (w *world) reload(b *box) bool { |
| // nothing is admitted. Otherwise the request is admitted under the box's |
| // epoch, holding the final row's backend. |
| func (w *world) admitPublic(b *box, host, path string) *req { |
| + r, _ := w.admit(b, host, path) |
| + return r |
| +} |
| + |
| +// The outcomes of admit. |
| +const ( |
| + admitted = "admitted" // proxied: the request holds a backend |
| + answered = "answered" // a static row: the edge answers it itself |
| + refused = "refused" // the internal refusal |
| + unknown = "unknown" // no such host, alias target or path target |
| +) |
| + |
| +// admit is admitPublic with its outcome, for INV-AV. The edge treats NULL |
| +// like 1 (classify-null-refused). |
| +func (w *world) admit(b *box, host, path string) (*req, string) { |
| asked, ok := b.EdgeRows[host] |
| if !ok { |
| w.logf("box%d: public %s%s: unknown host", b.ID, host, path) |
| - return nil |
| + return nil, unknown |
| } |
| final := asked |
| if asked.Alias != "" { |
| if final, ok = b.EdgeRows[asked.Alias]; !ok { |
| w.logf("box%d: public %s%s: alias target %s unknown", b.ID, host, path, asked.Alias) |
| - return nil |
| + return nil, unknown |
| } |
| } |
| if to, ok := final.PathTo[path]; ok { |
| if final, ok = b.EdgeRows[to]; !ok { |
| w.logf("box%d: public %s%s: path target %s unknown", b.ID, host, path, to) |
| - return nil |
| + return nil, unknown |
| } |
| } |
| checked := final |
| if !on("mark-on-final-upstream") { |
| checked = asked |
| } |
| - if m := b.Serving[checked.Host]; m != markPublic { |
| + m := b.Serving[checked.Host] |
| + if m == markNull && !on("classify-null-refused") { |
| + w.logf("box%d: public %s%s: %s is NULL, served like 0", b.ID, host, path, checked.Host) |
| + m = markPublic |
| + } |
| + if m != markPublic { |
| w.logf("box%d: public %s%s: internal refusal (%s is %v)", b.ID, host, path, checked.Host, m) |
| - return nil |
| + return nil, refused |
| } |
| if final.Backend == "" { |
| w.logf("box%d: public %s%s: answered by the edge (%s)", b.ID, host, path, final.Host) |
| - return nil |
| + return nil, answered |
| } |
| w.nextID++ |
| r := &req{ID: w.nextID, Epoch: b.Epoch, Row: checked.Host, Backend: final.Backend, Public: true, Box: b.ID} |
| b.Admitted = append(b.Admitted, r) |
| w.logf("box%d: public %s%s admitted as request %d (row %s, backend %s, epoch %d)", b.ID, host, path, r.ID, r.Row, r.Backend, r.Epoch) |
| - return r |
| + return r, admitted |
| } |
| // backendGroup is the group whose process listens on backend: the group of |
| -// the instance row that carries it, whatever row the request came in by. |
| +// the instance row that carries it, whatever row the request came in by. A |
| +// minted row copies its source's backend; the process is the source's, so |
| +// the source's own row decides (the truth, whatever the edge resolved). |
| func (w *world) backendGroup(b *box, backend string) string { |
| hosts := make([]string, 0, len(b.Rows)) |
| for h := range b.Rows { |
| @@ -175,7 +232,7 @@ func (w *world) backendGroup(b *box, backend string) string { |
| } |
| sort.Strings(hosts) |
| for _, h := range hosts { |
| - if r := b.Rows[h]; r.Kind == "instance" && r.Backend == backend { |
| + if r := b.Rows[h]; r.Kind == "instance" && !r.Platform && r.Backend == backend { |
| return w.group(b, h) |
| } |
| } |
| @@ -185,7 +242,10 @@ func (w *world) backendGroup(b *box, backend string) string { |
| // deliver resumes an admitted request: unless the fence cancelled it, its |
| // proxy reaches the newest process of the group listening on its backend. |
| // INV-P: a public request never reaches a process whose build declares |
| -// doc_engine. |
| +// doc_engine, nor one of a group that holds the doc-engine grant once the |
| +// edge has loaded the grant (the grant sets 1 whatever the manifest says, |
| +// spec §5.1 "The column"; a grant whose barrier timed out protects nothing |
| +// until the edge loads it, and the load drains every request admitted before). |
| func (w *world) deliver(r *req) { |
| b := w.boxes[r.Box] |
| idx := -1 |
| @@ -215,6 +275,8 @@ func (w *world) deliver(r *req) { |
| w.logf("box%d: request %d delivered to process %d (%s v%d)", b.ID, r.ID, p.ID, p.Group, p.Ver) |
| if r.Public && w.builds[p.Ver].Declares { |
| w.fail("INV-P", "box%d: public request %d (row %s) delivered to process %d of %s running v%d, which declares doc_engine", b.ID, r.ID, r.Row, p.ID, p.Group, p.Ver) |
| + } else if r.Public && w.grantLoaded(b, g) != "" { |
| + w.fail("INV-P", "box%d: public request %d (row %s) delivered to process %d of %s running v%d, whose group holds the doc-engine grant (on %s)", b.ID, r.ID, r.Row, p.ID, p.Group, p.Ver, w.grantLoaded(b, g)) |
| } |
| if p.Ver == b.Live[g] { |
| b.Good[g] = p.Ver // the live build answered: it is the good version now |
diff --git a/internal/sim/engines/invariants_test.go b/internal/sim/engines/invariants_test.go| index 4be0034c0..40d2357d8 100644 |
| --- a/internal/sim/engines/invariants_test.go |
| +++ b/internal/sim/engines/invariants_test.go |
| @@ -90,3 +90,75 @@ func edgeGroup(b *box, host string) string { |
| } |
| return host |
| } |
| + |
| +// INV-AV (liveness, Task 3): at the end of the calm phase, every row of a |
| +// group whose mark is 0 answers a public request. It is what catches the |
| +// rules that only protect availability (static-minted-public, |
| +// owner-nonempty-unique), which no safety invariant can see. Checked in |
| +// final() only, after every edge has polled with its faults cleared. |
| +// |
| +// The truth, not the model's resolution, decides which rows must answer: |
| +// - a static or service row is 0 by construction (spec §5.1, "The column"), |
| +// minted or not; |
| +// - an instance row answers when its group's mark is 0: the registry mark |
| +// of its own row, or for a minted row, of the row it was minted from |
| +// (Source); |
| +// - alias rows are skipped: they answer as their target does. |
| +// |
| +// Not counted, as the spec's fail-closed choices or another rule's stated |
| +// refusal: a minted row whose owner the correct fallback cannot find |
| +// uniquely (refused and logged until the reconciler records its owner), and |
| +// any row an avExemption names. |
| +func (w *world) checkAV() { |
| + for _, b := range w.boxes { |
| + hosts := make([]string, 0, len(b.Rows)) |
| + for h := range b.Rows { |
| + hosts = append(hosts, h) |
| + } |
| + sort.Strings(hosts) |
| + for _, h := range hosts { |
| + r := b.Rows[h] |
| + switch r.Kind { |
| + case "alias": |
| + continue |
| + case "instance": |
| + owner := h |
| + if r.Platform { |
| + if _, ok := b.resolveOwnerWith(r, true); !ok { |
| + w.logf("box%d: final: minted row %s has no unique owner; refused by design, not counted", b.ID, h) |
| + continue |
| + } |
| + owner = r.Source |
| + } |
| + if b.Mark[owner] != markPublic { |
| + continue |
| + } |
| + } |
| + if why := w.avExempt(b, h); why != "" { |
| + w.logf("box%d: final: %s exempt from INV-AV: %s", b.ID, h, why) |
| + continue |
| + } |
| + req, outcome := w.admit(b, h, "/") |
| + switch outcome { |
| + case admitted: |
| + w.deliver(req) |
| + case answered: |
| + default: |
| + w.fail("INV-AV", "box%d: row %s (%s) of a public group is %s a public request at the end of the calm phase (registry %v, edge %v)", b.ID, h, r.Kind, outcome, b.Mark[h], b.Serving[h]) |
| + } |
| + } |
| + } |
| +} |
| + |
| +// avExemptions are later tasks' stated refusals of a public row (each |
| +// returns why, or ""), which INV-AV must not count. |
| +var avExemptions []func(w *world, b *box, host string) string |
| + |
| +func (w *world) avExempt(b *box, host string) string { |
| + for _, f := range avExemptions { |
| + if why := f(w, b, host); why != "" { |
| + return why |
| + } |
| + } |
| + return "" |
| +} |
diff --git a/internal/sim/engines/launch_test.go b/internal/sim/engines/launch_test.go| index b5bf36f27..02b2c2f8b 100644 |
| --- a/internal/sim/engines/launch_test.go |
| +++ b/internal/sim/engines/launch_test.go |
| @@ -94,6 +94,7 @@ func (w *world) launch(b *box, g string, ver int, path string) bool { |
| } |
| } |
| } |
| + w.stage(b, g, ver) // a build runs from /opt/carlos/versions: it is staged |
| w.nextID++ |
| p := &proc{ID: w.nextID, Group: g, Ver: ver, Slice: "inst:" + g} |
| b.Procs = append(b.Procs, p) |
| @@ -128,27 +129,57 @@ func (w *world) running(b *box, g string) bool { |
| // after the repoint and before the restart: whatever systemd does there. |
| func (w *world) adopt(b *box, g string, ver int, between func()) bool { |
| w.logf("box%d: adopt %s v%d (live v%d)", b.ID, g, ver, b.Live[g]) |
| + if !w.repoint(b, g, ver) { |
| + return false |
| + } |
| + if between != nil { |
| + between() |
| + } |
| + w.stopGroup(b, g) |
| + return w.launch(b, g, ver, "adopt") |
| +} |
| + |
| +// repointAwake is adoption that repoints the live link and does not restart: |
| +// an awake hibernating route (release/adopt.go:1174), or a generation |
| +// rollout that only schedules the swap. What ran keeps running. |
| +func (w *world) repointAwake(b *box, g string, ver int) bool { |
| + w.logf("box%d: adopt %s v%d without a restart (live v%d)", b.ID, g, ver, b.Live[g]) |
| + if !w.repoint(b, g, ver) { |
| + return false |
| + } |
| + w.logf("box%d: %s keeps its running processes until their next start", b.ID, g) |
| + return true |
| +} |
| + |
| +// repoint is adoption up to the repoint: the adoption lock (public-holds-lock), |
| +// set-before (the gate, reached before the repoint; on a timeout the link |
| +// does not move and the mark stays 1), then the repoint. |
| +func (w *world) repoint(b *box, g string, ver int) bool { |
| + if b.Locked && on("public-holds-lock") { |
| + w.logf("box%d: adopt %s v%d: --public holds the adoption lock; not this pass", b.ID, g, ver) |
| + return false |
| + } |
| if on("set-before-repoint") && !w.gate(b, g, ver, "adopt") { |
| w.logf("box%d: adopt %s v%d: not repointed this pass", b.ID, g, ver) |
| return false |
| } |
| b.Live[g] = ver |
| - staged := false |
| - for _, v := range b.Staged[g] { |
| - staged = staged || v == ver |
| - } |
| - if !staged { |
| - b.Staged[g] = append(b.Staged[g], ver) |
| - } |
| + w.stage(b, g, ver) |
| if b.Good[g] == 0 { |
| b.Good[g] = ver |
| } |
| w.logf("box%d: live link of %s -> v%d", b.ID, g, ver) |
| - if between != nil { |
| - between() |
| + return true |
| +} |
| + |
| +// stage records that build ver of group g is staged on the box. |
| +func (w *world) stage(b *box, g string, ver int) { |
| + for _, v := range b.Staged[g] { |
| + if v == ver { |
| + return |
| + } |
| } |
| - w.stopGroup(b, g) |
| - return w.launch(b, g, ver, "adopt") |
| + b.Staged[g] = append(b.Staged[g], ver) |
| } |
| // wake is the activator waking group g by its wake plan: "recovery" starts |
| @@ -206,12 +237,17 @@ func (w *world) socketActivate(b *box, g string) { |
| func (w *world) pickBox() *box { return w.boxes[w.rng.Intn(len(w.boxes))] } |
| -// groups is every instance group of b's registry, sorted. |
| +// groups is every instance group of b's registry, sorted: the groups of the |
| +// instance rows that are not platform-minted (adoption skips minted rows, |
| +// release/adopt.go:1210; their processes are their source's). |
| func (w *world) groups(b *box) []string { |
| seen := map[string]bool{} |
| var out []string |
| for h, r := range b.Rows { |
| - if g := w.group(b, h); r.Kind == "instance" && !seen[g] { |
| + if r.Kind != "instance" || r.Platform { |
| + continue |
| + } |
| + if g := w.group(b, h); !seen[g] { |
| seen[g] = true |
| out = append(out, g) |
| } |
diff --git a/internal/sim/engines/rules_test.go b/internal/sim/engines/rules_test.go| index 295b927d9..a8a54911c 100644 |
| --- a/internal/sim/engines/rules_test.go |
| +++ b/internal/sim/engines/rules_test.go |
| @@ -53,4 +53,17 @@ var allRules = []string{ |
| "unreadable-declares", // an unreadable manifest is treated as declaring |
| "live-link-only-adoption", // nothing but adoption (and --public) moves a live link (R11) |
| "mark-on-final-upstream", // the edge checks the final resolved upstream's row (R1-2) |
| + |
| + // Task 3: classification, minted groups, the operator clear (spec §5.1 |
| + // "The column", "Platform-minted rows", "Becoming public is never |
| + // automatic", "Classification before serving"; §6.3) |
| + "classify-null-refused", // the edge refuses NULL like 1 (R2-2) |
| + "classify-staged", // the upgrade classifies 1 if any staged version declares (R8-1) |
| + "minted-follow-owner", // a minted instance row is in its owner's group: one mark, one barrier (R5-1) |
| + "grant-marks-group", // a grant on any row of the group marks the group (R6-1) |
| + "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-holds-lock", // --public holds the adoption lock: the live link cannot move under it |
| } |
diff --git a/internal/sim/engines/witness_test.go b/internal/sim/engines/witness_test.go| index 31cae6dba..103b1e94d 100644 |
| --- a/internal/sim/engines/witness_test.go |
| +++ b/internal/sim/engines/witness_test.go |
| @@ -61,6 +61,26 @@ var witnesses = []witness{ |
| "R1-2: a public request through an alias whose final upstream is the engine; checked on the alias row, it reaches the engine", wMarkOnFinalUpstreamAlias}, |
| {"mark-on-final-upstream", "mark-on-final-upstream", "INV-P", |
| "R1-2: a public request through a static row's path map whose final upstream is the engine; checked on the static row, it reaches the engine", wMarkOnFinalUpstreamPath}, |
| + |
| + // Task 3: classification, minted groups, the operator clear |
| + {"classify-null-refused", "classify-null-refused", "INV-P", |
| + "R2-2: the engine runs a declaring build whose live manifest cannot be read; the upgrade leaves it NULL; an edge that serves NULL like 0 admits a public request into the engine", wClassifyNullRefused}, |
| + {"classify-staged", "classify-staged", "INV-I1+INV-P", |
| + "R8-1: an awake engine is repointed to a plain build without a restart, so the declaring build still runs; an upgrade that reads only the live manifest classifies the group 0, under the running engine (INV-I1) and a public request reaches it (INV-P)", wClassifyStaged}, |
| + {"minted-follow-owner", "minted-follow-owner", "INV-P", |
| + "R5-1: an app minted a platform row while public; adoption of a declaring build marks and fences the app; a minted row that is its own group stays 0 and a public request by the minted host reaches the engine", wMintedFollowOwner}, |
| + {"grant-marks-group", "grant-marks-group", "INV-P", |
| + "R6-1: --grant doc-engine on the minted row of a plain release; a grant that marks only when it is on the owner's own row leaves the group 0, and a public request by the minted host reaches the granted app", wGrantMarksGroup}, |
| + {"static-minted-public", "static-minted-public", "INV-AV", |
| + "R6-2: a static app with a minted row and a canary; an upgrade that classifies minted static rows like instance rows leaves them NULL, and they never answer (liveness)", wStaticMintedPublic}, |
| + {"owner-nonempty-unique", "owner-nonempty-unique", "INV-AV", |
| + "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-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}, |
| } |
| // TestWitnesses is the deterministic half of the mutation evidence. |
| @@ -306,14 +326,13 @@ func newWitness() *world { |
| return w |
| } |
| -// stubPublic stands in for the operator's --public until Task 3's |
| -// operatorPublic: stop every process of the group, write 0, reload. |
| -func (w *world) stubPublic(b *box, g string) { |
| - w.logf("witness: --public %s (stub: stop, write 0, reload)", g) |
| - w.stopGroup(b, g) |
| - w.writeMark(b, g, markPublic) |
| +// clearPublic is the operator's --public on group g (operatorPublic), then |
| +// the edge's next poll, as one witness step. False when the clear refused. |
| +func (w *world) clearPublic(b *box, g string) bool { |
| + ok := w.operatorPublic(b, g) |
| w.reload(b) |
| w.check() |
| + return ok |
| } |
| // publicEngine sets the engine group on box 0 to a public route serving the |
| @@ -322,7 +341,9 @@ func (w *world) stubPublic(b *box, g string) { |
| func (w *world) publicEngine(t *testing.T) *box { |
| b := w.boxes[0] |
| b.Live[eng], b.Good[eng] = vPlain, vPlain |
| - w.stubPublic(b, eng) |
| + if !w.clearPublic(b, eng) { |
| + t.Fatalf("witness: --public refused on a plain live release: %s", w.boxesState()) |
| + } |
| if !w.launch(b, eng, vPlain, "seed") { |
| t.Fatalf("witness: the plain build did not start under 0: %s", w.boxesState()) |
| } |
| @@ -447,6 +468,12 @@ func wGateSeed(t *testing.T, w *world) { gatePath("seed")(t, w) } |
| func wGateCmd(t *testing.T, w *world) { gatePath("cmd")(t, w) } |
| func wGateRecovery(t *testing.T, w *world) { gatePath("recovery")(t, w) } |
| +// oraclePublic makes box 0's engine group public on a plain live release. |
| +func oraclePublic(w *world, b *box) { |
| + b.Live[eng] = vPlain |
| + w.clearPublic(b, eng) |
| +} |
| + |
| // wGateFallback (R9-1): the group ran a declaring build, was adopted to a |
| // plain one (the mark stays 1) and cleared with --public; a public request |
| // wakes it, the first wake fails and the plan falls back to the declaring |
| @@ -461,7 +488,9 @@ func wGateFallback(t *testing.T, w *world) { |
| w.adopt(b, eng, vPlain, nil) |
| w.check() |
| b.Good[eng] = vEngine // the plain build has not proved itself yet |
| - w.stubPublic(b, eng) |
| + if !w.clearPublic(b, eng) { |
| + t.Fatalf("witness: --public refused on a plain live release: %s", w.boxesState()) |
| + } |
| r := w.admitPublic(b, engHost, "/") |
| if r == nil { |
| t.Fatalf("witness: a public request after --public was refused: %s", w.boxesState()) |
| @@ -509,10 +538,218 @@ func wMarkOnFinalUpstreamPath(t *testing.T, w *world) { |
| markOnFinalUpstream(t, w, engSite, engSitePath) |
| } |
| +// ---------- Task 3: classification, minted groups, the operator clear ---------- |
| + |
| +// requestBy sends one public request by host and delivers it if admitted. |
| +func (w *world) requestBy(b *box, host string) { |
| + if r := w.admitPublic(b, host, "/"); r != nil { |
| + w.deliver(r) |
| + } |
| +} |
| + |
| +// wClassifyNullRefused (R2-2): the engine's live release declares but its |
| +// manifest cannot be read; the upgrade can classify it neither way, so it |
| +// stays NULL; a public request arrives. |
| +func wClassifyNullRefused(t *testing.T, w *world) { |
| + b := w.boxes[0] |
| + if !w.adopt(b, eng, vUnreadable, nil) { |
| + t.Fatalf("witness: adoption of the engine refused: %s", w.boxesState()) |
| + } |
| + w.check() |
| + w.upgrade(b) |
| + w.check() |
| + if m := w.groupMark(b, eng); m != markNull { |
| + t.Fatalf("witness: the upgrade classified an unreadable live release %v, want NULL", m) |
| + } |
| + w.requestBy(b, engHost) |
| + w.check() |
| +} |
| + |
| +// wClassifyStaged (R8-1): the engine runs a declaring build; adoption of a |
| +// plain build repoints the awake route without a restart, so the declaring |
| +// build keeps running; then the agent upgrades and a public request arrives. |
| +func wClassifyStaged(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 awake repoint was refused: %s", w.boxesState()) |
| + } |
| + w.check() |
| + w.upgrade(b) |
| + w.check() |
| + w.requestBy(b, engHost) |
| + w.check() |
| +} |
| + |
| +const ( |
| + tcpApp = "acct/tcp" |
| + tcpA = "tcp-a" |
| + tcpB = "tcp-b" |
| + tcpC = "tcp-c" |
| + tcpD = "tcp-d" |
| + mintedB = "tcp-b.acct.apps.example" |
| + mintedC = "tcp-c.acct.apps.example" |
| +) |
| + |
| +// tcpPublic provisions a TCP-backed instance tcp-b of a plain build and mints |
| +// its platform row with the owner recorded; both answer publicly. |
| +func (w *world) tcpPublic(t *testing.T, b *box) { |
| + if !w.provision(b, tcpB, tcpApp, "10.0.0.7:9102", vPlain) { |
| + t.Fatalf("witness: provisioning %s refused: %s", tcpB, w.boxesState()) |
| + } |
| + w.mint(b, tcpB, mintedB, true) |
| + w.reload(b) |
| + w.check() |
| + if r := w.admitPublic(b, mintedB, "/"); r == nil { |
| + t.Fatalf("witness: the minted row of a public app refused a public request: %s", w.boxesState()) |
| + } else { |
| + w.deliver(r) |
| + } |
| + w.check() |
| + if len(w.violations) > 0 { |
| + t.Fatalf("witness: the fixture violates: %v", w.violations) |
| + } |
| +} |
| + |
| +// wMintedFollowOwner (R5-1): a public app with a minted row adopts a |
| +// declaring build; a public request arrives by the minted host. |
| +func wMintedFollowOwner(t *testing.T, w *world) { |
| + b := w.boxes[0] |
| + w.tcpPublic(t, b) |
| + if !w.adopt(b, tcpB, vEngine, nil) { |
| + t.Fatalf("witness: adoption of the declaring build refused: %s", w.boxesState()) |
| + } |
| + w.check() |
| + w.requestBy(b, mintedB) |
| + w.check() |
| +} |
| + |
| +// wGrantMarksGroup (R6-1): a public app of a plain build with a minted row; |
| +// the operator grants doc-engine on the minted row; a public request arrives |
| +// by the minted host. |
| +func wGrantMarksGroup(t *testing.T, w *world) { |
| + b := w.boxes[0] |
| + w.tcpPublic(t, b) |
| + w.grant(b, mintedB) |
| + w.check() |
| + w.requestBy(b, mintedB) |
| + w.check() |
| +} |
| + |
| +// wStaticMintedPublic (R6-2): a static app with its minted row and a canary |
| +// on its own channel; the agent upgrades; at the end every public row must |
| +// answer. |
| +func wStaticMintedPublic(t *testing.T, w *world) { |
| + b := w.boxes[0] |
| + w.addStaticApp(b, "site") |
| + w.reload(b) |
| + w.check() |
| + w.upgrade(b) |
| + w.check() |
| + w.final() |
| +} |
| + |
| +// wOwnerNonEmptyUnique (R7-2): app acct/tcp has TCP instances tcp-b (plain) |
| +// and, provisioned later, tcp-a (declaring); tcp-b's platform row was minted |
| +// before owners were recorded, so the edge finds its owner by backend. tcp-c |
| +// and tcp-d share one address, so tcp-c's minted row has an ambiguous owner. |
| +// At the end every public row with a findable owner must answer. |
| +func wOwnerNonEmptyUnique(t *testing.T, w *world) { |
| + b := w.boxes[0] |
| + for _, x := range []struct { |
| + host, addr string |
| + }{{tcpB, "10.0.0.7:9102"}, {tcpC, "10.0.0.7:9103"}, {tcpD, "10.0.0.7:9103"}} { |
| + if !w.provision(b, x.host, tcpApp, x.addr, vPlain) { |
| + t.Fatalf("witness: provisioning %s refused: %s", x.host, w.boxesState()) |
| + } |
| + } |
| + w.mint(b, tcpB, mintedB, false) |
| + w.mint(b, tcpC, mintedC, false) |
| + w.check() |
| + if !w.provision(b, tcpA, tcpApp, "10.0.0.7:9101", vEngine) { |
| + t.Fatalf("witness: provisioning %s refused: %s", tcpA, w.boxesState()) |
| + } |
| + w.reload(b) |
| + w.check() |
| + if on("owner-nonempty-unique") { |
| + if r := w.admitPublic(b, mintedC, "/"); r != nil { |
| + t.Fatalf("witness: a minted row with an ambiguous owner was admitted: %s", w.boxesState()) |
| + } |
| + } |
| + w.final() |
| +} |
| + |
| +// wPublicChecksLive: the engine's live release declares; the operator runs |
| +// --public; a public request arrives and socket activation starts the unit. |
| +func wPublicChecksLive(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() |
| + w.clearPublic(b, eng) |
| + r := w.admitPublic(b, engHost, "/") |
| + w.socketActivate(b, eng) |
| + if r != nil { |
| + w.deliver(r) |
| + } |
| + w.check() |
| +} |
| + |
| +// wPublicStopsGroup (R7-1, R8-2): the engine runs a declaring generation; a |
| +// generation rollout of a plain build repoints the live link and only |
| +// schedules the swap; the operator runs --public; a public request arrives. |
| +func wPublicStopsGroup(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.clearPublic(b, eng) |
| + w.requestBy(b, engHost) |
| + w.check() |
| +} |
| + |
| +// 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 |
| +// arrives and socket activation starts the unit. |
| +func wPublicHoldsLock(t *testing.T, w *world) { |
| + b := w.boxes[0] |
| + if !w.adopt(b, eng, vEngine, nil) || !w.adopt(b, eng, vPlain, nil) { |
| + t.Fatalf("witness: adoption refused: %s", w.boxesState()) |
| + } |
| + w.check() |
| + w.clearHook = func(hb *box, g, stage string) { |
| + if stage == "locked" { |
| + w.adopt(hb, g, vEngine, nil) |
| + } |
| + } |
| + w.clearPublic(b, eng) |
| + w.clearHook = nil |
| + r := w.admitPublic(b, engHost, "/") |
| + w.socketActivate(b, eng) |
| + if r != nil { |
| + w.deliver(r) |
| + } |
| + w.check() |
| +} |
| + |
| // TestI1Oracle pins INV-I1 on hand-built states of box 0's engine group with |
| // a declaring process running: the fence condition alone catches a group that |
| // is NULL, unloaded by the edge, or written and swapped but unacknowledged; |
| // the served-map condition alone catches a counter advanced without a swap. |
| +// A NULL group counts as closed once the edge has fenced the change that made |
| +// it NULL: the edge refuses NULL like 1 (spec §5.1 "The column"), and the |
| +// upgrade leaves a group NULL under a running declaring build whose live |
| +// manifest cannot be read (Task 3). |
| func TestI1Oracle(t *testing.T) { |
| cases := []struct { |
| name string |
| @@ -520,26 +757,36 @@ func TestI1Oracle(t *testing.T) { |
| want string // "" holds; else a substring of the reason |
| }{ |
| {"fenced as internal", func(w *world, b *box) {}, ""}, |
| - {"NULL in the registry and the map", func(w *world, b *box) { |
| + {"NULL written over 0, not yet fenced", func(w *world, b *box) { |
| + oraclePublic(w, b) |
| + w.writeMark(b, eng, markNull) |
| + }, "not fenced"}, |
| + {"NULL written over 0 and fenced (the edge treats NULL like 1)", func(w *world, b *box) { |
| + oraclePublic(w, b) |
| + w.writeMark(b, eng, markNull) |
| + w.reload(b) |
| + }, ""}, |
| + {"NULL on one row, 0 served on the other", func(w *world, b *box) { |
| + oraclePublic(w, b) |
| b.Mark[engHost] = markNull |
| b.Serving[engHost] = markNull |
| }, "not fenced"}, |
| {"written 1, absent from the edge's map", func(w *world, b *box) { |
| - w.stubPublic(b, eng) |
| + oraclePublic(w, b) |
| w.writeMark(b, eng, markInternal) |
| delete(b.EdgeRows, engHost) |
| delete(b.Serving, engHost) |
| }, "not fenced"}, |
| {"written 1 and swapped in, fence not acknowledged", func(w *world, b *box) { |
| - w.stubPublic(b, eng) |
| + oraclePublic(w, b) |
| w.writeMark(b, eng, markInternal) |
| b.Serving[engHost] = markInternal |
| }, "not fenced"}, |
| {"counter advanced without a swap", func(w *world, b *box) { |
| - w.stubPublic(b, eng) |
| + oraclePublic(w, b) |
| w.writeMark(b, eng, markInternal) |
| b.Fenced = b.Wrote |
| - }, "serves " + engHost + " public"}, |
| + }, "serves " + eng + " public"}, |
| } |
| for _, c := range cases { |
| w := newWitness() |
diff --git a/internal/sim/engines/world_test.go b/internal/sim/engines/world_test.go| index ee29dbd9f..91a423520 100644 |
| --- a/internal/sim/engines/world_test.go |
| +++ b/internal/sim/engines/world_test.go |
| @@ -51,11 +51,20 @@ type proc struct { |
| // alias; Backend is a socket path or an address, "" for static and alias. |
| // An alias row names its target host in Alias and copies its upstream |
| // (edge.go:929); PathTo maps a path to the host that serves it (edge.go:1364). |
| +// |
| +// A platform-minted row (Platform) names in Source the host it was minted |
| +// from: the truth the oracle reads. Owner is what the registry has recorded |
| +// (routes.owner), "" for a row minted before owners were recorded; the edge |
| +// then finds the owner by App and backend (spec §5.1, "Platform-minted |
| +// rows"). App is the row's account/app. GrantAt is the registry change that |
| +// recorded the doc-engine grant. |
| type row struct { |
| Host, Kind, Owner, Backend string |
| Platform, Grant bool |
| PathTo map[string]string |
| Alias string |
| + App, Source string |
| + GrantAt int |
| } |
| // mark is a row's exposure mark. |
| @@ -105,6 +114,7 @@ type box struct { |
| Mark map[string]mark |
| Wrote, Fenced, Epoch int |
| ReloadFails, Armed bool |
| + Locked bool // the adoption lock, held by --public |
| Live, Good map[string]int |
| Staged map[string][]int |
| Procs []*proc |
| @@ -127,6 +137,11 @@ type world struct { |
| stats map[string]int |
| nextID int // process and request ids |
| + // clearHook, when set, runs inside operatorPublic at each stage |
| + // ("locked": step 1 passed; "stopped": step 2 done), so a scenario or the |
| + // walk can interleave there. |
| + clearHook func(b *box, g, stage string) |
| + |
| verbose bool |
| } |
| @@ -155,11 +170,12 @@ func newWorld(seed int64, verbose bool) *world { |
| home, calling := newBox(0), newBox(1) |
| w.boxes = []*box{home, calling} |
| - // one engine group, eng, with its minted platform row |
| - home.Rows[engHost] = &row{Host: engHost, Kind: "instance", Owner: eng, Backend: "/run/carlos/eng.sock", Platform: true, PathTo: map[string]string{}} |
| - home.Mark[engHost] = markInternal |
| + // one engine group, eng: its own route and its minted platform row |
| + home.Rows[eng] = &row{Host: eng, Kind: "instance", App: "acct/eng", Backend: "/run/carlos/eng.sock", PathTo: map[string]string{}} |
| + home.Rows[engHost] = &row{Host: engHost, Kind: "instance", App: "acct/eng", Owner: eng, Source: eng, Backend: "/run/carlos/eng.sock", Platform: true, PathTo: map[string]string{}} |
| + home.Mark[eng], home.Mark[engHost] = markInternal, markInternal |
| // one public app group, shop, on the calling box |
| - calling.Rows["shop"] = &row{Host: "shop", Kind: "instance", Backend: "127.0.0.1:9001", PathTo: map[string]string{}} |
| + calling.Rows["shop"] = &row{Host: "shop", Kind: "instance", App: "acct/shop", Backend: "127.0.0.1:9001", PathTo: map[string]string{}} |
| calling.Mark["shop"] = markPublic |
| // on each box an alias row and a static row with a path map, both |
| // resolving to the box's group: rows of kinds that serve no release, 0 |
| @@ -203,11 +219,14 @@ func (w *world) logf(format string, args ...any) { |
| // it runs the action, so steps 1 to chaosSteps are chaos and the rest calm. |
| func (w *world) calm() bool { return w.step > chaosSteps } |
| -// group is a row's group key: its owner for a minted instance row, else its |
| -// own host. |
| +// group is a row's group key: its owner for a minted instance row whose |
| +// owner the registry can find, else its own host (spec §5.1, |
| +// "Platform-minted rows"; resolveOwner). |
| func (w *world) group(b *box, host string) string { |
| - if r := b.Rows[host]; r != nil && r.Kind == "instance" && r.Owner != "" { |
| - return r.Owner |
| + if r := b.Rows[host]; r != nil && followsOwner(r) { |
| + if o, ok := b.resolveOwner(r); ok { |
| + return o |
| + } |
| } |
| return host |
| } |
| @@ -256,7 +275,18 @@ func (w *world) doStep() { |
| // final is the end-of-run oracle: what must hold once the calm steps are |
| // over. Later tasks add their end-state checks here. |
| +// |
| +// The faults are over: every edge's reload works again, and each edge polls |
| +// once, so every row has had its chance to answer. Then INV-AV. |
| func (w *world) final() { |
| + for _, b := range w.boxes { |
| + if b.ReloadFails { |
| + b.ReloadFails = false |
| + w.logf("box%d: final: the reload fault has cleared", b.ID) |
| + } |
| + w.reload(b) |
| + } |
| + w.checkAV() |
| w.logf("final: %d steps, %d violations, %d residuals", w.step, len(w.violations), len(w.residuals)) |
| } |