carlos / platform Public

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

Plain git — no account needed to clone.

Download

Download this file

1package hostops
2
3import (
4 "context"
5 "crypto/ed25519"
6 "errors"
7 "strings"
8 "sync"
9 "testing"
10 "time"
11
12 "github.com/carlosframework/platform/internal/hostclaim"
13 "github.com/carlosframework/platform/internal/release"
14)
15
16// racingStore loses the next armed admission CAS: just before it, it runs
17// race (a concurrent renewal, settle or phase write through the same raw
18// store), so the armed Put's ETag is stale and fails with ErrCASConflict.
19type racingStore struct {
20 *release.DirStore
21 mu sync.Mutex
22 race func()
23 fired int
24}
25
26func (s *racingStore) arm(race func()) {
27 s.mu.Lock()
28 s.race = race
29 s.mu.Unlock()
30}
31
32func (s *racingStore) Put(ctx context.Context, key string, body []byte, tag string) (string, error) {
33 s.mu.Lock()
34 race := s.race
35 if strings.HasSuffix(key, "/admission.json") && race != nil {
36 s.race = nil
37 s.fired++
38 } else {
39 race = nil
40 }
41 s.mu.Unlock()
42 if race != nil {
43 race()
44 }
45 return s.DirStore.Put(ctx, key, body, tag)
46}
47
48func racingWriter(t *testing.T) (*ControlWriter, *racingStore, func() time.Time) {
49 t.Helper()
50 raw := &racingStore{DirStore: release.NewDirStore(t.TempDir())}
51 pub, _, err := ed25519.GenerateKey(nil)
52 if err != nil {
53 t.Fatal(err)
54 }
55 now := time.Date(2026, 10, 7, 12, 0, 0, 0, time.UTC)
56 clock := func() time.Time { return now }
57 w, err := NewControlWriter(raw, pub, clock)
58 if err != nil {
59 t.Fatal(err)
60 }
61 return w, raw, clock
62}
63
64func startRegistration(t *testing.T, w *ControlWriter, clock func() time.Time, id string) *StartHandle {
65 t.Helper()
66 r := Registration{Kind: "create", Host: "x.example", Owner: testOwner, AppID: "app", Epoch: 1, Claim: SavedClaim{Absent: true}}
67 h, err := w.RegisterStart(t.Context(), "bab", "demo", "app", id, r, clock())
68 if err != nil {
69 t.Fatal(err)
70 }
71 return h
72}
73
74// Review P1-3: a routine renewal racing the settle CAS must not leave a known
75// outcome "issued" forever.
76func TestSettleSurvivesRenewalCAS(t *testing.T) {
77 ctx := t.Context()
78 w, raw, clock := racingWriter(t)
79 seedAdmission(t, raw.DirStore, "bab", "demo", openAdmission("app"))
80 h := startRegistration(t, w, clock, "reg")
81 _, settle, err := h.Runner(clock).Issue(ctx, Effect{ID: "eff", Resource: "store:x", Action: "grant"})
82 if err != nil {
83 t.Fatal(err)
84 }
85 raw.arm(func() {
86 if err := h.Renew(ctx, clock()); err != nil {
87 t.Errorf("renew: %v", err)
88 }
89 })
90 if err = settle("ok", nil); err != nil {
91 t.Fatalf("settle lost to a renewal: %v", err)
92 }
93 if raw.fired != 1 {
94 t.Fatalf("race not exercised: %d", raw.fired)
95 }
96 a, err := ReadAdmission(ctx, raw.DirStore, "bab", "demo", "app")
97 if err != nil {
98 t.Fatal(err)
99 }
100 if e := a.Value.Effects["eff"]; e.State != "complete" || e.ResultReference != "ok" {
101 t.Fatalf("effect after settle: %+v", e)
102 }
103}
104
105// Review P1-3: Renew and Bind themselves retry a lost CAS.
106func TestRenewAndBindRetryLostCAS(t *testing.T) {
107 ctx := t.Context()
108 w, raw, clock := racingWriter(t)
109 seedAdmission(t, raw.DirStore, "bab", "demo", openAdmission("app"))
110 one := startRegistration(t, w, clock, "one")
111 two := startRegistration(t, w, clock, "two")
112 raw.arm(func() { _ = two.Renew(ctx, clock()) })
113 if err := one.Renew(ctx, clock()); err != nil {
114 t.Fatalf("renew lost to a sibling renewal: %v", err)
115 }
116 raw.arm(func() { _ = two.Renew(ctx, clock()) })
117 if err := one.Bind(ctx, clock(), "inc-1", clock()); err != nil {
118 t.Fatalf("bind lost to a sibling renewal: %v", err)
119 }
120}
121
122// Review P1-3: the prepare and issue CASes run before the call, so they
123// replay after a lost race; the effect ends issued exactly once.
124func TestIssueReplaysPreparedAcrossCAS(t *testing.T) {
125 ctx := t.Context()
126 w, raw, clock := racingWriter(t)
127 seedAdmission(t, raw.DirStore, "bab", "demo", openAdmission("app"))
128 other := startRegistration(t, w, clock, "other")
129 h := startRegistration(t, w, clock, "reg")
130 raw.arm(func() { _ = other.Renew(ctx, clock()) })
131 _, settle, err := h.Runner(clock).Issue(ctx, Effect{ID: "eff", Resource: "store:x", Action: "grant"})
132 if err != nil {
133 t.Fatalf("issue lost to a renewal: %v", err)
134 }
135 a, _ := ReadAdmission(ctx, raw.DirStore, "bab", "demo", "app")
136 if a.Value.Effects["eff"].State != "issued" {
137 t.Fatalf("effect: %+v", a.Value.Effects["eff"])
138 }
139 if err = settle("ok", nil); err != nil {
140 t.Fatal(err)
141 }
142}
143
144// Review P1-3: SetPhase and the reopen retry; a retried reopen bumps the
145// epoch exactly once.
146func TestSetPhaseAndFinishRetryLostCAS(t *testing.T) {
147 ctx := t.Context()
148 w, raw, clock := racingWriter(t)
149 seedAdmission(t, raw.DirStore, "bab", "demo", openAdmission("app"))
150 h, err := w.BeginExclusive(ctx, "bab", "demo", "app", AppOperation{ID: "op", Kind: OpAppMutation, AppID: "app"}, testOwner)
151 if err != nil {
152 t.Fatal(err)
153 }
154 _, settle, err := h.Runner(clock).Issue(ctx, Effect{ID: "op", Resource: "app-mutation", Action: "test"})
155 if err != nil {
156 t.Fatal(err)
157 }
158 if err = settle("joined", nil); err != nil {
159 t.Fatal(err)
160 }
161 bump := func() {
162 before, err := ReadAdmission(ctx, raw.DirStore, "bab", "demo", "app")
163 if err != nil {
164 t.Error(err)
165 return
166 }
167 // An unrelated same-op intent write (another worker's phase journal).
168 if _, err = w.updateAdmission(ctx, before, func(v *AppAdmission) error { v.Op.Intent["note"] = "x"; return nil }); err != nil {
169 t.Error(err)
170 }
171 }
172 raw.arm(bump)
173 if err = h.SetPhase(ctx, "", "done", map[string]string{"k": "v"}); err != nil {
174 t.Fatalf("SetPhase lost: %v", err)
175 }
176 raw.arm(bump)
177 if err = h.FinishMutation(ctx); err != nil {
178 t.Fatalf("finish lost: %v", err)
179 }
180 a, _ := ReadAdmission(ctx, raw.DirStore, "bab", "demo", "app")
181 if a.Value.State != "open" || a.Value.Epoch != 2 {
182 t.Fatalf("reopen: state %s epoch %d", a.Value.State, a.Value.Epoch)
183 }
184}
185
186func restoreFixture(t *testing.T) (*ControlWriter, *release.DirStore, func() time.Time, *OperationHandle, string) {
187 t.Helper()
188 ctx := t.Context()
189 w, raw, clock := testWriter(t)
190 seedAdmission(t, raw, "bab", "demo", openAdmission("app"))
191 if err := release.TrashApp(ctx, raw, "bab", "demo", "owner", clock()); err != nil {
192 t.Fatal(err)
193 }
194 _, tag, err := raw.Get(ctx, release.AppTombstoneKey("bab", "demo"))
195 if err != nil {
196 t.Fatal(err)
197 }
198 h, err := w.BeginExclusive(ctx, "bab", "demo", "app", AppOperation{ID: "op", Kind: OpAppMutation, AppID: "app", Intent: map[string]string{"action": "restore"}}, testOwner)
199 if err != nil {
200 t.Fatal(err)
201 }
202 if err = h.PrepareRestore(ctx, tag, clock().Unix()); err != nil {
203 t.Fatal(err)
204 }
205 return w, raw, clock, h, tag
206}
207
208// SQL-fix order BeginExclusive -> PrepareRestore -> Issue: a crash after
209// the receipt and before Issue is recovered from the receipt alone, the
210// epoch advances once, and a late Issue gets the recorded result, never a
211// permit.
212func TestRecoverPreparedReceiptWithNoEffect(t *testing.T) {
213 ctx := t.Context()
214 w, raw, clock, h, _ := restoreFixture(t)
215 if err := w.RecoverMutation(ctx, "bab", "demo", "app", "op"); err != nil {
216 t.Fatal(err)
217 }
218 a, err := ReadAdmission(ctx, raw, "bab", "demo", "app")
219 if err != nil {
220 t.Fatal(err)
221 }
222 e := a.Value.Effects["op"]
223 if a.Value.State != "open" || a.Value.Op != nil || a.Value.Epoch != 2 || a.Value.LastRestore.State != "complete" || e.State != "complete" || e.ResultReference != "restore-reconciled" {
224 t.Fatalf("not recovered: %+v", a.Value)
225 }
226 if _, _, err = raw.Get(ctx, release.AppTombstoneKey("bab", "demo")); err == nil {
227 t.Fatal("tombstone survived")
228 }
229 _, _, err = h.Runner(clock).Issue(ctx, Effect{ID: "op", Resource: OpAppMutation, Action: "restore", Inputs: map[string]string{"tombstone_etag": "x"}})
230 if result, ok := EffectResult(err); !ok || result != "restore-reconciled" {
231 t.Fatalf("late issue got a permit or the wrong refusal: %v", err)
232 }
233 // Idempotent: the issuer's own recovery call now succeeds.
234 if err = w.RecoverMutation(ctx, "bab", "demo", "app", "op"); err != nil {
235 t.Fatalf("second recovery: %v", err)
236 }
237 a, _ = ReadAdmission(ctx, raw, "bab", "demo", "app")
238 if a.Value.Epoch != 2 {
239 t.Fatalf("epoch bumped twice: %d", a.Value.Epoch)
240 }
241}
242
243// Recovery's decision on an issued restore effect stands: the issuer's late
244// settle neither overwrites it nor fails.
245func TestSettleNeverOverwritesRecoveredOutcome(t *testing.T) {
246 ctx := t.Context()
247 w, raw, clock, h, _ := restoreFixture(t)
248 _, settle, err := h.Runner(clock).Issue(ctx, Effect{ID: "op", Resource: OpAppMutation, Action: "restore"})
249 if err != nil {
250 t.Fatal(err)
251 }
252 if err = w.RecoverMutation(ctx, "bab", "demo", "app", "op"); err != nil {
253 t.Fatal(err)
254 }
255 callErr := errors.New("lost response")
256 if err = settle("", callErr); !errors.Is(err, callErr) {
257 t.Fatalf("late settle: %v", err)
258 }
259 a, _ := ReadAdmission(ctx, raw, "bab", "demo", "app")
260 if e := a.Value.Effects["op"]; e.State != "complete" || e.ResultReference != "restore-reconciled" {
261 t.Fatalf("recovered outcome overwritten: %+v", e)
262 }
263}
264
265func mailMember(t *testing.T, w *ControlWriter, clock func() time.Time) (*MailGateLease, *OperationHandle, func(string, error) error) {
266 t.Helper()
267 ctx := t.Context()
268 l, err := w.AcquireMailGate(ctx, MailGateOwner{Console: "c", Enrollment: "e", Controller: "k"}, MailGateScope{})
269 if err != nil {
270 t.Fatal(err)
271 }
272 if err = w.RecordMailGateMember(ctx, l, MailGateMember{Account: "bab", App: "demo", AppID: "app", Operation: "op", Effect: "op"}); err != nil {
273 t.Fatal(err)
274 }
275 h, err := w.BeginExclusive(ctx, "bab", "demo", "app", AppOperation{ID: "op", Kind: OpAppMutation, AppID: "app", Intent: map[string]string{"action": "mail", "gate": l.ID}}, testOwner)
276 if err != nil {
277 t.Fatal(err)
278 }
279 _, settle, err := h.Runner(clock).Issue(ctx, Effect{ID: "op", Resource: "mail-gate:" + l.ID, Action: "mail", Inputs: map[string]string{"gate": l.ID}})
280 if err != nil {
281 t.Fatal(err)
282 }
283 return l, h, settle
284}
285
286// Review P1-2: a mail member whose effect completed is recovered after a
287// crash, but only once the gate no longer holds its gate ID.
288func TestMailMutationRecoversOnlyAfterGateRelease(t *testing.T) {
289 ctx := t.Context()
290 w, raw, clock := testWriter(t)
291 seedAdmission(t, raw, "bab", "demo", openAdmission("app"))
292 l, _, settle := mailMember(t, w, clock)
293 if err := settle("joined", nil); err != nil {
294 t.Fatal(err)
295 }
296 // Crash before Release: the gate still holds this gate ID.
297 if err := w.RecoverMutation(ctx, "bab", "demo", "app", "op"); !errors.Is(err, ErrPending) {
298 t.Fatalf("reopened while the gate is held: %v", err)
299 }
300 if err := w.ReleaseMailGate(ctx, l); err != nil {
301 t.Fatal(err)
302 }
303 if err := w.RecoverMutation(ctx, "bab", "demo", "app", "op"); err != nil {
304 t.Fatalf("complete effect + free gate not recovered: %v", err)
305 }
306 a, _ := ReadAdmission(ctx, raw, "bab", "demo", "app")
307 if a.Value.State != "open" || a.Value.Epoch != 2 {
308 t.Fatalf("not reopened: %+v", a.Value)
309 }
310}
311
312// Review P1-2: an uncertain mail effect keeps gate and op until the fenced
313// reconciler settles it from readback; the old lease is fenced; the op then
314// recovers.
315func TestReconcileMailGateReadbackDoesNotProveQuiescence(t *testing.T) {
316 ctx := t.Context()
317 w, raw, clock := testWriter(t)
318 seedAdmission(t, raw, "bab", "demo", openAdmission("app"))
319 l, _, settle := mailMember(t, w, clock)
320 callErr := errors.New("timeout")
321 _ = settle(MailAttemptsResult([]MailAttempt{{Surface: "store", Method: "Put", Key: "accounts/bab/email/domains/example.com.json"}}), callErr)
322 if _, err := w.ReconcileMailGate(ctx, l.ID, 0, nil); !errors.Is(err, ErrMailUnproven) {
323 t.Fatalf("uncertain effect reopened: %v", err)
324 }
325 if err := w.RecoverMutation(ctx, "bab", "demo", "app", "op"); !errors.Is(err, ErrPending) {
326 t.Fatalf("op reopened: %v", err)
327 }
328 a, _ := ReadAdmission(ctx, raw, "bab", "demo", "app")
329 if a.Value.Effects["op"].State != "uncertain" || a.Value.Op == nil {
330 t.Fatal("durable ownership was lost")
331 }
332}
333
334// Review P1-2: no readback authority (DNS) or a still-issued effect refuses;
335// gate, op and effect all stay held.
336func TestReconcileMailGateRefusesUnproven(t *testing.T) {
337 for _, tc := range []string{"dns", "issued"} {
338 t.Run(tc, func(t *testing.T) {
339 ctx := t.Context()
340 w, raw, clock := testWriter(t)
341 seedAdmission(t, raw, "bab", "demo", openAdmission("app"))
342 l, _, settle := mailMember(t, w, clock)
343 want := "issued"
344 if tc == "dns" {
345 _ = settle(MailAttemptsResult([]MailAttempt{{Surface: "dns", Method: "EnsureRecord", Name: "x.example.com|TXT"}}), errors.New("timeout"))
346 want = "uncertain"
347 }
348 if _, err := w.ReconcileMailGate(ctx, l.ID, 0, nil); !errors.Is(err, ErrMailUnproven) {
349 t.Fatalf("unproven reconciled: %v", err)
350 }
351 g, _, _ := w.readMailGate(ctx)
352 a, _ := ReadAdmission(ctx, raw, "bab", "demo", "app")
353 if g.State != "held" || g.ID != l.ID || a.Value.Effects["op"].State != want || a.Value.Op == nil {
354 t.Fatalf("released something: gate %+v effect %+v", g, a.Value.Effects["op"])
355 }
356 if err := w.RecoverMutation(ctx, "bab", "demo", "app", "op"); !errors.Is(err, ErrPending) {
357 t.Fatalf("reopened: %v", err)
358 }
359 })
360 }
361}
362
363// Review P1-1 (v2 residue): an app grant can neither replace a foreign
364// holder by naming its own op in the new claim, nor overwrite a foreign op
365// on a free claim.
366func TestAppGrantCannotTakeForeignClaim(t *testing.T) {
367 g := lifecycleGrant{kind: "app", account: "bab", app: "demo", appID: "app", operation: "mine"}
368 foreign := &hostclaim.Claim{Host: "x.example", Gen: 4, Holder: &hostclaim.Holder{Account: "zed", App: "web", Incarnation: "z1"}, Op: hostclaim.Op{Kind: hostclaim.KindServing}}
369 steal := &hostclaim.Claim{Host: "x.example", Gen: 5, Holder: &hostclaim.Holder{Account: "bab", App: "demo", Incarnation: "b1"}, Op: hostclaim.Op{ID: "mine"}}
370 if appGrantAllows(g, foreign, steal) {
371 t.Fatal("foreign holder replaced by naming the grant's op")
372 }
373 evict := &hostclaim.Claim{Host: "x.example", Gen: 5, Op: hostclaim.Op{ID: "mine", Kind: hostclaim.KindPurging}}
374 if appGrantAllows(g, foreign, evict) {
375 t.Fatal("foreign holder removed")
376 }
377 fenced := &hostclaim.Claim{Host: "x.example", Gen: 4, Op: hostclaim.Op{ID: "theirs", Kind: hostclaim.KindPurging}}
378 if appGrantAllows(g, fenced, evict) {
379 t.Fatal("foreign purge fence overwritten")
380 }
381 free := &hostclaim.Claim{Host: "x.example", Gen: 4}
382 if !appGrantAllows(g, free, evict) {
383 t.Fatal("own fence on a free claim refused")
384 }
385 if claimTransitionShape("x.example", free, &hostclaim.Claim{Host: "x.example", Gen: 9}) || claimTransitionShape("y.example", nil, &hostclaim.Claim{Host: "x.example", Gen: 1}) {
386 t.Fatal("gen skip or host mismatch admitted")
387 }
388}
389
390// Review P1-1 (v2 residue): a lifecycle grant dies with its registration.
391func TestLifecycleGrantDiesWithRegistration(t *testing.T) {
392 ctx := t.Context()
393 w, raw, clock := testWriter(t)
394 seedAdmission(t, raw, "bab", "demo", openAdmission("app"))
395 h := startRegistration(t, w, clock, "reg")
396 grants, _ := h.Lifecycle(ctx).Value(lifecycleGrantKey{}).([]lifecycleGrant)
397 if len(grants) != 1 {
398 t.Fatal("no grant")
399 }
400 if live, err := grantLive(ctx, raw, grants[0]); err != nil || !live {
401 t.Fatalf("live registration's grant refused: %v", err)
402 }
403 before, _ := ReadAdmission(ctx, raw, "bab", "demo", "app")
404 if _, err := w.updateAdmission(ctx, before, func(v *AppAdmission) error { delete(v.Registrations, "reg"); return nil }); err != nil {
405 t.Fatal(err)
406 }
407 if live, err := grantLive(ctx, raw, grants[0]); err != nil || live {
408 t.Fatalf("drained registration's grant still live: %v", err)
409 }
410}
411