hostbox: reconcile's relocation row cases, and only the source or a probing target wakes under relocating
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
5 files changed,
+417
−6
internal/hostbox/relocationrows.go+79 −0internal/hostbox/relocationrows_test.go+189 −0internal/hostbox/wakepolicy.go+80 −6internal/hostguard/admissionfence.go+31 −0internal/hostguard/admissionfence_test.go+38 −0
diff --git a/internal/hostbox/relocationrows.go b/internal/hostbox/relocationrows.go| new file mode 100644 |
| index 000000000..9858a12b5 |
| --- /dev/null |
| +++ b/internal/hostbox/relocationrows.go |
| @@ -0,0 +1,79 @@ |
| +package hostbox |
| + |
| +import ( |
| + "slices" |
| + |
| + "github.com/carlosframework/platform/internal/hostclaim" |
| +) |
| + |
| +// RowCase is what reconcile does with one relocatable host's registry row |
| +// on this box (region HA spec 1 §5.3, claim C4 as amended by L12). |
| +type RowCase int |
| + |
| +const ( |
| + // RowNone: mint nothing, and there is no row to keep. |
| + RowNone RowCase = iota |
| + // RowReady: mint the row, or keep it, ready. |
| + RowReady |
| + // RowHeld: mint the row, or keep it, held with HoldOp. |
| + RowHeld |
| + // RowRetiring: keep an existing row held; mint none. A park-local chore |
| + // this box owes retires it under the host guard (claim P6-D2). |
| + RowRetiring |
| + // RowUnexplained: the claim gives this box no role, yet a row exists. |
| + // Keep it held, never serve it, and alert. Never delete it by hostname. |
| + RowUnexplained |
| +) |
| + |
| +// RowDecision is RelocationRow's answer. |
| +type RowDecision struct { |
| + Case RowCase |
| + HoldOp string |
| +} |
| + |
| +// RelocationRow decides one row from a fresh claim read. box is this box's |
| +// reference; exists says whether the registry holds a row for the host; |
| +// fenceClosed says whether this box's local admission fence for the host is |
| +// closed (the source's release has begun, §5.8's release sequence step 1). |
| +// |
| +// op.exec, serving (Serving's own rule) ready |
| +// relocating source in starting, or releasing |
| +// until its admission fence closes ready |
| +// any other participant of a running operation held, hold_op = op id |
| +// owner of an open park-local chore for the host retiring (held, no mint) |
| +// none of these none; an existing row is unexplained |
| +// |
| +// A box on the claim's lost list is never ready. A nil or holderless claim |
| +// (an error is the caller's: claim C6, keep rows and mint none) gives none. |
| +func RelocationRow(c *hostclaim.Claim, box string, exists, fenceClosed bool) RowDecision { |
| + if c == nil || c.Holder == nil || box == "" { |
| + return unexplained(exists) |
| + } |
| + lost := slices.Contains(c.Lost, box) |
| + switch { |
| + case lost: |
| + case Serving(c, Selection{Box: box}) == nil: |
| + return RowDecision{Case: RowReady} |
| + case c.Op.Kind == hostclaim.KindRelocating && c.Op.Exec == box && c.Op.Intent["source_box"] == box && |
| + (c.Op.Phase == hostclaim.PhaseStarting || (c.Op.Phase == hostclaim.PhaseReleasing && !fenceClosed)): |
| + return RowDecision{Case: RowReady} |
| + case c.Op.Kind != hostclaim.KindServing && c.Op.ID != "" && slices.Contains(c.Op.Participants, box): |
| + return RowDecision{Case: RowHeld, HoldOp: c.Op.ID} |
| + } |
| + for _, o := range c.Obligations { |
| + if o.Kind == "park-local" && o.Box == box { |
| + if !exists { |
| + return RowDecision{Case: RowNone} |
| + } |
| + return RowDecision{Case: RowRetiring, HoldOp: o.Op} |
| + } |
| + } |
| + return unexplained(exists) |
| +} |
| + |
| +func unexplained(exists bool) RowDecision { |
| + if exists { |
| + return RowDecision{Case: RowUnexplained} |
| + } |
| + return RowDecision{Case: RowNone} |
| +} |
diff --git a/internal/hostbox/relocationrows_test.go b/internal/hostbox/relocationrows_test.go| new file mode 100644 |
| index 000000000..f957f59b8 |
| --- /dev/null |
| +++ b/internal/hostbox/relocationrows_test.go |
| @@ -0,0 +1,189 @@ |
| +package hostbox |
| + |
| +import ( |
| + "context" |
| + "errors" |
| + "testing" |
| + |
| + "github.com/carlosframework/platform/internal/hostclaim" |
| + "github.com/carlosframework/platform/internal/hostwrite" |
| +) |
| + |
| +func relocating(phase, exec string) *hostclaim.Claim { |
| + return &hostclaim.Claim{Holder: &hostclaim.Holder{}, Op: hostclaim.Op{Kind: hostclaim.KindRelocating, ID: "rel-1", Phase: phase, Exec: exec, |
| + Participants: []string{"src", "dst"}, Intent: map[string]string{"source_box": "src", "target_box": "dst", "region": "ie"}}} |
| +} |
| + |
| +func TestRelocationRowCases(t *testing.T) { |
| + withChore := func(c *hostclaim.Claim, box string) *hostclaim.Claim { |
| + c.Obligations = append(c.Obligations, hostclaim.Obligation{ID: "o", Op: "rel-0", Kind: "park-local", Box: box, Owner: "box:" + box, Retirement: "r"}) |
| + return c |
| + } |
| + serving := &hostclaim.Claim{Holder: &hostclaim.Holder{}, Op: hostclaim.Op{Kind: hostclaim.KindServing, Exec: "src", Participants: []string{"src"}}} |
| + lostSource := relocating(hostclaim.PhaseStarting, "src") |
| + lostSource.Lost = []string{"src"} |
| + for _, tc := range []struct { |
| + name string |
| + c *hostclaim.Claim |
| + box string |
| + exists, fenceClosed bool |
| + want RowDecision |
| + }{ |
| + {"the serving executor", serving, "src", false, false, RowDecision{Case: RowReady}}, |
| + {"a serving host elsewhere, no row", serving, "dst", false, false, RowDecision{Case: RowNone}}, |
| + {"a serving host elsewhere, a row", serving, "dst", true, false, RowDecision{Case: RowUnexplained}}, |
| + {"the source in starting", relocating(hostclaim.PhaseStarting, "src"), "src", true, false, RowDecision{Case: RowReady}}, |
| + {"the source releasing, fence open", relocating(hostclaim.PhaseReleasing, "src"), "src", true, false, RowDecision{Case: RowReady}}, |
| + {"the source releasing, fence closed", relocating(hostclaim.PhaseReleasing, "src"), "src", true, true, RowDecision{Case: RowHeld, HoldOp: "rel-1"}}, |
| + {"the source in pinning", relocating(hostclaim.PhasePinning, "none"), "src", true, false, RowDecision{Case: RowHeld, HoldOp: "rel-1"}}, |
| + {"the target in starting", relocating(hostclaim.PhaseStarting, "src"), "dst", false, false, RowDecision{Case: RowHeld, HoldOp: "rel-1"}}, |
| + {"the target executing in probing", relocating(hostclaim.PhaseProbing, "dst"), "dst", true, false, RowDecision{Case: RowHeld, HoldOp: "rel-1"}}, |
| + {"a lost source", lostSource, "src", true, false, RowDecision{Case: RowUnexplained}}, |
| + {"a former executor owing park-local", withChore(serving, "old"), "old", true, false, RowDecision{Case: RowRetiring, HoldOp: "rel-0"}}, |
| + {"a former executor with no row", withChore(serving, "old"), "old", false, false, RowDecision{Case: RowNone}}, |
| + {"no claim, a row", nil, "src", true, false, RowDecision{Case: RowUnexplained}}, |
| + } { |
| + t.Run(tc.name, func(t *testing.T) { |
| + if got := RelocationRow(tc.c, tc.box, tc.exists, tc.fenceClosed); got != tc.want { |
| + t.Fatalf("got %+v, want %+v", got, tc.want) |
| + } |
| + }) |
| + } |
| +} |
| + |
| +func TestWakePhaseRelocating(t *testing.T) { |
| + for _, tc := range []struct { |
| + phase, exec, box string |
| + probe, want bool |
| + }{ |
| + {hostclaim.PhaseStarting, "src", "src", false, true}, |
| + {hostclaim.PhaseReleasing, "src", "src", false, true}, |
| + {hostclaim.PhaseStarting, "src", "src", true, false}, |
| + {hostclaim.PhaseInstalling, "dst", "dst", false, false}, |
| + {hostclaim.PhaseInstalled, "dst", "dst", true, false}, |
| + {hostclaim.PhaseProbing, "dst", "dst", true, true}, |
| + {hostclaim.PhaseProbing, "dst", "dst", false, false}, |
| + {hostclaim.PhaseProbeFailed, "dst", "dst", true, false}, |
| + {hostclaim.PhasePinning, "none", "src", false, false}, |
| + } { |
| + if got := WakePhase(relocating(tc.phase, tc.exec), tc.box, tc.probe) == nil; got != tc.want { |
| + t.Fatalf("%+v: %v", tc, got) |
| + } |
| + } |
| + stuck := relocating(hostclaim.PhaseProbing, "dst") |
| + stuck.Op.Journal = map[string]string{"stuck": "operator"} |
| + if WakePhase(stuck, "dst", true) == nil { |
| + t.Fatal("a stuck operation probed") |
| + } |
| +} |
| + |
| +func TestPolicyRoutesRelocationToItsOwner(t *testing.T) { |
| + c := relocating(hostclaim.PhaseStarting, "src") |
| + held := func(context.Context, string) (string, error) { return "", nil } |
| + s := Selection{Host: "x.example", Box: "src", Op: "rel-1", Action: "spawn"} |
| + moves := func(context.Context, *hostclaim.Claim, Selection, hostwrite.Identity) error { return nil } |
| + if err := (Policy{Held: held, Moves: moves}).Check(context.Background(), c, s, hostwrite.Identity{}); !errors.Is(err, ErrChanged) { |
| + t.Fatalf("moves' policy authorized a relocation: %v", err) |
| + } |
| + called := false |
| + reloc := func(context.Context, *hostclaim.Claim, Selection, hostwrite.Identity) error { |
| + called = true |
| + return nil |
| + } |
| + if err := (Policy{Held: held, Relocation: reloc}).Check(context.Background(), c, s, hostwrite.Identity{}); err != nil || !called { |
| + t.Fatalf("relocation policy: %v called=%v", err, called) |
| + } |
| +} |
| + |
| +func TestPolicyLetsTheServingExecutorClearAnObsoleteHold(t *testing.T) { |
| + serving := &hostclaim.Claim{Holder: &hostclaim.Holder{}, Op: hostclaim.Op{Kind: hostclaim.KindServing, Exec: "dst", Participants: []string{"dst"}}} |
| + obsolete := func(context.Context, string) (string, error) { return "rel-1", nil } |
| + reconcile := Selection{Host: "x.example", Box: "dst", Action: "reconcile"} |
| + // Region HA off (no Relocation policy): today's refusal, unchanged. |
| + if err := (Policy{Held: obsolete}).Check(context.Background(), serving, reconcile, hostwrite.Identity{}); !errors.Is(err, ErrChanged) { |
| + t.Fatalf("with HA off a held serving row was reconciled: %v", err) |
| + } |
| + policy := Policy{Held: obsolete, Relocation: RelocationPolicy("dst")} |
| + if err := policy.Check(context.Background(), serving, reconcile, hostwrite.Identity{}); err != nil { |
| + t.Fatalf("the serving executor could not reconcile an obsolete hold away: %v", err) |
| + } |
| + for name, s := range map[string]Selection{ |
| + "a spawn under the hold": {Host: "x.example", Box: "dst", Action: "spawn"}, |
| + "another box's reconcile": {Host: "x.example", Box: "src", Action: "reconcile"}, |
| + } { |
| + if err := policy.Check(context.Background(), serving, s, hostwrite.Identity{}); !errors.Is(err, ErrChanged) { |
| + t.Fatalf("%s: %v", name, err) |
| + } |
| + } |
| +} |
| + |
| +func TestRelocationPolicyGivesEachActionItsRole(t *testing.T) { |
| + policy := RelocationPolicy("dst") |
| + at := func(phase, exec string) *hostclaim.Claim { return relocating(phase, exec) } |
| + for name, tc := range map[string]struct { |
| + c *hostclaim.Claim |
| + action string |
| + want error |
| + }{ |
| + "a participant reconciles": {at(hostclaim.PhaseStarting, "src"), "reconcile", nil}, |
| + "a non-executor's held config": {at(hostclaim.PhaseStarting, "src"), "held-config", ErrChanged}, |
| + "the executor's held config": {at(hostclaim.PhaseInstalling, "dst"), "held-config", nil}, |
| + "the executor installs": {at(hostclaim.PhaseInstalled, "dst"), "install", nil}, |
| + "the target spawns in installing": {at(hostclaim.PhaseInstalling, "dst"), "spawn", ErrChanged}, |
| + "the target spawns in probing": {at(hostclaim.PhaseProbing, "dst"), "spawn", ErrChanged}, // only a probe |
| + "the target probes in probing": {at(hostclaim.PhaseProbing, "dst"), "probe", nil}, |
| + "the target probes in installed": {at(hostclaim.PhaseInstalled, "dst"), "probe", ErrChanged}, |
| + "the source probes": {at(hostclaim.PhaseProbing, "src"), "probe", ErrChanged}, |
| + "an unknown action": {at(hostclaim.PhaseProbing, "dst"), "purge", ErrChanged}, |
| + } { |
| + if err := policy(context.Background(), tc.c, Selection{Host: "x.example", Box: "dst", Op: "rel-1", Action: tc.action}, hostwrite.Identity{}); !errors.Is(err, tc.want) { |
| + t.Fatalf("%s: %v, want %v", name, err, tc.want) |
| + } |
| + } |
| + if err := policy(context.Background(), at(hostclaim.PhaseStarting, "src"), Selection{Host: "x.example", Box: "src", Op: "rel-1", Action: "reconcile"}, hostwrite.Identity{}); !errors.Is(err, ErrChanged) { |
| + t.Fatalf("another box's selection: %v", err) |
| + } |
| +} |
| + |
| +func TestPolicyLetsAHeldParticipantReconcileItsRow(t *testing.T) { |
| + held := func(context.Context, string) (string, error) { return "rel-1", nil } |
| + reloc := func(context.Context, *hostclaim.Claim, Selection, hostwrite.Identity) error { return nil } |
| + policy := Policy{Held: held, Relocation: reloc} |
| + row := func(box, op string) Selection { |
| + return Selection{Host: "x.example", Box: box, Op: op, Action: "reconcile"} |
| + } |
| + c := relocating(hostclaim.PhaseStarting, "src") |
| + for name, s := range map[string]Selection{"the target, not the executor": row("dst", "rel-1"), "the source": row("src", "rel-1")} { |
| + if err := policy.Check(context.Background(), c, s, hostwrite.Identity{}); err != nil { |
| + t.Fatalf("%s: %v", name, err) |
| + } |
| + } |
| + lost := relocating(hostclaim.PhaseStarting, "src") |
| + lost.Lost = []string{"src"} |
| + for name, tc := range map[string]struct { |
| + c *hostclaim.Claim |
| + s Selection |
| + }{ |
| + "a box with no role": {c, row("zzz", "rel-1")}, |
| + "another operation": {c, row("dst", "rel-0")}, |
| + "a lost participant": {lost, row("src", "rel-1")}, |
| + "a spawn by the target": {c, Selection{Host: "x.example", Box: "dst", Op: "rel-1", Action: "spawn"}}, |
| + } { |
| + if err := policy.Check(context.Background(), tc.c, tc.s, hostwrite.Identity{}); !errors.Is(err, ErrChanged) { |
| + t.Fatalf("%s: %v", name, err) |
| + } |
| + } |
| + // A target probe passes the whole policy; its ordinary spawn does not. |
| + probing := relocating(hostclaim.PhaseProbing, "dst") |
| + full := Policy{Held: held, Relocation: RelocationPolicy("dst")} |
| + if err := full.Check(context.Background(), probing, Selection{Host: "x.example", Box: "dst", Op: "rel-1", Action: "probe"}, hostwrite.Identity{}); err != nil { |
| + t.Fatalf("the target's probe: %v", err) |
| + } |
| + if err := full.Check(context.Background(), probing, Selection{Host: "x.example", Box: "dst", Op: "rel-1", Action: "spawn"}, hostwrite.Identity{}); !errors.Is(err, ErrChanged) { |
| + t.Fatalf("an ordinary spawn by the probing target: %v", err) |
| + } |
| + moving := &hostclaim.Claim{Holder: &hostclaim.Holder{}, Op: hostclaim.Op{Kind: hostclaim.KindMoving, ID: "mv-1", Phase: "sealed", Exec: "src", Participants: []string{"src", "dst"}}} |
| + if err := (Policy{Held: held, Moves: reloc, Relocation: reloc}).Check(context.Background(), moving, row("dst", "mv-1"), hostwrite.Identity{}); !errors.Is(err, ErrChanged) { |
| + t.Fatalf("a move's participant reconciled through the relocation action: %v", err) |
| + } |
| +} |
diff --git a/internal/hostbox/wakepolicy.go b/internal/hostbox/wakepolicy.go| index 9c3c00d65..5dec10dfb 100644 |
| --- a/internal/hostbox/wakepolicy.go |
| +++ b/internal/hostbox/wakepolicy.go |
| @@ -2,6 +2,7 @@ package hostbox |
| import ( |
| "context" |
| + "slices" |
| "time" |
| "github.com/carlosframework/platform/internal/hostclaim" |
| @@ -27,6 +28,17 @@ func WakePhase(c *hostclaim.Claim, box string, probe bool) error { |
| if c.Op.ID != "" && ((!probe && c.Op.Phase == "committed") || (probe && c.Op.Phase == "probing")) { |
| return nil |
| } |
| + case hostclaim.KindRelocating: |
| + // Region HA §5.8: the source keeps serving in starting and in |
| + // releasing (its local admission fence closes releasing); the |
| + // target runs only probes, in probing. Nobody else, ever. |
| + if c.Op.ID == "" || c.Op.Journal["stuck"] != "" { |
| + break |
| + } |
| + source := c.Op.Intent["source_box"] == box && (c.Op.Phase == hostclaim.PhaseStarting || c.Op.Phase == hostclaim.PhaseReleasing) |
| + if (!probe && source) || (probe && c.Op.Phase == hostclaim.PhaseProbing) { |
| + return nil |
| + } |
| case hostclaim.KindReverting, hostclaim.KindRestoring: |
| if c.Op.ID != "" && ((!probe && (c.Op.Phase == "probed" || c.Op.Phase == "opened")) || (probe && c.Op.Phase == "probing")) { |
| return nil |
| @@ -37,10 +49,13 @@ func WakePhase(c *hostclaim.Claim, box string, probe bool) error { |
| // Policy separates ordinary writes from the moves-owned held actions. |
| // Moves validates roles, held inputs and phase-specific rights with the same capture. |
| +// Relocation does the same for a relocating claim (region HA §5.8); with it |
| +// nil, every relocating action is refused. |
| type Policy struct { |
| - Inputs func(context.Context, *hostclaim.Claim, Selection, hostwrite.Identity) error |
| - Moves func(context.Context, *hostclaim.Claim, Selection, hostwrite.Identity) error |
| - Held func(context.Context, string) (string, error) |
| + Inputs func(context.Context, *hostclaim.Claim, Selection, hostwrite.Identity) error |
| + Moves func(context.Context, *hostclaim.Claim, Selection, hostwrite.Identity) error |
| + Relocation func(context.Context, *hostclaim.Claim, Selection, hostwrite.Identity) error |
| + Held func(context.Context, string) (string, error) |
| } |
| func (p Policy) Check(ctx context.Context, c *hostclaim.Claim, s Selection, id hostwrite.Identity) error { |
| @@ -61,14 +76,43 @@ func (p Policy) Check(ctx context.Context, c *hostclaim.Claim, s Selection, id h |
| } |
| } |
| if hold != "" { |
| - return ErrChanged |
| + // A hold of an operation the claim no longer runs is obsolete. |
| + // Only the serving executor's reconcile may pass it, and only |
| + // to clear it (round 3 finding N4), and only where region HA |
| + // is on (Relocation set): with it off, today's refusal. |
| + if p.Relocation == nil || s.Action != "reconcile" || hold == c.Op.ID || c.Op.ID != "" { |
| + return ErrChanged |
| + } |
| } |
| return Serving(c, s) |
| } |
| - if p.Moves == nil || s.Op == "" || c.Op.ID != s.Op || c.Op.Exec != s.Box { |
| + owner := p.Moves |
| + if c.Op.Kind == hostclaim.KindRelocating { |
| + owner = p.Relocation |
| + } |
| + if s.Action == "reconcile" { |
| + // Region HA §5.3: reconcile mints or keeps a held row for any |
| + // participant of a running relocation, executor or not, never |
| + // for a lost box. The row's hold is the operation's; the owner |
| + // policy permits the hold write and nothing else for a |
| + // non-executor. Other kinds keep refusing the action here. |
| + if c.Op.Kind != hostclaim.KindRelocating || owner == nil || s.Op == "" || c.Op.ID != s.Op || |
| + !slices.Contains(c.Op.Participants, s.Box) || slices.Contains(c.Lost, s.Box) { |
| + return ErrChanged |
| + } |
| + return owner(ctx, c, s, id) |
| + } |
| + if owner == nil || s.Op == "" || c.Op.ID != s.Op || c.Op.Exec != s.Box { |
| return ErrChanged |
| } |
| switch s.Action { |
| + case "probe": |
| + // A relocation target's probe spawn (§5.8, R1): selected as its own |
| + // action, so the policy can give it probe authority where an |
| + // ordinary spawn has none. |
| + if c.Op.Kind != hostclaim.KindRelocating || (hold != "" && hold != s.Op) { |
| + return ErrChanged |
| + } |
| case "held-config", "held-release", "held-timers": |
| if hold != s.Op { |
| return ErrChanged |
| @@ -84,7 +128,37 @@ func (p Policy) Check(ctx context.Context, c *hostclaim.Claim, s Selection, id h |
| default: |
| return ErrChanged |
| } |
| - return p.Moves(ctx, c, s, id) |
| + return owner(ctx, c, s, id) |
| +} |
| + |
| +// RelocationPolicy is the Policy.Relocation owner for one box: the role |
| +// and phase rights of each action under a relocating claim, after |
| +// Policy.Check has matched the operation and the participant. A |
| +// reconcile is the hold write any participant makes; held inputs are the |
| +// executor's; execution actions are what WakePhase allows the executor, |
| +// and "probe" is the target's probe spawn in probing (WakePhase with |
| +// probe set), which an ordinary spawn never gets. Production sets it with |
| +// CARLOS_REGION_HA on (Task 10); nil refuses. |
| +func RelocationPolicy(box string) func(context.Context, *hostclaim.Claim, Selection, hostwrite.Identity) error { |
| + return func(_ context.Context, c *hostclaim.Claim, s Selection, _ hostwrite.Identity) error { |
| + if box == "" || s.Box != box || c.Op.Kind != hostclaim.KindRelocating { |
| + return ErrChanged |
| + } |
| + switch s.Action { |
| + case "reconcile": |
| + return nil |
| + case "held-config", "held-release", "held-timers", "install", "restore": |
| + if c.Op.Exec != box { |
| + return ErrChanged |
| + } |
| + return nil |
| + case "spawn", "seed", "writer": |
| + return WakePhase(c, box, false) |
| + case "probe": |
| + return WakePhase(c, box, true) |
| + } |
| + return ErrChanged |
| + } |
| } |
| const RequestLimit = 120 * time.Second |
diff --git a/internal/hostguard/admissionfence.go b/internal/hostguard/admissionfence.go| new file mode 100644 |
| index 000000000..70a800fdc |
| --- /dev/null |
| +++ b/internal/hostguard/admissionfence.go |
| @@ -0,0 +1,31 @@ |
| +package hostguard |
| + |
| +import "github.com/carlosframework/platform/internal/hostclaim" |
| + |
| +// AdmissionFenced reports whether the host's durable admission fence is |
| +// closed for the installation and session captured names on claim c: a |
| +// stop root began (ActiveFence) or completed (a FenceJournal receipt) for |
| +// that session. It is the same reading With refuses on, exposed for |
| +// region HA §5.3's row decision ("the source in releasing until its |
| +// admission fence closes"). It takes no lock: the record is replaced |
| +// atomically, and callers already hold the host lock (Activate's reader, |
| +// WithLocal) or need no lock for one consistent read. The claim is the |
| +// caller's fresh read. It grants nothing. |
| +func (g Guard) AdmissionFenced(host string, c *hostclaim.Claim, captured Captured) (bool, error) { |
| + if c == nil || c.Holder == nil { |
| + return false, ErrNoHolder |
| + } |
| + r, err := readRecord(g.DataDir, host) |
| + if err != nil { |
| + return false, err |
| + } |
| + if r.ActiveFence != nil && r.ActiveFence.matchesSession(c, captured) { |
| + return true, nil |
| + } |
| + for _, receipt := range r.FenceJournal { |
| + if receipt.Target.matchesSession(c, captured) { |
| + return true, nil |
| + } |
| + } |
| + return false, nil |
| +} |
diff --git a/internal/hostguard/admissionfence_test.go b/internal/hostguard/admissionfence_test.go| new file mode 100644 |
| index 000000000..8260d6632 |
| --- /dev/null |
| +++ b/internal/hostguard/admissionfence_test.go |
| @@ -0,0 +1,38 @@ |
| +//go:build unix |
| + |
| +package hostguard |
| + |
| +import ( |
| + "errors" |
| + "testing" |
| +) |
| + |
| +func TestAdmissionFencedFollowsTheDurableFence(t *testing.T) { |
| + f, target, captured := fenceFixture(t) |
| + if fenced, err := f.g.AdmissionFenced(target.Host, f.obs.Claim, captured); err != nil || fenced { |
| + t.Fatalf("before any fence: %v %v", fenced, err) |
| + } |
| + if _, err := f.g.BeginFence(t.Context(), target); err != nil { |
| + t.Fatal(err) |
| + } |
| + // Read inside the host lock, as Activate's reader and WithLocal do: |
| + // no second acquisition, no deadlock. |
| + err := f.g.WithLocal(t.Context(), target.Host, func() error { |
| + fenced, err := f.g.AdmissionFenced(target.Host, f.obs.Claim, captured) |
| + if err != nil || !fenced { |
| + return errors.New("after BeginFence, under the lock: not fenced") |
| + } |
| + return nil |
| + }) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + other := captured |
| + other.Stamp++ |
| + if fenced, err := f.g.AdmissionFenced(target.Host, f.obs.Claim, other); err != nil || fenced { |
| + t.Fatalf("another installation: %v %v", fenced, err) |
| + } |
| + if _, err := f.g.AdmissionFenced("no.such.host", f.obs.Claim, captured); !errors.Is(err, ErrNoRecord) { |
| + t.Fatalf("a host with no record: %v", err) |
| + } |
| +} |