hostguard: AdmissionFenced reads closed whenever With refuses on the record, so a retired fenced installation stays fenced, and a probe waits out quiet_after
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
5 files changed,
+205
−38
internal/hostbox/work.go+1 −1internal/hostbox/work_test.go+35 −0internal/hostguard/admissionfence.go+22 −18internal/hostguard/admissionfence_test.go+121 −3internal/hostguard/guard.go+26 −16
diff --git a/internal/hostbox/work.go b/internal/hostbox/work.go| index ad9fa22c7..74a4d58a5 100644 |
| --- a/internal/hostbox/work.go |
| +++ b/internal/hostbox/work.go |
| @@ -65,7 +65,7 @@ func (w selectedWork) With(ctx context.Context, identity hostwrite.Identity, fn |
| return ErrChanged |
| } |
| switch selected.Action { |
| - case "install", "restore", "spawn", "seed", "writer": |
| + case "install", "restore", "spawn", "seed", "writer", "probe": |
| if until, ok := c.QuietAfter[s.Region]; ok && !s.Now().After(until) { |
| return ErrChanged |
| } |
diff --git a/internal/hostbox/work_test.go b/internal/hostbox/work_test.go| index 2d013ef9b..694faa9af 100644 |
| --- a/internal/hostbox/work_test.go |
| +++ b/internal/hostbox/work_test.go |
| @@ -114,3 +114,38 @@ func TestSelectedWorkNeedsLocalRecordAndProviders(t *testing.T) { |
| t.Fatal(err) |
| } |
| } |
| + |
| +// Every execution action waits out quiet_after[region], a relocation |
| +// target's probe spawn among them (§5.8: like a push). |
| +func TestSelectedWorkExecutionWaitsOutTheQuietWindow(t *testing.T) { |
| + source, store, obs := workFixture(t) |
| + source.Policy = func(context.Context, *hostclaim.Claim, Selection, hostwrite.Identity) error { return nil } |
| + ctx := context.Background() |
| + quiet := time.Unix(200, 0) |
| + if _, err := hostclaim.Update(ctx, store, obs, func(c *hostclaim.Claim) error { |
| + c.QuietAfter = map[string]time.Time{"ie": quiet} |
| + return nil |
| + }); err != nil { |
| + t.Fatal(err) |
| + } |
| + run := func(now time.Time, action string) error { |
| + at := source |
| + at.Now = func() time.Time { return now } |
| + work, err := at.Select(ctx, "shop.example", action) |
| + if err != nil { |
| + return err |
| + } |
| + return work.With(ctx, hostwrite.Identity{}, func() error { return nil }) |
| + } |
| + for _, action := range []string{"probe", "spawn", "install", "restore", "seed", "writer"} { |
| + if err := run(quiet, action); !errors.Is(err, ErrChanged) { |
| + t.Fatalf("%s inside the quiet window: %v", action, err) |
| + } |
| + if err := run(quiet.Add(time.Second), action); err != nil { |
| + t.Fatalf("%s after the quiet window: %v", action, err) |
| + } |
| + } |
| + if err := run(quiet, "config"); err != nil { |
| + t.Fatalf("a non-execution action waited: %v", err) |
| + } |
| +} |
diff --git a/internal/hostguard/admissionfence.go b/internal/hostguard/admissionfence.go| index 70a800fdc..9177d5830 100644 |
| --- a/internal/hostguard/admissionfence.go |
| +++ b/internal/hostguard/admissionfence.go |
| @@ -2,30 +2,34 @@ 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. |
| +// AdmissionFenced reports whether the host's admission is closed for the |
| +// installation and session captured names on claim c. It is With's own |
| +// reading of the record (record.admits), exposed for region HA §5.3's row |
| +// decision ("the source in releasing until its admission fence closes"): |
| +// closed whenever With would refuse on the record, so a stop root that |
| +// began (ActiveFence) or completed (a FenceJournal receipt) for that |
| +// session, and also an inactive or retired installation, a stamp that is |
| +// not the active one, or another incarnation on the record. Absence is |
| +// never evidence: a retired fenced installation still reads closed. With's |
| +// other refusals (no record, a malformed record, a claim for another host |
| +// or incarnation) are errors, never "open". 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 |
| } |
| + if c.Host != host || c.Holder.Incarnation == "" { |
| + return false, ErrMalformedClaim |
| + } |
| + if c.Holder.Incarnation != captured.Incarnation { |
| + return false, ErrClaimIncarnation |
| + } |
| 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 |
| + // Every refusal admits gives is a record state, never a read failure. |
| + return r.admits(c, captured) != nil, nil |
| } |
diff --git a/internal/hostguard/admissionfence_test.go b/internal/hostguard/admissionfence_test.go| index 8260d6632..04bbc0e0b 100644 |
| --- a/internal/hostguard/admissionfence_test.go |
| +++ b/internal/hostguard/admissionfence_test.go |
| @@ -3,8 +3,12 @@ |
| package hostguard |
| import ( |
| + "context" |
| "errors" |
| + "os" |
| + "path/filepath" |
| "testing" |
| + "time" |
| ) |
| func TestAdmissionFencedFollowsTheDurableFence(t *testing.T) { |
| @@ -27,12 +31,126 @@ func TestAdmissionFencedFollowsTheDurableFence(t *testing.T) { |
| if err != nil { |
| t.Fatal(err) |
| } |
| + // A capture whose stamp is not the active one is refused by With, so |
| + // it reads closed too. |
| other := captured |
| other.Stamp++ |
| - if fenced, err := f.g.AdmissionFenced(target.Host, f.obs.Claim, other); err != nil || fenced { |
| + 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) |
| + if _, err := f.g.AdmissionFenced("no.such.host", f.obs.Claim, captured); err == nil { |
| + t.Fatal("a claim for another host was read") |
| + } |
| +} |
| + |
| +// AdmissionFenced and With read the same record the same way: With admits |
| +// exactly when AdmissionFenced reads open with no error, in every state. |
| +func TestAdmissionFencedAgreesWithWith(t *testing.T) { |
| + verified := func(context.Context, FenceTarget, []byte, time.Time) error { return nil } |
| + complete := func(t *testing.T, f *guardFixture, target FenceTarget) { |
| + if _, err := f.g.BeginFence(t.Context(), target); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := f.g.CompleteFence(t.Context(), target, []byte("root receipt"), time.Unix(100, 0).UTC(), verified); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + retire := func(t *testing.T, f *guardFixture, target FenceTarget) { |
| + complete(t, f, target) |
| + if err := f.g.CleanupRetireInstallationWith(t.Context(), target, func(context.Context, FenceReceipt) error { return nil }, func() error { return nil }); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + for _, tc := range []struct { |
| + name string |
| + change func(*testing.T, *guardFixture, FenceTarget, *Captured) |
| + with error // With's refusal; nil admits |
| + fenced bool |
| + readErr error |
| + }{ |
| + {"open", func(*testing.T, *guardFixture, FenceTarget, *Captured) {}, nil, false, nil}, |
| + {"a stop root began", func(t *testing.T, f *guardFixture, target FenceTarget, _ *Captured) { |
| + if _, err := f.g.BeginFence(t.Context(), target); err != nil { |
| + t.Fatal(err) |
| + } |
| + }, ErrFenced, true, nil}, |
| + {"a stop root completed, the receipt only", func(t *testing.T, f *guardFixture, target FenceTarget, _ *Captured) { |
| + complete(t, f, target) |
| + // As CompleteColdHandover leaves it: no active fence, the journal kept. |
| + r, err := readRecord(f.g.DataDir, target.Host) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + r.ActiveFence = nil |
| + if err := writeRecord(f.g.DataDir, r); err != nil { |
| + t.Fatal(err) |
| + } |
| + }, ErrFenced, true, nil}, |
| + {"fenced, then retired by cleanup", func(t *testing.T, f *guardFixture, target FenceTarget, _ *Captured) { |
| + retire(t, f, target) |
| + }, ErrInactive, true, nil}, |
| + {"fenced, retired, then captured again", func(t *testing.T, f *guardFixture, target FenceTarget, c *Captured) { |
| + retire(t, f, target) |
| + fresh, err := Capture(f.g.DataDir, target.Host) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + *c = fresh |
| + }, ErrInactive, true, nil}, |
| + {"retired with no fence", func(t *testing.T, f *guardFixture, _ FenceTarget, _ *Captured) { |
| + if err := f.g.RetireInstallation(t.Context(), f.obs.Host, allow); err != nil { |
| + t.Fatal(err) |
| + } |
| + }, ErrInactive, true, nil}, |
| + {"a later installation is active", func(t *testing.T, f *guardFixture, _ FenceTarget, _ *Captured) { |
| + f.obs.Claim.Holder.Stamp = 3 |
| + if err := f.g.Activate(t.Context(), f.obs.Host, "uk", nil); err != nil { |
| + t.Fatal(err) |
| + } |
| + }, ErrStampMismatch, true, nil}, |
| + {"another incarnation on the record", func(t *testing.T, f *guardFixture, _ FenceTarget, _ *Captured) { |
| + if err := writeRecord(f.g.DataDir, record{V: 1, Host: f.obs.Host, Incarnation: "other", ActiveStamp: 1}); err != nil { |
| + t.Fatal(err) |
| + } |
| + }, ErrRecordIncarnation, true, nil}, |
| + {"a later session of the fenced installation", func(t *testing.T, f *guardFixture, target FenceTarget, _ *Captured) { |
| + if _, err := f.g.BeginFence(t.Context(), target); err != nil { |
| + t.Fatal(err) |
| + } |
| + f.obs.Claim.Holder.Session = 8 |
| + }, nil, false, nil}, |
| + {"a claim of another incarnation", func(_ *testing.T, f *guardFixture, _ FenceTarget, _ *Captured) { |
| + f.obs.Claim.Holder.Incarnation = "other" |
| + }, ErrClaimIncarnation, false, ErrClaimIncarnation}, |
| + {"a claim of another host", func(_ *testing.T, f *guardFixture, _ FenceTarget, _ *Captured) { |
| + f.obs.Claim.Host = "else.example" |
| + }, ErrMalformedClaim, false, ErrMalformedClaim}, |
| + {"no record", func(t *testing.T, f *guardFixture, _ FenceTarget, _ *Captured) { |
| + if err := os.Remove(filepath.Join(f.g.DataDir, "hostguard", f.obs.Host+".json")); err != nil { |
| + t.Fatal(err) |
| + } |
| + }, ErrNoRecord, false, ErrNoRecord}, |
| + {"a malformed record", func(t *testing.T, f *guardFixture, _ FenceTarget, _ *Captured) { |
| + if err := os.WriteFile(filepath.Join(f.g.DataDir, "hostguard", f.obs.Host+".json"), []byte(`{`), 0660); err != nil { |
| + t.Fatal(err) |
| + } |
| + }, ErrMalformedRecord, false, ErrMalformedRecord}, |
| + } { |
| + t.Run(tc.name, func(t *testing.T) { |
| + f, target, captured := fenceFixture(t) |
| + tc.change(t, f, target, &captured) |
| + called := false |
| + withErr := f.g.With(t.Context(), f.obs.Host, captured, allow, func() error { called = true; return nil }) |
| + if !errors.Is(withErr, tc.with) || (withErr == nil) != called { |
| + t.Fatalf("With = %v, called=%v; want %v", withErr, called, tc.with) |
| + } |
| + fenced, err := f.g.AdmissionFenced(f.obs.Host, f.obs.Claim, captured) |
| + if fenced != tc.fenced || !errors.Is(err, tc.readErr) || (tc.readErr == nil && err != nil) { |
| + t.Fatalf("AdmissionFenced = %v, %v; want %v, %v", fenced, err, tc.fenced, tc.readErr) |
| + } |
| + if open := !fenced && err == nil; open != (withErr == nil) { |
| + t.Fatalf("disagreement: With = %v, AdmissionFenced = %v, %v", withErr, fenced, err) |
| + } |
| + }) |
| } |
| } |
diff --git a/internal/hostguard/guard.go b/internal/hostguard/guard.go| index 04b4be015..35f4e177c 100644 |
| --- a/internal/hostguard/guard.go |
| +++ b/internal/hostguard/guard.go |
| @@ -132,22 +132,8 @@ func (g Guard) With(ctx context.Context, host string, captured Captured, check f |
| if err != nil { |
| return err |
| } |
| - if r.Incarnation != captured.Incarnation { |
| - return ErrRecordIncarnation |
| - } |
| - if r.ActiveStamp == 0 { |
| - return ErrInactive |
| - } |
| - if captured.Stamp != r.ActiveStamp { |
| - return ErrStampMismatch |
| - } |
| - if r.ActiveFence != nil && r.ActiveFence.matchesSession(c, captured) { |
| - return ErrFenced |
| - } |
| - for _, receipt := range r.FenceJournal { |
| - if receipt.Target.matchesSession(c, captured) { |
| - return ErrFenced |
| - } |
| + if err := r.admits(c, captured); err != nil { |
| + return err |
| } |
| if fn == nil { |
| return ErrInvalidInput |
| @@ -159,6 +145,30 @@ func (g Guard) With(ctx context.Context, host string, captured Captured, check f |
| }) |
| } |
| +// admits is With's reading of the local record: nil only when the record |
| +// lets the captured installation run claim c's session. AdmissionFenced |
| +// reads the same, so the two cannot disagree. |
| +func (r record) admits(c *hostclaim.Claim, captured Captured) error { |
| + if r.Incarnation != captured.Incarnation { |
| + return ErrRecordIncarnation |
| + } |
| + if r.ActiveStamp == 0 { |
| + return ErrInactive |
| + } |
| + if captured.Stamp != r.ActiveStamp { |
| + return ErrStampMismatch |
| + } |
| + if r.ActiveFence != nil && r.ActiveFence.matchesSession(c, captured) { |
| + return ErrFenced |
| + } |
| + for _, receipt := range r.FenceJournal { |
| + if receipt.Target.matchesSession(c, captured) { |
| + return ErrFenced |
| + } |
| + } |
| + return nil |
| +} |
| + |
| func (r *record) retire() { |
| if r.ActiveStamp == 0 { |
| return |