//go:build unix package host import ( "context" "crypto/sha256" "encoding/hex" "encoding/json" "errors" "os" "path/filepath" "sync" "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" "github.com/carlosframework/platform/internal/release" ) // guardClaims is the guard's claim reader, set by the test. type guardClaims struct { mu sync.Mutex obs hostclaim.Observed } func (g *guardClaims) set(o hostclaim.Observed) { g.mu.Lock(); g.obs = o; g.mu.Unlock() } func (g *guardClaims) Read(context.Context, string) (hostclaim.Observed, error) { g.mu.Lock() defer g.mu.Unlock() return g.obs, nil } // failFirstClaimRead fails the first Get of the claim key only: C10's // re-read (Astra round 4). type failFirstClaimRead struct { release.Store key string failed bool mu sync.Mutex } var errClaimReadDown = errors.New("control store unreachable") func (s *failFirstClaimRead) Get(ctx context.Context, key string) ([]byte, string, error) { s.mu.Lock() first := key == s.key && !s.failed if first { s.failed = true } s.mu.Unlock() if first { return nil, "", errClaimReadDown } return s.Store.Get(ctx, key) } // Astra round 4 (rev 4.2, ruling 70): after a restart between the serving // CAS and the participation discharge, a failure of only C10's claim // re-read never falls through to ordinary cleanup. Cleanup fails with that // error, nothing is stopped, the completed probe's fence is not replaced; // on retry the participation is discharged by the binding and the probe's // receipt, and the first serving wake's cold handover (N6's prior, the // probe's receipt) is accepted by the guard. func TestRelocationRestartDischargeSurvivesAFailedClaimRead(t *testing.T) { ctx := t.Context() const host, box = "shop.example", "dst" created := time.Date(2026, 10, 7, 0, 0, 0, 0, time.UTC) inc, err := hostclaim.Incarnation("acct", "app", host, created) if err != nil { t.Fatal(err) } holder := func(session, stamp uint64, gen string) *hostclaim.Holder { return &hostclaim.Holder{Family: "instance", Key: "apps/acct/app/instances/" + host + ".json", Account: "acct", App: "app", Incarnation: inc, CreatedAt: created, Location: "instances/" + host, Stamp: stamp, Session: session, Generation: gen, WriterBox: box, Admissions: []hostclaim.AdmissionRecord{{Session: 12, Box: box, Generation: "P1", Mode: "cold", Region: "ie"}}} } probing := hostclaim.Observed{Host: host, ETag: "e1", Claim: &hostclaim.Claim{V: 2, Gen: 30, Host: host, Holder: holder(12, 9, "P1"), Op: hostclaim.Op{Kind: hostclaim.KindRelocating, ID: "rel-1", Phase: hostclaim.PhaseRelocatingProbing, Exec: box, Participants: []string{box}}}} reader := &guardClaims{obs: probing} dataDir := t.TempDir() if err := os.Mkdir(filepath.Join(dataDir, "hostguard"), 0o770); err != nil { t.Fatal(err) } guard := hostguard.Guard{DataDir: dataDir, LockDir: t.TempDir(), Claims: reader} if err := guard.Activate(ctx, host, box, nil); err != nil { t.Fatal(err) } // The probe's writer-session stop, completed with root's receipt // (ruling 49), while the op was still relocating/probing. prior := hostguard.FenceTarget{FenceID: "fence-1", Region: "ie", Host: host, Incarnation: inc, Stamp: 9, Session: 12, Op: "rel-1", Phase: hostclaim.PhaseRelocatingProbing, ObligationID: hostguard.WriterStopObligation(12), Box: box, Enrollment: "enr", BootID: "boot", ControllerID: "ctl", ClaimETag: "e1", DirectoryRevision: "1", RosterRevision: 1} if _, err := guard.BeginFence(ctx, prior); err != nil { t.Fatal(err) } at := time.Date(2026, 10, 11, 9, 0, 0, 0, time.UTC) receipt := mustMarshalStopReceipt(StopReceipt{V: 1, ID: "r1", Kind: StopReceiptStopped, Region: "ie", ClaimETag: "e1", ObligationID: prior.ObligationID, FenceID: "fence-1", Host: host, Incarnation: inc, InstallationStamp: 9, WriterSession: 12, OperationID: "rel-1", Phase: hostclaim.PhaseRelocatingProbing, Box: box, Enrollment: "enr", BootID: "boot", ControllerID: "ctl", DirectoryRevision: testReceiptNumeral(prior), Binding: "1", EvidenceAt: at}) if err := guard.CompleteFence(ctx, prior, receipt, at, func(context.Context, hostguard.FenceTarget, []byte, time.Time) error { return nil }); err != nil { t.Fatal(err) } sum := sha256.Sum256(receipt) done, _ := json.Marshal(hostclaim.CompletedRelocation{Op: "rel-1", Stop: hostclaim.ProbeStop{Box: box, Session: 12, Receipt: "fence-1", ReceiptSHA256: hex.EncodeToString(sum[:])}, Stamp: 9, Phase: hostclaim.PhaseRelocatingProbing, Generation: "P1"}) serving := &hostclaim.Claim{V: 2, Gen: 31, Host: host, Holder: holder(12, 9, "P1"), Op: hostclaim.Op{Kind: hostclaim.KindServing, Exec: box, Participants: []string{box}, Journal: map[string]string{hostclaim.JournalCompleted: string(done)}}} raw, err := json.Marshal(serving) if err != nil { t.Fatal(err) } key, _ := hostclaim.Key(host) dir := release.NewDirStore(t.TempDir()) if _, err := dir.Put(ctx, key, raw, ""); err != nil { t.Fatal(err) } if obs, err := hostclaim.Read(ctx, dir, host); err != nil || obs.Claim.Op.Kind != hostclaim.KindServing { t.Fatalf("the serving claim does not read back: %v", err) } reader.set(hostclaim.Observed{Host: host, ETag: "e2", Claim: serving}) store := &failFirstClaimRead{Store: dir, key: key} stops := 0 a := &ClaimAssembly{Claims: store, Guard: guard, Wake: &ClaimWakeAssembly{}, Launches: &hostbox.LaunchJournal{}, Reconcile: &hostbox.ReconcileAdapter{Census: &hostbox.EmptyInstallationCensus{}}, collectStop: func(context.Context, StopRequest, StopDrainer) (StopResult, error) { stops++ return StopResult{}, errors.New("a serving-shaped stop") }} p := &productionClaimLocal{legacyParticipation: true, assembly: a, slots: map[string]*claimParticipation{}} // The restart: clearUntracked hands cleanup the current serving // observation and the persisted .hostexec binding. binding := execlease.Binding{Box: box, Enrollment: "enr", Host: host, Incarnation: inc, Op: "rel-1", BootID: "boot-0"} current := hostclaim.Observed{Host: host, ETag: "e2", Claim: serving} if err := p.cleanup(ctx, current, binding); !errors.Is(err, errClaimReadDown) { t.Fatalf("a failed C10 read fell through: %v", err) } if stops != 0 { t.Fatal("ordinary cleanup asked a stop while the claim read was failing") } if got, err := guard.CompletedWriterStops(ctx, host, hostguard.Captured{Incarnation: inc, Stamp: 9}, "rel-1", box); err != nil || len(got) != 1 || got[0].Target != prior { t.Fatalf("the probe's receipt changed: %+v %v", got, err) } // The retry: storage recovered. if err := p.cleanup(ctx, current, binding); err != nil { t.Fatalf("the retry did not discharge: %v", err) } if stops != 0 { t.Fatal("the discharge asked a stop") } // The first serving wake: N6's Before verifies the receipt, stopping // nothing; after its cold admission (session and stamp 40), the // guard's handover barrier over the probe's receipt is accepted, // which a replacement fence would have refused (ErrFenceActive). h := relocationCompletedHandover{Guard: guard, Box: box} if err := h.Before(ctx, current); err != nil { t.Fatalf("N6 before admission: %v", err) } r, err := h.receipt(ctx, current) if err != nil || r.Target != prior { t.Fatalf("N6's prior is not the probe's receipt: %+v %v", r.Target, err) } admitted := *serving admitted.Holder = holder(40, 40, "g40") admitted.Holder.Admissions = append(admitted.Holder.Admissions, hostclaim.AdmissionRecord{Session: 40, Box: box, Generation: "g40", Mode: "cold", Region: "ie"}) reader.set(hostclaim.Observed{Host: host, ETag: "e3", Claim: &admitted}) barrier := prior barrier.FenceID, barrier.Stamp, barrier.Session, barrier.Op, barrier.Phase, barrier.ObligationID, barrier.ClaimETag = "handover-1", 40, 40, "", "", "", "e3" if err := guard.BeginColdHandover(ctx, prior, barrier, func(*hostclaim.Claim) error { return nil }); err != nil { t.Fatalf("the serving handover from the probe's receipt: %v", err) } }