//go:build linux package host import ( "context" "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" ) // probeStopped moves the participation box's relocation to a passed and // stopped probe: dst's session 12 admitted into P1 at the install stamp, // the guard activated there, and root's writer-session stop of session 12 // completed with its receipt in the guard journal (ruling 49). It returns // the probing observation, the stop's fence target and root's receipt. func (b *participationBox) probeStopped() (hostclaim.Observed, hostguard.FenceTarget, []byte) { b.t.Helper() ctx := b.t.Context() probing := b.update(func(c *hostclaim.Claim) error { stamp := c.Gen + 1 c.Holder.Stamp, c.Holder.Session, c.Holder.Generation, c.Holder.WriterBox = stamp, 12, "P1", "dst" c.Holder.Admissions = append(c.Holder.Admissions, hostclaim.AdmissionRecord{Session: 12, Box: "dst", Generation: "P1", Predecessor: "g1", Mode: "cold", Region: "ie"}) probe, _ := json.Marshal(hostclaim.Probe{Box: "dst", Session: 12, At: time.Date(2026, 10, 7, 3, 0, 0, 0, time.UTC)}) c.Op.Phase = hostclaim.PhaseRelocatingProbing c.Op.Journal = map[string]string{"install_stamp": strconv.FormatUint(stamp, 10), "prepared": "P1", "prepared_opened": "P1", "probe": string(probe)} return nil }) if err := b.guard.Activate(ctx, participationHost, "dst", nil); err != nil { b.t.Fatal(err) } h := probing.Claim.Holder target := hostguard.FenceTarget{FenceID: "fence-1", Region: "ie", Host: participationHost, Incarnation: h.Incarnation, Stamp: h.Stamp, Session: 12, Op: "rel-1", Phase: hostclaim.PhaseRelocatingProbing, ObligationID: hostguard.WriterStopObligation(12), Box: "dst", Enrollment: b.actor.Enrollment, BootID: "boot", ControllerID: "ctl", ClaimETag: probing.ETag, DirectoryRevision: "4", RosterRevision: 4} // The probe's launch is in the box's launch journal, as the wake // journals it: main's ordinary cleanup would stop that session again. if err := b.launches.Prelaunch(ctx, hostbox.LaunchInventory{ControllerPID: 4101, ProcessStart: "start-1", Target: target, Groups: []hostbox.LaunchGroup{{RelativePath: "carlos/" + claimContainmentName(target), Device: 1, Inode: 12}}, NoState: true}); err != nil { b.t.Fatal(err) } if _, err := b.guard.BeginFence(ctx, target); err != nil { b.t.Fatal(err) } at := time.Date(2026, 10, 7, 3, 1, 0, 0, time.UTC) receipt := mustMarshalStopReceipt(StopReceipt{V: 1, ID: "r1", Kind: StopReceiptStopped, Region: "ie", ClaimETag: probing.ETag, ObligationID: target.ObligationID, FenceID: "fence-1", Host: participationHost, Incarnation: h.Incarnation, InstallationStamp: h.Stamp, WriterSession: 12, OperationID: "rel-1", Phase: hostclaim.PhaseRelocatingProbing, Box: "dst", Enrollment: b.actor.Enrollment, BootID: "boot", ControllerID: "ctl", DirectoryRevision: testReceiptNumeral(target), Binding: "4", EvidenceAt: at}) if err := b.guard.CompleteFence(ctx, target, receipt, at, func(context.Context, hostguard.FenceTarget, []byte, time.Time) error { return nil }); err != nil { b.t.Fatal(err) } return probing, target, receipt } // serve is CompleteRelocation's serving CAS as the box sees it: kind // serving on dst, the holder still the stopped probe session, and the // serving op recording the stop. func (b *participationBox) serve(receipt []byte) hostclaim.Observed { b.t.Helper() sum := sha256.Sum256(receipt) return b.update(func(c *hostclaim.Claim) error { done, _ := json.Marshal(hostclaim.CompletedRelocation{Op: "rel-1", Stop: hostclaim.ProbeStop{Box: "dst", Session: 12, Receipt: "fence-1", ReceiptSHA256: hex.EncodeToString(sum[:])}, Stamp: c.Holder.Stamp, Phase: hostclaim.PhaseRelocatingProbing, Generation: c.Holder.Generation}) c.Op = hostclaim.Op{Kind: hostclaim.KindServing, Exec: "dst", Participants: []string{"dst"}, Journal: map[string]string{hostclaim.JournalCompleted: string(done)}} return nil }) } func (b *participationBox) servingCheck(o hostclaim.Observed) error { h := o.Claim.Holder return b.p.Check(b.t.Context(), b.actor, o.Claim, hostbox.Selection{Host: participationHost, Box: "dst", Region: "ie", Captured: hostguard.Captured{Incarnation: h.Incarnation, Stamp: h.Stamp}}) } // Rev 3 finding 5, rulings 56 and 66, C10: completion without an agent // restart. While the participation slot is open the serving Check denies // (the wake cannot admit); DischargeCompleted closes the slot through main's // Prepare, whose cleanup is C10's discharge by the slot's binding and the // probe's receipt (no stop asked of root), ClearStale removes the record, // the serving Check passes, and N6's handover finds the probe's receipt. func TestRelocationCompletionDischargesParticipationWithoutRestart(t *testing.T) { t.Parallel() ctx := t.Context() b := newParticipationBox(t) if err := b.p.Prepare(ctx, b.read()); err != nil { // acknowledged at installing t.Fatal(err) } _, target, receipt := b.probeStopped() serving := b.serve(receipt) if err := b.servingCheck(serving); err == nil { t.Fatal("the serving wake was admissible with the participation open") } if err := b.p.DischargeCompleted(ctx, serving); err != nil { t.Fatalf("discharge: %v", err) } if b.stops != 0 { t.Fatalf("the discharge asked root for %d stops", b.stops) } if len(b.p.slots) != 0 || len(b.records()) != 0 { t.Fatalf("slots %d records %v", len(b.p.slots), b.records()) } if err := b.servingCheck(b.read()); err != nil { t.Fatalf("the serving Check after the discharge: %v", err) } h := relocationCompletedHandover{Guard: b.guard, Box: "dst"} if err := h.Before(ctx, b.read()); err != nil { t.Fatalf("N6 before admission: %v", err) } if r, err := h.receipt(ctx, b.read()); err != nil || r.Target != target { t.Fatalf("N6's prior: %+v %v", r.Target, err) } } // Rev 4 finding 3, ruling 66: the agent restarts after the serving CAS, // with the participation's .hostexec record left behind. The new process's // DischargeCompleted reaches clearUntracked, C10 discharges by the // record's binding (no stop asked, the probe's fence untouched), // ClearStale removes the record, and the first serving wake's handover // from the probe's receipt is accepted by the guard. func TestRelocationRestartAfterServingCASRecoversParticipation(t *testing.T) { t.Parallel() 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, receipt := b.probeStopped() serving := b.serve(receipt) // The crash: the process ends and its controller with it; the record // stays. The restarted agent has no slot. _ = slot.Controller.Close(ctx) b.p.mu.Lock() b.p.slots = map[string]*claimParticipation{} b.p.mu.Unlock() if len(b.records()) != 1 { t.Fatalf("records after the crash: %v", b.records()) } if err := b.p.DischargeCompleted(ctx, serving); err != nil { t.Fatalf("discharge after the restart: %v", err) } if b.stops != 0 || len(b.records()) != 0 { 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) } if err := b.servingCheck(b.read()); err != nil { t.Fatalf("the serving Check: %v", err) } // The first serving wake: a cold admission at session and stamp 40; // the guard's handover barrier over the probe's receipt is accepted. admitted := b.update(func(c *hostclaim.Claim) error { c.Holder.Stamp, c.Holder.Session, c.Holder.Generation = 40, 40, "g40" c.Holder.Admissions = append(c.Holder.Admissions, hostclaim.AdmissionRecord{Session: 40, Box: "dst", Generation: "g40", Predecessor: "P1", Mode: "cold", Region: "ie"}) return nil }) barrier := target barrier.FenceID, barrier.Stamp, barrier.Session, barrier.Op, barrier.Phase, barrier.ObligationID, barrier.ClaimETag = "handover-1", 40, 40, "", "", "", admitted.ETag if err := b.guard.BeginColdHandover(ctx, target, barrier, func(*hostclaim.Claim) error { return nil }); err != nil { 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) { t.Parallel() 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) { t.Parallel() 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) } }