carlos / platform Public

Clone
git clone https://amadan.net/carlos/platform

Plain git — no account needed to clone.

Download

Download this file

1//go:build unix
2
3package hostguard
4
5import (
6 "context"
7 "errors"
8 "testing"
9 "time"
10
11 "github.com/carlosframework/platform/internal/hostclaim"
12)
13
14func fenceFixture(t *testing.T) (*guardFixture, FenceTarget, Captured) {
15 t.Helper()
16 f := newGuardFixture(t)
17 f.obs.Claim.Holder.Session = 7
18 f.obs.Claim.Op.Kind = hostclaim.KindLanding
19 f.obs.Claim.Op.ID = "operation-1"
20 f.obs.Claim.Op.Phase = "probing"
21 if err := f.g.Activate(t.Context(), f.obs.Host, "uk", nil); err != nil {
22 t.Fatal(err)
23 }
24 captured, err := Capture(f.g.DataDir, f.obs.Host)
25 if err != nil {
26 t.Fatal(err)
27 }
28 target := FenceTarget{FenceID: "fence-1", Host: f.obs.Host, Incarnation: "inc", Stamp: 1, Session: 7,
29 Op: "operation-1", Phase: "probing", Box: "uk", Enrollment: "enrollment-1",
30 BootID: "boot-1", ControllerID: "controller-1", ClaimETag: "one", DirectoryRevision: "directory-9"}
31 return f, target, captured
32}
33
34func TestBeginFenceDeniesOnlyExactCurrentAuthority(t *testing.T) {
35 f, target, captured := fenceFixture(t)
36 bad := target
37 bad.ClaimETag = "stale"
38 if _, err := f.g.BeginFence(t.Context(), bad); !errors.Is(err, ErrFenceTarget) {
39 t.Fatalf("stale begin = %v", err)
40 }
41 if _, err := f.g.BeginFence(t.Context(), target); err != nil {
42 t.Fatal(err)
43 }
44 if _, err := f.g.BeginFence(t.Context(), target); err != nil { // exact replay
45 t.Fatal(err)
46 }
47 retry := target
48 retry.FenceID = "new-id-must-not-replace-durable-id"
49 got, err := f.g.BeginFence(t.Context(), retry)
50 if err != nil || got != target {
51 t.Fatalf("begin retry = %+v, %v; want original %+v", got, err, target)
52 }
53 called := false
54 err = f.g.With(t.Context(), target.Host, captured, allow, func() error { called = true; return nil })
55 if !errors.Is(err, ErrFenced) || called {
56 t.Fatalf("guard under fence = %v, called=%v", err, called)
57 }
58 f.obs.Claim.Op.Phase = "next-phase"
59 if err := f.g.With(t.Context(), target.Host, captured, allow, func() error { called = true; return nil }); !errors.Is(err, ErrFenced) {
60 t.Fatalf("phase change bypassed fence: %v", err)
61 }
62
63 // A later writer session is a different authority tuple. The old deny
64 // remains durable for cleanup but cannot deny the successor session.
65 f.obs.Claim.Holder.Session = 8
66 if err := f.g.With(t.Context(), target.Host, captured, allow, func() error { called = true; return nil }); err != nil || !called {
67 t.Fatalf("new session guard = %v, called=%v", err, called)
68 }
69}
70
71func TestBeginFenceCannotOverwriteUnsettledTarget(t *testing.T) {
72 f, first, _ := fenceFixture(t)
73 if _, err := f.g.BeginFence(t.Context(), first); err != nil {
74 t.Fatal(err)
75 }
76 second := first
77 second.FenceID = "fence-2"
78 second.Phase = "next-phase"
79 second.ClaimETag = "two"
80 second.DirectoryRevision = "directory-10"
81 f.obs.ETag = "two"
82 f.obs.Claim.Op.Phase = "next-phase"
83 if _, err := f.g.BeginFence(t.Context(), second); !errors.Is(err, ErrFenceActive) {
84 t.Fatalf("unsettled overwrite = %v", err)
85 }
86 at := time.Date(2026, 10, 6, 14, 30, 0, 0, time.UTC)
87 if err := f.g.CompleteFence(t.Context(), first, []byte("first receipt"), at, func(context.Context, FenceTarget, []byte, time.Time) error { return nil }); err != nil {
88 t.Fatal(err)
89 }
90 if _, err := f.g.BeginFence(t.Context(), second); err != nil {
91 t.Fatalf("completed predecessor blocked successor fence: %v", err)
92 }
93 if err := f.g.CleanupWith(t.Context(), first, func(context.Context, FenceReceipt) error { return nil }, func() error { return nil }); err != nil {
94 t.Fatal(err)
95 }
96 r, err := readRecord(f.g.DataDir, first.Host)
97 if err != nil || r.ActiveFence == nil || *r.ActiveFence != second || len(r.FenceJournal) != 1 {
98 t.Fatalf("predecessor cleanup damaged successor fence: %+v, %v", r, err)
99 }
100}
101
102func TestCompleteFenceVerificationFailureAndImmutableReplay(t *testing.T) {
103 f, target, captured := fenceFixture(t)
104 if _, err := f.g.BeginFence(t.Context(), target); err != nil {
105 t.Fatal(err)
106 }
107 failure := errors.New("root rejected evidence")
108 at := time.Date(2026, 10, 6, 14, 0, 0, 0, time.UTC)
109 if err := f.g.CompleteFence(t.Context(), target, []byte("receipt"), at, func(context.Context, FenceTarget, []byte, time.Time) error { return failure }); !errors.Is(err, failure) {
110 t.Fatalf("failed verification = %v", err)
111 }
112 r, err := readRecord(f.g.DataDir, target.Host)
113 if err != nil || len(r.FenceJournal) != 0 || r.ActiveFence == nil {
114 t.Fatalf("failed verification persisted evidence: %+v, %v", r, err)
115 }
116 verify := func(ctx context.Context, got FenceTarget, _ []byte, _ time.Time) error {
117 // Verification is outside the flock: reacquiring it completes rather
118 // than deadlocking. The installed deny is visible to the nested guard.
119 return f.g.With(ctx, got.Host, captured, allow, func() error { return nil })
120 }
121 if err := f.g.CompleteFence(t.Context(), target, []byte("receipt"), at, verify); !errors.Is(err, ErrFenced) {
122 t.Fatalf("nested verifier = %v", err)
123 }
124 verify = func(context.Context, FenceTarget, []byte, time.Time) error { return nil }
125 if err := f.g.CompleteFence(t.Context(), target, []byte("receipt"), at, verify); err != nil {
126 t.Fatal(err)
127 }
128 if err := f.g.CompleteFence(t.Context(), target, []byte("receipt"), at, verify); err != nil {
129 t.Fatalf("exact replay = %v", err)
130 }
131 if err := f.g.CompleteFence(t.Context(), target, []byte("changed"), at, verify); !errors.Is(err, ErrReceiptChanged) {
132 t.Fatalf("changed replay = %v", err)
133 }
134}
135
136func TestCleanupUsesJournalDuringControlOutage(t *testing.T) {
137 f, target, _ := fenceFixture(t)
138 if _, err := f.g.BeginFence(t.Context(), target); err != nil {
139 t.Fatal(err)
140 }
141 at := time.Date(2026, 10, 6, 15, 0, 0, 0, time.UTC)
142 if err := f.g.CompleteFence(t.Context(), target, []byte{0, 1, 2, 255}, at, func(context.Context, FenceTarget, []byte, time.Time) error { return nil }); err != nil {
143 t.Fatal(err)
144 }
145 f.err = errors.New("control store unavailable")
146 verified, cleaned := false, false
147 err := f.g.CleanupWith(t.Context(), target, func(_ context.Context, receipt FenceReceipt) error {
148 verified = string(receipt.Bytes) == string([]byte{0, 1, 2, 255}) && receipt.EvidenceAt.Equal(at)
149 return nil
150 }, func() error { cleaned = true; return nil })
151 if err != nil || !verified || !cleaned {
152 t.Fatalf("cleanup = %v verified=%v cleaned=%v", err, verified, cleaned)
153 }
154 r, err := readRecord(f.g.DataDir, target.Host)
155 if err != nil || r.ActiveFence == nil || len(r.FenceJournal) != 1 {
156 t.Fatalf("cleaned record = %+v, %v", r, err)
157 }
158 f.err = nil
159 called := false
160 if err := f.g.With(t.Context(), target.Host, hostguardCaptured(target), allow, func() error { called = true; return nil }); !errors.Is(err, ErrFenced) || called {
161 t.Fatalf("cleanup reopened fenced session: %v called=%v", err, called)
162 }
163}
164
165func TestCleanupOldFencePreservesSuccessor(t *testing.T) {
166 f, target, _ := fenceFixture(t)
167 if _, err := f.g.BeginFence(t.Context(), target); err != nil {
168 t.Fatal(err)
169 }
170 at := time.Date(2026, 10, 6, 16, 0, 0, 0, time.UTC)
171 if err := f.g.CompleteFence(t.Context(), target, []byte("root receipt"), at, func(context.Context, FenceTarget, []byte, time.Time) error { return nil }); err != nil {
172 t.Fatal(err)
173 }
174 f.obs.ETag = "two"
175 f.obs.Claim.Holder.Incarnation = "successor"
176 f.obs.Claim.Holder.Stamp = 2
177 f.obs.Claim.Holder.Session = 8
178 f.obs.Claim.Op = hostclaim.Op{Kind: hostclaim.KindServing, ID: "operation-2", Phase: "serving", Exec: "uk"}
179 if err := f.g.Activate(t.Context(), target.Host, "uk", nil); err != nil {
180 t.Fatal(err)
181 }
182 if err := f.g.CleanupWith(t.Context(), target, func(context.Context, FenceReceipt) error { return nil }, func() error { return nil }); err != nil {
183 t.Fatal(err)
184 }
185 r, err := readRecord(f.g.DataDir, target.Host)
186 if err != nil || r.Incarnation != "successor" || r.ActiveStamp != 2 || r.ActiveFence == nil || len(r.FenceJournal) != 1 {
187 t.Fatalf("successor after cleanup = %+v, %v", r, err)
188 }
189}
190
191func hostguardCaptured(target FenceTarget) Captured {
192 return Captured{Incarnation: target.Incarnation, Stamp: target.Stamp}
193}
194
195func TestBeginFenceResumesAfterUnrelatedClaimCAS(t *testing.T) {
196 f, target, _ := fenceFixture(t)
197 target.RosterRevision = 9
198 if _, err := f.g.BeginFence(t.Context(), target); err != nil {
199 t.Fatal(err)
200 }
201 f.obs.ETag = "bookkeeping"
202 if _, err := f.g.BeginFence(t.Context(), target); !errors.Is(err, ErrFenceTarget) {
203 t.Fatalf("stale request: %v", err)
204 }
205 fresh := target
206 fresh.ClaimETag = f.obs.ETag
207 fresh.FenceID = "retry"
208 // Roster binding B2: unrelated roster writes between the request and its
209 // retry move the receipt numeral but not the binding. Retry authority
210 // ignores the numeral, and both lookups return the persisted target with
211 // its ORIGINAL numeral.
212 fresh.RosterRevision = target.RosterRevision + 4
213 got, err := f.g.BeginFence(t.Context(), fresh)
214 if err != nil || got != target || got.RosterRevision != 9 {
215 t.Fatalf("resume=%+v %v", got, err)
216 }
217 existing, err := f.g.ExistingFence(t.Context(), fresh)
218 if err != nil || existing != target || existing.RosterRevision != 9 {
219 t.Fatalf("existing=%+v %v", existing, err)
220 }
221 // Exact stored-target comparisons stay exact: the retry's numeral names no
222 // persisted fence.
223 if err := f.g.ResumeFence(t.Context(), fresh); !errors.Is(err, ErrFenceTarget) {
224 t.Fatalf("resume with a changed numeral: %v", err)
225 }
226 at := time.Unix(1, 0).UTC()
227 if err := f.g.CompleteFence(t.Context(), got, []byte("original receipt"), at, func(context.Context, FenceTarget, []byte, time.Time) error { return nil }); err != nil {
228 t.Fatal(err)
229 }
230 receipt, err := f.g.FenceReceiptFor(t.Context(), target)
231 if err != nil || string(receipt.Bytes) != "original receipt" || receipt.Target != target {
232 t.Fatalf("receipt=%+v %v", receipt, err)
233 }
234 if _, err := f.g.FenceReceiptFor(t.Context(), fresh); !errors.Is(err, ErrNoFenceReceipt) {
235 t.Fatalf("receipt for a changed numeral: %v", err)
236 }
237 for _, change := range []func(*FenceTarget){func(v *FenceTarget) { v.Session++ }, func(v *FenceTarget) { v.Op = "another" }, func(v *FenceTarget) { v.ControllerID = "another" }, func(v *FenceTarget) { v.DirectoryRevision = "another-binding" }} {
238 g, old, _ := fenceFixture(t)
239 old.RosterRevision = 9
240 if _, err := g.g.BeginFence(t.Context(), old); err != nil {
241 t.Fatal(err)
242 }
243 fresh := old
244 change(&fresh)
245 fresh.RosterRevision++
246 if _, err := g.g.BeginFence(t.Context(), fresh); err == nil {
247 t.Fatal("changed authority resumed")
248 }
249 if _, err := g.g.ExistingFence(t.Context(), fresh); !errors.Is(err, ErrFenceTarget) {
250 t.Fatalf("changed authority found: %v", err)
251 }
252 }
253}
254