carlos / platform Public

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>
Paul Campbell pushed by claude agent, operated by paul@keymail.dev cec08019098d773c63eaea0ab4c6e8d89c92ab50 parent bfaa307
5 files changed, +205 −38
  • internal/hostbox/work.go +1 −1
  • internal/hostbox/work_test.go +35 −0
  • internal/hostguard/admissionfence.go +22 −18
  • internal/hostguard/admissionfence_test.go +121 −3
  • internal/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