package host import ( "context" "crypto/ed25519" "encoding/json" "errors" "os" "path/filepath" "reflect" "strings" "testing" "time" "github.com/carlosframework/platform/internal/hostclaim" "github.com/carlosframework/platform/internal/hostguard" "github.com/carlosframework/platform/internal/release" ) type stopPeerFunc func(context.Context) (StopPeer, error) func (f stopPeerFunc) AuthenticatedPeer(ctx context.Context) (StopPeer, error) { return f(ctx) } type stopInventoryFunc func(context.Context, string) (StopInventory, error) func (f stopInventoryFunc) ReadStopInventory(ctx context.Context, host string) (StopInventory, error) { return f(ctx, host) } type stopDrainFunc func(context.Context, StopRequest, string) (DrainAcknowledgement, error) func (f stopDrainFunc) DrainClaim(ctx context.Context, req StopRequest, fence string) (DrainAcknowledgement, error) { return f(ctx, req, fence) } type stopKernelFunc func(context.Context, hostguard.FenceTarget, StopInventory, DrainAcknowledgement) (StopKernelEvidence, error) func (f stopKernelFunc) VerifyStopped(ctx context.Context, target hostguard.FenceTarget, inv StopInventory, ack DrainAcknowledgement) (StopKernelEvidence, error) { return f(ctx, target, inv, ack) } type stopClaimReader struct{ obs hostclaim.Observed } func (r stopClaimReader) Read(context.Context, string) (hostclaim.Observed, error) { return r.obs, nil } type failPutStore struct { release.Store err error } func (s *failPutStore) Put(ctx context.Context, key string, body []byte, match string) (string, error) { if s.err != nil { err := s.err s.err = nil return "", err } return s.Store.Put(ctx, key, body, match) } func claimStopFixture(t *testing.T) (ClaimStopCoordinator, StopRequest, *failPutStore) { t.Helper() host := "shop.example" data, locks := t.TempDir(), t.TempDir() if err := os.Mkdir(filepath.Join(data, "hostguard"), 0770); err != nil { t.Fatal(err) } claim := &hostclaim.Claim{V: 2, Host: host, Gen: 19, Holder: &hostclaim.Holder{Incarnation: "inc-1", Stamp: 11, Session: 13}, Op: hostclaim.Op{Kind: hostclaim.KindMoving, ID: "operation-1", Phase: "stopping", Exec: "box-1"}} obs := hostclaim.Observed{Host: host, Claim: claim, ETag: "etag-1"} guard := hostguard.Guard{DataDir: data, LockDir: locks, Claims: stopClaimReader{obs}} if err := guard.Activate(t.Context(), host, "box-1", nil); err != nil { t.Fatal(err) } req := StopRequest{Host: host, Incarnation: "inc-1", InstallationStamp: 11, WriterSession: 13, OperationID: "operation-1", Phase: "stopping", ObligationID: "obligation-1", Box: "box-1", Enrollment: "enrollment-1", BootID: "boot-1", ControllerID: "controller-1", ExpectedClaimETag: "etag-1", ExpectedDirectoryRevision: "directory-1", RosterRevision: 7} base := release.NewDirStore(t.TempDir()) store := &failPutStore{Store: base} ids := []string{"fence-1", "receipt-1", "fence-retry", "receipt-retry"} now := time.Date(2026, 10, 6, 17, 0, 0, 123, time.UTC) c := ClaimStopCoordinator{VerifiedRosterRevision: fixedRosterRevision(7), EvidenceSigner: ed25519.NewKeyFromSeed(make([]byte, ed25519.SeedSize)), RequestLimit: 120 * time.Second, Guard: guard, Claims: stopClaimReader{obs}, CarlosUID: 1001, Evidence: store, Peers: stopPeerFunc(func(context.Context) (StopPeer, error) { return StopPeer{UID: 1001, PID: 42, ProcessStart: "start-42", BootID: "boot-1", ControllerID: "controller-1"}, nil }), Inventory: stopInventoryFunc(func(_ context.Context, got string) (StopInventory, error) { return StopInventory{Host: got, Incarnation: "inc-1", InstallationStamp: 11, Box: "box-1", Enrollment: "enrollment-1", DirectoryRevision: "directory-1", Entries: []StopInventoryEntry{{Kind: "path", ArtifactID: "artifact-1", ManagedRoot: "data", RelativePath: "derived/by/root"}}}, nil }), Drainer: stopDrainFunc(func(_ context.Context, _ StopRequest, fence string) (DrainAcknowledgement, error) { return DrainAcknowledgement{FenceID: fence, ControllerID: "controller-1", ProcessStart: "start-42", Digest: strings.Repeat("a", 64)}, nil }), Kernel: stopKernelFunc(func(_ context.Context, _ hostguard.FenceTarget, _ StopInventory, _ DrainAcknowledgement) (StopKernelEvidence, error) { return StopKernelEvidence{InventorySHA256: strings.Repeat("b", 64), LocalJournalSHA256: strings.Repeat("c", 64), CgroupIdentities: []string{"cg-1"}, UnitInvocationIDs: []string{"unit-1"}}, nil }), NewID: func() string { id := ids[0]; ids = ids[1:]; return id }, Now: func() time.Time { return now }} return c, req, store } func TestClaimStopCollectsAndPublishesExactReceipt(t *testing.T) { c, req, _ := claimStopFixture(t) result, err := c.FenceAndStop(t.Context(), req) if err != nil { t.Fatal(err) } wantKey := "control/hosts/shop.example/evidence/operation-1/box-1/receipt-1.json" if result.Key != wantKey || result.Digest == "" { t.Fatalf("result = %+v", result) } stored, _, err := c.Evidence.Get(t.Context(), result.Key) if err != nil || !reflect.DeepEqual(stored, result.Bytes) { t.Fatalf("stored exact bytes = %v, %v", reflect.DeepEqual(stored, result.Bytes), err) } var receipt StopReceipt if err := json.Unmarshal(stored, &receipt); err != nil { t.Fatal(err) } if receipt.FenceID != "fence-1" || receipt.ID != "receipt-1" || receipt.EvidenceAt != result.EvidenceAt || receipt.ControllerID != req.ControllerID || receipt.Phase != req.Phase || receipt.ProducerDrainDigest != strings.Repeat("a", 64) || !reflect.DeepEqual(receipt.CgroupIdentities, []string{"cg-1"}) || !reflect.DeepEqual(receipt.UnitInvocationIDs, []string{"unit-1"}) { t.Fatalf("receipt = %+v", receipt) } } func TestClaimStopAuthenticatesBeforeFenceOrDrain(t *testing.T) { c, req, _ := claimStopFixture(t) drained := false c.Peers = stopPeerFunc(func(context.Context) (StopPeer, error) { return StopPeer{UID: 1001, PID: 42, ProcessStart: "start-42", BootID: "wrong", ControllerID: "controller-1"}, nil }) c.Drainer = stopDrainFunc(func(context.Context, StopRequest, string) (DrainAcknowledgement, error) { drained = true return DrainAcknowledgement{}, nil }) if _, err := c.FenceAndStop(t.Context(), req); !errors.Is(err, ErrClaimStopIdentity) || drained { t.Fatalf("unauthenticated stop = %v drained=%v", err, drained) } called := false err := c.Guard.With(t.Context(), req.Host, hostguard.Captured{Incarnation: req.Incarnation, Stamp: req.InstallationStamp}, func(*hostclaim.Claim) error { return nil }, func() error { called = true; return nil }) if err != nil || !called { t.Fatalf("authentication installed fence: %v called=%v", err, called) } } func TestClaimStopPublicationRetryKeepsBytesAndEvidenceTime(t *testing.T) { c, req, store := claimStopFixture(t) transport := errors.New("temporary publication failure") store.err = transport result, err := c.FenceAndStop(t.Context(), req) if !errors.Is(err, transport) || len(result.Bytes) == 0 || result.EvidenceAt.IsZero() { t.Fatalf("first publication = %+v, %v", result, err) } original := append([]byte(nil), result.Bytes...) at := result.EvidenceAt if err := c.Publish(t.Context(), result); err != nil { t.Fatal(err) } if !reflect.DeepEqual(result.Bytes, original) || result.EvidenceAt != at { t.Fatal("retry changed durable receipt") } if err := c.Publish(t.Context(), result); err != nil { t.Fatalf("create-only exact replay = %v", err) } } // The fence was persisted with roster numeral 5 before the crash; the retry // arrives after two roster writes with numeral 7. The retry resumes the // durable fence, and its receipt carries the persisted numeral and the // installation's binding (roster binding B2, N1). func TestClaimStopCrashAfterBeginReusesDurableFenceID(t *testing.T) { c, req, _ := claimStopFixture(t) original := hostguard.FenceTarget{ObligationID: req.ObligationID, ClaimGen: 19, FenceID: "fence-before-crash", Host: req.Host, Incarnation: req.Incarnation, Stamp: req.InstallationStamp, Session: req.WriterSession, Op: req.OperationID, Phase: req.Phase, Box: req.Box, Enrollment: req.Enrollment, BootID: req.BootID, ControllerID: req.ControllerID, ClaimETag: req.ExpectedClaimETag, DirectoryRevision: req.ExpectedDirectoryRevision, RosterRevision: req.RosterRevision - 2} if _, err := c.Guard.BeginFence(t.Context(), original); err != nil { t.Fatal(err) } seen := "" c.Drainer = stopDrainFunc(func(_ context.Context, _ StopRequest, fence string) (DrainAcknowledgement, error) { seen = fence return DrainAcknowledgement{FenceID: fence, ControllerID: req.ControllerID, ProcessStart: "start-42", Digest: strings.Repeat("a", 64)}, nil }) result, err := c.FenceAndStop(t.Context(), req) if err != nil { t.Fatal(err) } var receipt StopReceipt if err := json.Unmarshal(result.Bytes, &receipt); err != nil { t.Fatal(err) } if seen != original.FenceID || receipt.FenceID != original.FenceID { t.Fatalf("retry used fence %q, receipt %q, want durable %q", seen, receipt.FenceID, original.FenceID) } if result.Target != original || receipt.DirectoryRevision != "5" || receipt.Binding != req.ExpectedDirectoryRevision { t.Fatalf("retry target %+v, receipt numeral %q binding %q; want the persisted fence's", result.Target, receipt.DirectoryRevision, receipt.Binding) } if _, err := validateTransportStop(req, result); err != nil { t.Fatal("the agent refused the original numeral:", err) } } // A fence persisted before roster binding has no numeral: a retry resumes // it, and it is refused before any drain, with the deny kept. func TestClaimStopRefusesFenceWithoutRosterNumeral(t *testing.T) { c, req, _ := claimStopFixture(t) legacy := hostguard.FenceTarget{ObligationID: req.ObligationID, ClaimGen: 19, FenceID: "legacy-fence", Host: req.Host, Incarnation: req.Incarnation, Stamp: req.InstallationStamp, Session: req.WriterSession, Op: req.OperationID, Phase: req.Phase, Box: req.Box, Enrollment: req.Enrollment, BootID: req.BootID, ControllerID: req.ControllerID, ClaimETag: req.ExpectedClaimETag, DirectoryRevision: req.ExpectedDirectoryRevision} if _, err := c.Guard.BeginFence(t.Context(), legacy); err != nil { t.Fatal(err) } c.Drainer = stopDrainFunc(func(context.Context, StopRequest, string) (DrainAcknowledgement, error) { t.Fatal("a fence without a numeral was drained") return DrainAcknowledgement{}, nil }) if _, err := c.FenceAndStop(t.Context(), req); !errors.Is(err, ErrClaimStopReceipt) { t.Fatalf("legacy fence: %v", err) } if pending, err := c.Guard.PendingFences(t.Context()); err != nil || len(pending) != 1 || pending[0] != legacy { t.Fatalf("legacy fence not kept: %+v %v", pending, err) } zero := req zero.RosterRevision = 0 if _, err := c.FenceAndStop(t.Context(), zero); !errors.Is(err, ErrClaimStopUnavailable) { t.Fatalf("a request without a numeral: %v", err) } } func TestClaimStopCompletedRetryPublishesOriginalEvidence(t *testing.T) { c, req, _ := claimStopFixture(t) drains := 0 c.Drainer = stopDrainFunc(func(_ context.Context, _ StopRequest, fence string) (DrainAcknowledgement, error) { drains++ return DrainAcknowledgement{FenceID: fence, ControllerID: req.ControllerID, ProcessStart: "start-42", Digest: strings.Repeat("a", 64)}, nil }) first, err := c.FenceAndStop(t.Context(), req) if err != nil { t.Fatal(err) } second, err := c.FenceAndStop(t.Context(), req) if err != nil { t.Fatal(err) } if drains != 1 || !reflect.DeepEqual(first.Bytes, second.Bytes) || first.Key != second.Key || !first.EvidenceAt.Equal(second.EvidenceAt) { t.Fatalf("completed retry recollected evidence: drains=%d first=%+v second=%+v", drains, first, second) } } func TestClaimStopRefusesIncompleteProvidersAndEvidence(t *testing.T) { c, req, _ := claimStopFixture(t) c.Kernel = nil if _, err := c.FenceAndStop(t.Context(), req); !errors.Is(err, ErrClaimStopUnavailable) { t.Fatalf("nil kernel = %v", err) } c, req, _ = claimStopFixture(t) c.Kernel = stopKernelFunc(func(context.Context, hostguard.FenceTarget, StopInventory, DrainAcknowledgement) (StopKernelEvidence, error) { return StopKernelEvidence{}, nil }) if _, err := c.FenceAndStop(t.Context(), req); !errors.Is(err, ErrClaimStopReceipt) { t.Fatalf("incomplete evidence = %v", err) } } // fixedRosterRevision is a fixture's verified roster read: the roster it // stands for is at revision n. func fixedRosterRevision(n uint64) func(context.Context) (uint64, error) { return func(context.Context) (uint64, error) { return n, nil } }