host: a probe-fence marker that is present is never replaced and the hold release refuses without it, an unreadable completion record ends C10's cleanup and refuses N6 rather than routing to main's stop, two completed stops of one session are no receipt, and every relocation completion guard has a test that fails without it
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
6 files changed,
+301
−27
internal/host/relocationdischarge_linux_test.go+72 −0internal/host/relocationhandover.go+15 −4internal/host/relocationhandover_unix_test.go+36 −0internal/host/relocationowners.go+41 −22internal/host/relocationreleasehold_test.go+55 −1internal/host/relocationstopprobe_unix_test.go+82 −0
diff --git a/internal/host/relocationdischarge_linux_test.go b/internal/host/relocationdischarge_linux_test.go| index e55c3343c..8708702e2 100644 |
| --- a/internal/host/relocationdischarge_linux_test.go |
| +++ b/internal/host/relocationdischarge_linux_test.go |
| @@ -7,12 +7,14 @@ import ( |
| "crypto/sha256" |
| "encoding/hex" |
| "encoding/json" |
| + "errors" |
| "strconv" |
| "testing" |
| "time" |
| "github.com/carlosframework/platform/internal/hostbox" |
| "github.com/carlosframework/platform/internal/hostclaim" |
| + execlease "github.com/carlosframework/platform/internal/hostexec" |
| "github.com/carlosframework/platform/internal/hostguard" |
| ) |
| @@ -166,3 +168,73 @@ func TestRelocationRestartAfterServingCASRecoversParticipation(t *testing.T) { |
| t.Fatalf("the serving handover from the probe's receipt: %v", err) |
| } |
| } |
| + |
| +// Absence is never evidence: after the restart, a completion record that |
| +// is present but cannot be read is not "no completion". C10 ends the |
| +// cleanup with that error before anything else: main's cleanup, which |
| +// would ask root to stop the probe's journaled launch again (replacing |
| +// its fence), never runs, nothing is stopped, and the record stays for |
| +// the next pass. The probe's receipt is as it was. |
| +func TestRelocationRestartDischargeRefusesAnUnreadableCompletion(t *testing.T) { |
| + ctx := t.Context() |
| + b := newParticipationBox(t) |
| + if err := b.p.Prepare(ctx, b.read()); err != nil { // acknowledged at installing |
| + t.Fatal(err) |
| + } |
| + slot := b.p.slots[participationHost] |
| + _, target, _ := b.probeStopped() |
| + serving := b.update(func(c *hostclaim.Claim) error { |
| + c.Op = hostclaim.Op{Kind: hostclaim.KindServing, Exec: "dst", Participants: []string{"dst"}, Journal: map[string]string{hostclaim.JournalCompleted: "{not json"}} |
| + return nil |
| + }) |
| + _ = slot.Controller.Close(ctx) |
| + b.p.mu.Lock() |
| + b.p.slots = map[string]*claimParticipation{} |
| + b.p.mu.Unlock() |
| + if err := b.p.DischargeCompleted(ctx, serving); !errors.Is(err, hostclaim.ErrRelocation) { |
| + t.Fatalf("an unreadable completion was not refused: %v", err) |
| + } |
| + if b.stops != 0 || len(b.records()) != 1 { |
| + t.Fatalf("stops %d records %v", b.stops, b.records()) |
| + } |
| + h := serving.Claim.Holder |
| + got, err := b.guard.CompletedWriterStops(ctx, participationHost, hostguard.Captured{Incarnation: h.Incarnation, Stamp: h.Stamp}, "rel-1", "dst") |
| + if err != nil || len(got) != 1 || got[0].Target != target { |
| + t.Fatalf("the probe's receipt changed: %+v %v", got, err) |
| + } |
| +} |
| + |
| +// C10's text: the discharge is by the persisted binding's exact op and |
| +// incarnation. A binding for another op, or another incarnation, never |
| +// discharges a completed relocation its participation was not part of: |
| +// C10 falls through and main's cleanup answers for that binding (here it |
| +// refuses: the box's fixture has no root to stop through), so the cleanup |
| +// never succeeds on its account. The participation's own binding does. |
| +func TestRelocationDischargeIsKeyedByTheBindingsOpAndIncarnation(t *testing.T) { |
| + ctx := t.Context() |
| + b := newParticipationBox(t) |
| + if err := b.p.Prepare(ctx, b.read()); err != nil { // acknowledged at installing |
| + t.Fatal(err) |
| + } |
| + slot := b.p.slots[participationHost] |
| + _, _, receipt := b.probeStopped() |
| + serving := b.serve(receipt) |
| + _ = slot.Controller.Close(ctx) |
| + own := slot.Config.Binding |
| + for name, change := range map[string]func(*execlease.Binding){ |
| + "another op": func(x *execlease.Binding) { x.Op = "rel-0" }, |
| + "another incarnation": func(x *execlease.Binding) { x.Incarnation = "other" }, |
| + } { |
| + binding := own |
| + change(&binding) |
| + if err := b.p.cleanup(ctx, serving, binding); err == nil { |
| + t.Fatalf("%s: the completed relocation was discharged by a binding that is not its participation's", name) |
| + } |
| + } |
| + if err := b.p.cleanup(ctx, serving, own); err != nil { |
| + t.Fatalf("the participation's own binding: %v", err) |
| + } |
| + if b.stops != 0 { |
| + t.Fatalf("root was asked for %d stops", b.stops) |
| + } |
| +} |
diff --git a/internal/host/relocationhandover.go b/internal/host/relocationhandover.go| index 585ee46f3..496aa0d44 100644 |
| --- a/internal/host/relocationhandover.go |
| +++ b/internal/host/relocationhandover.go |
| @@ -74,19 +74,30 @@ func (h relocationCompletedHandover) receipt(ctx context.Context, prior hostclai |
| // writerSessionReceipt is root's completed writer-session stop of session |
| // (hostguard.WriterStopObligation) for op on box, from this box's guard |
| // journal: the one reader of a probe's stop, for the probe stop and for |
| -// the serving handover after it. |
| +// the serving handover after it. Two different completed stops of the |
| +// same session under the same op are a defect of the journal, refused as |
| +// main's CompletedColdPredecessor refuses them (ErrFenceTarget): neither |
| +// is "the" receipt, and the stop never guesses which to record. |
| func writerSessionReceipt(ctx context.Context, guard hostguard.Guard, host string, captured hostguard.Captured, op, box string, session uint64) (hostguard.FenceReceipt, error) { |
| receipts, err := guard.CompletedWriterStops(ctx, host, captured, op, box) |
| if err != nil { |
| return hostguard.FenceReceipt{}, err |
| } |
| want := hostguard.WriterStopObligation(session) |
| + var found hostguard.FenceReceipt |
| for _, r := range receipts { |
| - if r.Target.Session == session && r.Target.ObligationID == want { |
| - return r, nil |
| + if r.Target.Session != session || r.Target.ObligationID != want { |
| + continue |
| } |
| + if found.Target.FenceID != "" && found.Target != r.Target { |
| + return hostguard.FenceReceipt{}, fmt.Errorf("%w: root completed more than one stop of session %d under %s", hostguard.ErrFenceTarget, session, op) |
| + } |
| + found = r |
| + } |
| + if found.Target.FenceID == "" { |
| + return hostguard.FenceReceipt{}, fmt.Errorf("%w: root has no completed stop of session %d under %s", ErrClaimStopReceipt, session, op) |
| } |
| - return hostguard.FenceReceipt{}, fmt.Errorf("%w: root has no completed stop of session %d under %s", ErrClaimStopReceipt, session, op) |
| + return found, nil |
| } |
| // Before replaces the predecessor stop for exactly this prior: the stop |
diff --git a/internal/host/relocationhandover_unix_test.go b/internal/host/relocationhandover_unix_test.go| index 8ece78885..2cf5288cc 100644 |
| --- a/internal/host/relocationhandover_unix_test.go |
| +++ b/internal/host/relocationhandover_unix_test.go |
| @@ -7,6 +7,7 @@ import ( |
| "crypto/sha256" |
| "encoding/hex" |
| "encoding/json" |
| + "errors" |
| "net" |
| "net/http" |
| "path/filepath" |
| @@ -89,10 +90,45 @@ func TestRelocationCompletedHandoverRoutesOnlyTheCompletedPrior(t *testing.T) { |
| t.Fatalf("a serving prior's handover: %v (locals %d, posted %d)", err, locals, len(posted)) |
| } |
| + // After the first serving wake the holder has moved on (session, stamp |
| + // or generation), while the record still names the stopped probe: that |
| + // prior is a serving session like any other, main's stop and main's |
| + // local handover, never the probe's receipt again. |
| + for name, move := range map[string]func(*hostclaim.Holder){ |
| + "session": func(h *hostclaim.Holder) { h.Session = 40 }, |
| + "stamp": func(h *hostclaim.Holder) { h.Stamp = 40 }, |
| + "generation": func(h *hostclaim.Holder) { h.Generation = "g40" }, |
| + } { |
| + moved := completedPrior |
| + c := *moved.Claim |
| + h := *c.Holder |
| + move(&h) |
| + c.Holder = &h |
| + moved.Claim = &c |
| + if err := w.BeforeAdmission(ctx, moved); err != nil || stops != 2 { |
| + t.Fatalf("%s moved on, before admission: %v (stops %d)", name, err, stops) |
| + } |
| + if err := w.AfterAdmission(ctx, moved, admitted(moved)); err != nil || locals != 2 || len(posted) != 1 { |
| + t.Fatalf("%s moved on, after admission: %v (locals %d, posted %d)", name, err, locals, len(posted)) |
| + } |
| + stops, locals = 1, 1 |
| + } |
| + |
| // A record whose stop is not root's receipt is refused before anything. |
| forged := serving(map[string]string{hostclaim.JournalCompleted: string(mustJSON(t, hostclaim.CompletedRelocation{Op: "rel-1", |
| Stop: hostclaim.ProbeStop{Box: "dst", Session: 12, Receipt: "fence-1", ReceiptSHA256: hex.EncodeToString(make([]byte, 32))}, Stamp: 9, Phase: hostclaim.PhaseRelocatingProbing, Generation: "P1"}))}) |
| if err := w.BeforeAdmission(ctx, forged); err == nil || stops != 1 { |
| t.Fatalf("a forged record: %v (stops %d)", err, stops) |
| } |
| + |
| + // A serving prior on this box whose record cannot be read is refused |
| + // before and after admission, never routed to main's stop or local |
| + // handover (absence is never evidence). |
| + unreadable := serving(map[string]string{hostclaim.JournalCompleted: "{not json"}) |
| + if err := w.BeforeAdmission(ctx, unreadable); !errors.Is(err, hostclaim.ErrRelocation) || stops != 1 { |
| + t.Fatalf("an unreadable record before admission: %v (stops %d)", err, stops) |
| + } |
| + if err := w.AfterAdmission(ctx, unreadable, admitted(unreadable)); !errors.Is(err, hostclaim.ErrRelocation) || locals != 1 || len(posted) != 1 { |
| + t.Fatalf("an unreadable record after admission: %v (locals %d, posted %d)", err, locals, len(posted)) |
| + } |
| } |
diff --git a/internal/host/relocationowners.go b/internal/host/relocationowners.go| index 14fff02a4..286472639 100644 |
| --- a/internal/host/relocationowners.go |
| +++ b/internal/host/relocationowners.go |
| @@ -334,19 +334,29 @@ type relocationProbeFence struct { |
| } |
| // relocationProbeFenceOwner returns the owner persisted for host's probe |
| -// session, or persists a new, random one first. A marker for another |
| -// session, or one that does not read, is replaced: a fresh owner can only |
| -// ever match a fence installed under it, so replacing one takes nothing |
| -// over. |
| +// session, or persists a new, random one first. A marker that is present |
| +// is the only record of the owner a fence may already be installed under, |
| +// so it is never replaced: one that cannot be read, or that names another |
| +// host or session, refuses (absence is never evidence), nothing is |
| +// installed, and the marker stays for an operator. The op's probe session |
| +// is fixed once its probe passed, so such a marker is a defect, never a |
| +// retry's. |
| func relocationProbeFenceOwner(path, host string, session uint64) (string, error) { |
| - if rec, ok, err := readRelocationProbeFence(path); err == nil && ok && rec.Host == host && rec.Session == session && rec.Owner != "" { |
| + rec, found, err := readRelocationProbeFence(path) |
| + if err != nil { |
| + return "", err |
| + } |
| + if found { |
| + if rec.Host != host || rec.Session != session { |
| + return "", fmt.Errorf("%w: %s names %s session %d, not %s session %d", hostrelocate.ErrRefused, path, rec.Host, rec.Session, host, session) |
| + } |
| return rec.Owner, nil |
| } |
| var b [16]byte |
| if _, err := rand.Read(b[:]); err != nil { |
| return "", err |
| } |
| - rec := relocationProbeFence{Host: host, Session: session, Owner: hex.EncodeToString(b[:])} |
| + rec = relocationProbeFence{Host: host, Session: session, Owner: hex.EncodeToString(b[:])} |
| raw, err := json.Marshal(rec) |
| if err != nil { |
| return "", err |
| @@ -360,8 +370,9 @@ func relocationProbeFenceOwner(path, host string, session uint64) (string, error |
| return rec.Owner, nil |
| } |
| -// readRelocationProbeFence reads the persisted owner; false when there is |
| -// none. |
| +// readRelocationProbeFence reads the persisted owner; false, with no |
| +// error, only when there is no marker at all. A marker that does not |
| +// decode, or names no host, session or owner, is an error, never "none". |
| func readRelocationProbeFence(path string) (relocationProbeFence, bool, error) { |
| raw, err := os.ReadFile(path) |
| if errors.Is(err, os.ErrNotExist) { |
| @@ -372,7 +383,10 @@ func readRelocationProbeFence(path string) (relocationProbeFence, bool, error) { |
| } |
| var rec relocationProbeFence |
| if err := json.Unmarshal(raw, &rec); err != nil { |
| - return relocationProbeFence{}, false, err |
| + return relocationProbeFence{}, false, fmt.Errorf("%w: %s cannot be read: %v", hostrelocate.ErrRefused, path, err) |
| + } |
| + if rec.Host == "" || rec.Session == 0 || rec.Owner == "" { |
| + return relocationProbeFence{}, false, fmt.Errorf("%w: %s names no host, session or owner", hostrelocate.ErrRefused, path) |
| } |
| return rec, true, nil |
| } |
| @@ -392,8 +406,12 @@ func readRelocationProbeFence(path string) (relocationProbeFence, bool, error) { |
| // it stopped. Once the row is no longer held for the op, the discharge has |
| // happened (nothing else releases a relocating hold for a completed op), |
| // and a replay only lifts and marks. A missing row is refused: absence is |
| -// never evidence of the discharge. Anything else leaves everything: an |
| -// excluded box's row is its park-local's. |
| +// never evidence of the discharge. So is a missing, unreadable or |
| +// mismatched probe-fence owner, read before anything is released: the |
| +// probe stop persisted it before it fenced, and without it nothing can |
| +// say the fence is lifted, so the mark is never written over its absence. |
| +// Anything else leaves everything: an excluded box's row is its |
| +// park-local's. |
| func (a ClaimAssembly) relocationReleaseHold(cfg RelocationAssemblyConfig) func(context.Context, string, string) error { |
| return func(ctx context.Context, host, op string) error { |
| if cfg.Registry == nil || cfg.Activator == nil || cfg.Discharge == nil { |
| @@ -427,6 +445,17 @@ func (a ClaimAssembly) relocationReleaseHold(cfg RelocationAssemblyConfig) func( |
| if row == nil { |
| return fmt.Errorf("%w: %s has no row on this box", hostrelocate.ErrRefused, host) |
| } |
| + marker, err := relocationProbeFencePath(cfg.DataDir, op) |
| + if err != nil { |
| + return err |
| + } |
| + fence, found, err := readRelocationProbeFence(marker) |
| + if err != nil { |
| + return err // the owner is unreadable: nothing is released, the fence stays, the claim is not marked |
| + } |
| + if !found || fence.Host != host || fence.Session != done.Stop.Session { |
| + return fmt.Errorf("%w: %s has no probe-fence owner for %s's stopped session %d", hostrelocate.ErrRefused, host, op, done.Stop.Session) |
| + } |
| if row.HoldOp == op { |
| if done.Stop.Session != obs.Claim.Holder.Session { |
| return fmt.Errorf("%w: %s's serving holder is not %s's stopped probe session", hostrelocate.ErrRefused, host, op) |
| @@ -438,17 +467,7 @@ func (a ClaimAssembly) relocationReleaseHold(cfg RelocationAssemblyConfig) func( |
| return err |
| } |
| } |
| - marker, err := relocationProbeFencePath(cfg.DataDir, op) |
| - if err != nil { |
| - return err |
| - } |
| - fence, found, err := readRelocationProbeFence(marker) |
| - if err != nil { |
| - return err // the owner is unreadable: the fence stays, the claim is not marked |
| - } |
| - if found && fence.Host == host && fence.Session == done.Stop.Session { |
| - cfg.Activator.LiftFenceAs(host, fence.Session, fence.Owner) |
| - } |
| + cfg.Activator.LiftFenceAs(host, fence.Session, fence.Owner) |
| _, err = hostclaim.Update(ctx, a.Claims, obs, hostclaim.MarkCompletedDischarged(op)) |
| if errors.Is(err, hostclaim.ErrAlreadyRecorded) { |
| return nil |
diff --git a/internal/host/relocationreleasehold_test.go b/internal/host/relocationreleasehold_test.go| index 086223773..cdbf1cdd4 100644 |
| --- a/internal/host/relocationreleasehold_test.go |
| +++ b/internal/host/relocationreleasehold_test.go |
| @@ -6,6 +6,7 @@ import ( |
| "context" |
| "encoding/json" |
| "errors" |
| + "os" |
| "path/filepath" |
| "slices" |
| "strings" |
| @@ -55,6 +56,7 @@ func TestRelocationReleaseHoldWaitsForDischarge(t *testing.T) { |
| reg *registry.Registry |
| act *stopProbeActivator |
| owner string |
| + marker string |
| discharge error |
| hold func(context.Context, string, string) error |
| } |
| @@ -79,6 +81,7 @@ func TestRelocationReleaseHoldWaitsForDischarge(t *testing.T) { |
| if err != nil { |
| t.Fatal(err) |
| } |
| + w.marker = marker |
| if w.owner, err = relocationProbeFenceOwner(marker, host, 12); err != nil { |
| t.Fatal(err) |
| } |
| @@ -196,6 +199,47 @@ func TestRelocationReleaseHoldWaitsForDischarge(t *testing.T) { |
| } |
| }) |
| } |
| + // Absence is never evidence (the Global Constraint): the hold release |
| + // reads its evidence before it releases anything, and without it |
| + // refuses with nothing done: the row stays held, the probe's fence |
| + // stands and the record is not marked. |
| + t.Run("an unreadable completion record", func(t *testing.T) { |
| + w := setup(t, claim(func(c *hostclaim.Claim) { c.Op.Journal[hostclaim.JournalCompleted] = "{not json" })) |
| + if err := w.hold(ctx, host, "rel-1"); !errors.Is(err, hostclaim.ErrRelocation) || len(w.order) != 0 || !held(w) || w.act.svc.FenceGeneration(host) != 12 { |
| + t.Fatalf("%v order %v held=%v fence=%d", err, w.order, held(w), w.act.svc.FenceGeneration(host)) |
| + } |
| + }) |
| + for name, change := range map[string]func(*testing.T, string){ |
| + "no probe-fence owner": func(t *testing.T, path string) { |
| + if err := os.Remove(path); err != nil { |
| + t.Fatal(err) |
| + } |
| + }, |
| + "an unreadable probe-fence owner": func(t *testing.T, path string) { |
| + if err := os.WriteFile(path, []byte("{not json"), 0o600); err != nil { |
| + t.Fatal(err) |
| + } |
| + }, |
| + "a probe-fence owner for another session": func(t *testing.T, path string) { |
| + if err := os.WriteFile(path, []byte(`{"host":"shop.example","session":13,"owner":"`+strings.Repeat("b", 32)+`"}`), 0o600); err != nil { |
| + t.Fatal(err) |
| + } |
| + }, |
| + } { |
| + t.Run(name, func(t *testing.T) { |
| + w := setup(t, claim(nil)) |
| + change(t, w.marker) |
| + err := w.hold(ctx, host, "rel-1") |
| + if !errors.Is(err, hostrelocate.ErrRefused) || len(w.order) != 0 || !held(w) || w.act.svc.FenceGeneration(host) != 12 || marked(w) { |
| + t.Fatalf("%v order %v held=%v fence=%d marked=%v", err, w.order, held(w), w.act.svc.FenceGeneration(host), marked(w)) |
| + } |
| + // An unreadable owner is reported as such, never as a missing |
| + // one: the operator who reads the refusal must find the file. |
| + if unreadable := strings.Contains(name, "unreadable"); unreadable != strings.Contains(err.Error(), "cannot be read") { |
| + t.Fatalf("the refusal names the wrong reason: %v", err) |
| + } |
| + }) |
| + } |
| } |
| // Astra round 6, finding 3, through the target's real hold release and |
| @@ -235,7 +279,17 @@ func TestRelocationSuccessorStartsOnlyAfterTheTargetsDischarge(t *testing.T) { |
| a := ClaimAssembly{Claims: store, stopLocal: func(context.Context) (claimStopLocal, error) { |
| return claimStopLocal{Actor: hostbox.Actor{Box: box}}, nil |
| }} |
| - hold := a.relocationReleaseHold(RelocationAssemblyConfig{Registry: reg, Activator: newStopProbeActivator(&order), DataDir: t.TempDir(), |
| + // The probe stop's persisted owner: the hold release reads it before |
| + // it releases anything. |
| + dataDir := t.TempDir() |
| + marker, err := relocationProbeFencePath(dataDir, "rel-1") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := relocationProbeFenceOwner(marker, host, 12); err != nil { |
| + t.Fatal(err) |
| + } |
| + hold := a.relocationReleaseHold(RelocationAssemblyConfig{Registry: reg, Activator: newStopProbeActivator(&order), DataDir: dataDir, |
| Discharge: func(context.Context, hostclaim.Observed) error { return discharge }}) |
| start := func() error { |
| obs, err := hostclaim.Read(ctx, store, host) |
diff --git a/internal/host/relocationstopprobe_unix_test.go b/internal/host/relocationstopprobe_unix_test.go| index bb00976fc..80ea796ab 100644 |
| --- a/internal/host/relocationstopprobe_unix_test.go |
| +++ b/internal/host/relocationstopprobe_unix_test.go |
| @@ -3,6 +3,7 @@ |
| package host |
| import ( |
| + "bytes" |
| "context" |
| "crypto/sha256" |
| "encoding/hex" |
| @@ -12,6 +13,7 @@ import ( |
| "os" |
| "path/filepath" |
| "slices" |
| + "strings" |
| "testing" |
| "time" |
| @@ -19,6 +21,7 @@ import ( |
| "github.com/carlosframework/platform/internal/hostbox" |
| "github.com/carlosframework/platform/internal/hostclaim" |
| "github.com/carlosframework/platform/internal/hostguard" |
| + "github.com/carlosframework/platform/internal/hostrelocate" |
| ) |
| // stopProbeActivator is a real activator.Service's fences, the calls |
| @@ -291,3 +294,82 @@ func TestRelocationStopProbeRetriesAFailedMarkerWrite(t *testing.T) { |
| t.Fatal("the fence installed is not the persisted owner's") |
| } |
| } |
| + |
| +// Absence is never evidence: a probe-fence marker that is present is the |
| +// only record of the owner a fence may already be installed under, so one |
| +// that cannot be read, or that names another host, session or no owner, |
| +// is never replaced by a fresh owner. The stop refuses before the |
| +// activator is asked, no fence is installed, and the marker stays as it |
| +// was, for an operator. |
| +func TestRelocationStopProbeNeverReplacesAPresentMarker(t *testing.T) { |
| + const host = "shop.example" |
| + owner := strings.Repeat("a", 32) |
| + for name, body := range map[string][]byte{ |
| + "unreadable": []byte("{not json"), |
| + "another session": []byte(`{"host":"shop.example","session":13,"owner":"` + owner + `"}`), |
| + "another host": []byte(`{"host":"other.example","session":12,"owner":"` + owner + `"}`), |
| + "no owner": []byte(`{"host":"shop.example","session":12}`), |
| + } { |
| + t.Run(name, func(t *testing.T) { |
| + f := newStopProbeFixture(t) |
| + path, err := relocationProbeFencePath(f.cfg.DataDir, "rel-1") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := os.WriteFile(path, body, 0o600); err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := f.stop(); !errors.Is(err, hostrelocate.ErrRefused) { |
| + t.Fatalf("the probe stop over a marker it did not write: %v", err) |
| + } |
| + if len(f.order) != 0 || f.act.svc.Fenced(host) { |
| + t.Fatalf("the activator was asked: %v", f.order) |
| + } |
| + if after, err := os.ReadFile(path); err != nil || !bytes.Equal(after, body) { |
| + t.Fatalf("the marker was replaced: %q %v", after, err) |
| + } |
| + }) |
| + } |
| +} |
| + |
| +// Two completed writer-session stops of the probe's session under the same |
| +// op on this box (a second fence under another roster revision) leave no |
| +// one receipt to record or to hand over from. The stop and N6 refuse with |
| +// hostguard.ErrFenceTarget, as main's CompletedColdPredecessor does, |
| +// rather than take the first the journal lists. |
| +func TestRelocationStopProbeRefusesTwoCompletedStopsOfTheSession(t *testing.T) { |
| + ctx := t.Context() |
| + f := newStopProbeFixture(t) |
| + first, err := f.stop() |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + // Root's second stop of session 12 under rel-1: a repeat under the next |
| + // roster revision, which main's fence target carries, so the guard |
| + // journals it beside the first rather than meeting it again. |
| + second := f.target |
| + second.FenceID, second.DirectoryRevision = "fence-2", "2" |
| + if _, err := f.a.Guard.BeginFence(ctx, second); err != nil { |
| + t.Fatal(err) |
| + } |
| + at := time.Date(2026, 10, 7, 4, 0, 0, 0, time.UTC) |
| + receipt := mustMarshalStopReceipt(StopReceipt{V: 1, ID: "r2", Kind: StopReceiptStopped, Region: "ie", ClaimETag: "e1", ObligationID: second.ObligationID, FenceID: "fence-2", Host: f.obs.Host, Incarnation: second.Incarnation, |
| + InstallationStamp: 9, WriterSession: 12, OperationID: "rel-1", Phase: hostclaim.PhaseRelocatingProbing, Box: "dst", Enrollment: "enr", BootID: "boot", ControllerID: "ctl", DirectoryRevision: "2", EvidenceAt: at}) |
| + if err := f.a.Guard.CompleteFence(ctx, second, receipt, at, func(context.Context, hostguard.FenceTarget, []byte, time.Time) error { return nil }); err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := f.stop(); !errors.Is(err, hostguard.ErrFenceTarget) { |
| + t.Fatalf("the stop over two receipts: %v", err) |
| + } |
| + // N6 over the serving record of the first stop refuses the same way. |
| + done, _ := json.Marshal(hostclaim.CompletedRelocation{Op: "rel-1", Stop: first, Stamp: 9, Phase: hostclaim.PhaseRelocatingProbing, Generation: "P1"}) |
| + c := *f.obs.Claim |
| + c.Op = hostclaim.Op{Kind: hostclaim.KindServing, Exec: "dst", Participants: []string{"dst"}, Journal: map[string]string{hostclaim.JournalCompleted: string(done)}} |
| + h := relocationCompletedHandover{Guard: f.a.Guard, Box: "dst"} |
| + if _, err := h.receipt(ctx, hostclaim.Observed{Host: f.obs.Host, ETag: "e2", Claim: &c}); !errors.Is(err, hostguard.ErrFenceTarget) { |
| + t.Fatalf("N6 over two receipts: %v", err) |
| + } |
| +} |