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 "crypto/rand"
7 "encoding/json"
8 "errors"
9 "strings"
10 "testing"
11 "time"
12
13 "github.com/carlosframework/platform/internal/hostactors"
14 "github.com/carlosframework/platform/internal/hostclaim"
15 "github.com/carlosframework/platform/internal/provision"
16 "github.com/carlosframework/platform/internal/release"
17)
18
19const moveStartID = "mv_abcdefghijklmnopqrstuvwxyz"
20
21// moveStartStore counts claim CASes and can run a hook on the first record
22// read, which BeginClaimMove makes after its admission-free prechecks.
23type moveStartStore struct {
24 release.Store
25 claimKey, recordKey string
26 claimPuts int
27 onRecordRead func()
28}
29
30func (s *moveStartStore) Get(ctx context.Context, key string) ([]byte, string, error) {
31 if key == s.recordKey && s.onRecordRead != nil {
32 f := s.onRecordRead
33 s.onRecordRead = nil
34 f()
35 }
36 return s.Store.Get(ctx, key)
37}
38
39func (s *moveStartStore) Put(ctx context.Context, key string, body []byte, match string) (string, error) {
40 if key == s.claimKey {
41 s.claimPuts++
42 }
43 return s.Store.Put(ctx, key, body, match)
44}
45
46type moveStartFixture struct {
47 raw *moveStartStore
48 w *ControlWriter
49 authority ProductionAuthority
50 host, inc string
51 now time.Time
52 enIE string
53 enUK string
54 pub ed25519.PublicKey
55}
56
57// productionData is ConfigureClaimRuntime's data handle over f.raw: the
58// mutation boundary over the conditional record store, checked by the
59// production checker with the real ControlWriter over the same raw store.
60func (f *moveStartFixture) productionData(t *testing.T) release.Store {
61 t.Helper()
62 records, err := NewConditionalRecordStore(f.raw, f.pub)
63 if err != nil {
64 t.Fatal(err)
65 }
66 check, err := NewProductionMutationCheck(f.raw, f.w)
67 if err != nil {
68 t.Fatal(err)
69 }
70 data, err := NewMutationBoundary(true, records, check)
71 if err != nil {
72 t.Fatal(err)
73 }
74 return data
75}
76
77func moveRosterBox(id, region, enrollment, instance string) hostactors.Box {
78 return hostactors.Box{Enrollment: enrollment, Namespace: "platform", BoxID: id, Label: id, Region: region, Pool: "default", Cloud: hostactors.Cloud{Provider: "aws", Account: "123", Region: region, InstanceID: instance}, EvidenceSHA256: strings.Repeat("c", 64), State: "active"}
79}
80
81func newMoveStartFixture(t *testing.T, mutateRoster func(*hostactors.Roster)) *moveStartFixture {
82 t.Helper()
83 ctx := context.Background()
84 now := time.Date(2026, 10, 7, 12, 0, 0, 0, time.UTC)
85 host := "shop.example.com"
86 f := &moveStartFixture{host: host, now: now, enIE: strings.Repeat("a", 32), enUK: strings.Repeat("b", 32)}
87 claimKey, _ := hostclaim.Key(host)
88 f.raw = &moveStartStore{Store: release.NewDirStore(t.TempDir()), claimKey: claimKey, recordKey: provision.RecordKey("bab", "app", host)}
89 pub, key, err := ed25519.GenerateKey(rand.Reader)
90 if err != nil {
91 t.Fatal(err)
92 }
93 f.pub = pub
94 roster := hostactors.Roster{V: 1, DeploymentBucket: "bucket", Revision: 3, Boxes: map[string]hostactors.Box{
95 "ie-box": moveRosterBox("ie-box", "ie", f.enIE, "i-ie"),
96 "uk-box": moveRosterBox("uk-box", "uk", f.enUK, "i-uk"),
97 }, Consoles: map[string]hostactors.Console{}, Holds: map[string]hostactors.Reservation{}, Activations: map[string]hostactors.Reservation{}}
98 if mutateRoster != nil {
99 mutateRoster(&roster)
100 }
101 signed, err := release.SignRootEnvelope(release.RootSigner(key), roster)
102 if err != nil {
103 t.Fatal(err)
104 }
105 if _, err = f.raw.Store.Put(ctx, hostactors.Key, signed, ""); err != nil {
106 t.Fatal(err)
107 }
108 if f.inc, err = hostclaim.Incarnation("bab", "app", host, now); err != nil {
109 t.Fatal(err)
110 }
111 rec, _ := json.Marshal(provision.Record{Schema: 1, Host: host, Slug: "app", Account: "bab", App: "app", Channel: "edge", Region: "ie", Acquisition: "acq", CreatedAt: now})
112 if _, err = f.raw.Store.Put(ctx, f.raw.recordKey, rec, ""); err != nil {
113 t.Fatal(err)
114 }
115 c := hostclaim.Claim{V: 2, Host: host, Gen: 4, Holder: &hostclaim.Holder{Family: "instance", Account: "bab", App: "app", Key: f.raw.recordKey, Acquisition: "acq", Incarnation: f.inc, CreatedAt: now, Location: "instances/" + host},
116 Op: hostclaim.Op{Kind: hostclaim.KindServing, Exec: "ie-box"}, Touched: hostclaim.Touched{Complete: true, Regions: []string{"ie"}, Boxes: []string{"ie-box"}}}
117 cb, _ := json.Marshal(c)
118 if _, err = f.raw.Store.Put(ctx, claimKey, cb, ""); err != nil {
119 t.Fatal(err)
120 }
121 admission, _ := json.Marshal(AppAdmission{V: 1, AppID: "canonical", Revision: 1, Epoch: 1, State: "open", Registrations: map[string]Registration{}, Effects: map[string]Effect{}})
122 if _, err = f.raw.Store.Put(ctx, "apps/bab/app/admission.json", admission, ""); err != nil {
123 t.Fatal(err)
124 }
125 clock := func() time.Time { return f.now }
126 if f.w, err = NewControlWriter(f.raw, pub, clock); err != nil {
127 t.Fatal(err)
128 }
129 f.authority = ProductionAuthority{Store: f.raw, Control: f.w, Directory: hostactors.Directory{Store: f.raw, PublicKey: pub, Bucket: "bucket"}, ConsoleID: "console", Enrollment: strings.Repeat("e", 32), Now: clock}
130 return f
131}
132
133func (f *moveStartFixture) read(t *testing.T) hostclaim.Observed {
134 t.Helper()
135 o, err := hostclaim.Read(context.Background(), f.raw, f.host)
136 if err != nil {
137 t.Fatal(err)
138 }
139 return o
140}
141
142func (f *moveStartFixture) begin(h *StartHandle, o hostclaim.Observed, target, to string) (hostclaim.Observed, error) {
143 f.raw.claimPuts = 0
144 return BeginClaimMove(context.Background(), f.raw, f.authority.Directory, h, o, moveStartID, "operator", target, to, f.now)
145}
146
147// BeginClaimMove persists admission_id from the writer-minted exact "move"
148// registration alongside app_id, and both enrollments from the signed
149// roster, in one claim CAS.
150func TestBeginClaimMovePersistsAdmissionAndRosterEnrollment(t *testing.T) {
151 ctx := context.Background()
152 f := newMoveStartFixture(t, nil)
153 o := f.read(t)
154 h, err := f.authority.RegisterMove(ctx, o, "canonical", moveStartID)
155 if err != nil {
156 t.Fatal(err)
157 }
158 moving, err := f.begin(h, o, "uk-box", "uk")
159 if err != nil {
160 t.Fatal(err)
161 }
162 in := moving.Claim.Op.Intent
163 want := map[string]string{"to": "uk", "source": "ie-box", "target": "uk-box", "app_id": "canonical", "admission_id": moveStartID, "source_enrollment": f.enIE, "target_enrollment": f.enUK}
164 for k, v := range want {
165 if in[k] != v {
166 t.Fatalf("intent %s = %q, want %q (%+v)", k, in[k], v, in)
167 }
168 }
169 if len(in) != len(want) || in["source_box"] != "" || in["source_region"] != "" {
170 t.Fatalf("extra intent keys: %+v", in)
171 }
172 if f.raw.claimPuts != 1 || moving.Claim.Op.Kind != hostclaim.KindMoving || moving.Claim.Gen != o.Claim.Gen+1 {
173 t.Fatalf("claim CASes=%d op=%+v", f.raw.claimPuts, moving.Claim.Op)
174 }
175 a, err := ReadAdmission(ctx, f.raw, "bab", "app", "canonical")
176 if err != nil {
177 t.Fatal(err)
178 }
179 reg := a.Value.Registrations[h.ID()]
180 if reg.Kind != RegistrationMove || reg.Host != f.host || reg.Incarnation != f.inc || reg.Claim != SavedObservation(o) {
181 t.Fatalf("registration not the exact move registration: %+v", reg)
182 }
183 // The record start is one issued effect of the same registration and
184 // passes the lifecycle record writer's admission reread.
185 rec, err := StartClaimMoveRecord(ctx, conditionalRecordStore{raw: f.raw}, h, moving, provision.MoveStart{ID: moveStartID, Incarnation: f.inc, From: "ie", To: "uk", Cut: "auto", By: "operator", Now: f.now})
186 if err != nil {
187 t.Fatal(err)
188 }
189 if rec.Move == nil || rec.Move.ID != moveStartID || rec.Move.State != provision.MovePreparing {
190 t.Fatalf("record not started: %+v", rec.Move)
191 }
192 // The start CAS output converts under C7 only with the admission it
193 // carries: the conversion's required keys are present.
194 if in["source"] == "" || in["source_enrollment"] == "" || in["app_id"] == "" || in["admission_id"] == "" {
195 t.Fatal("start output is not convertible")
196 }
197}
198
199func TestBeginClaimMoveRefusesWithoutExactLiveAdmission(t *testing.T) {
200 for _, tc := range []string{"closed-before-cas", "drained-before-cas", "expired", "other-observation", "wrong-kind", "foreign-handle", "nil-handle"} {
201 t.Run(tc, func(t *testing.T) {
202 ctx := context.Background()
203 f := newMoveStartFixture(t, nil)
204 o := f.read(t)
205 var h *StartHandle
206 var err error
207 if tc == "wrong-kind" {
208 // A writer-minted registration under the move id, of another kind.
209 reg := Registration{Kind: "wake", Host: f.host, Owner: RegistrationOwner{Console: "console", Enrollment: strings.Repeat("e", 32), Controller: "controller"}, AppID: "canonical", Epoch: 1, Claim: SavedObservation(o), Incarnation: f.inc, CreatedAt: f.now}
210 h, err = f.w.RegisterStart(ctx, "bab", "app", "canonical", moveStartID, reg, f.now)
211 } else {
212 h, err = f.authority.RegisterMove(ctx, o, "canonical", moveStartID)
213 }
214 if err != nil {
215 t.Fatal(err)
216 }
217 switch tc {
218 case "closed-before-cas":
219 // Closed between the prechecks and the claim CAS: the final
220 // admission reread is inside the transition.
221 f.raw.onRecordRead = func() {
222 if _, err := f.w.BeginExclusive(ctx, "bab", "app", "canonical", AppOperation{ID: "delete", Kind: OpDeleting, AppID: "canonical"}, RegistrationOwner{Console: "console", Enrollment: strings.Repeat("e", 32), Controller: "controller"}); err != nil {
223 t.Error(err)
224 }
225 }
226 case "drained-before-cas":
227 f.raw.onRecordRead = func() { f.now = f.now.Add(RegistrationTTL) }
228 case "expired":
229 f.now = f.now.Add(RegistrationTTL)
230 case "other-observation":
231 // A registration minted for an older claim observation.
232 var err error
233 if o, err = hostclaim.Update(ctx, f.raw.Store, o, func(c *hostclaim.Claim) error { return nil }); err != nil {
234 t.Fatal(err)
235 }
236 case "foreign-handle":
237 h = &StartHandle{w: f.w, account: "bab", app: "app", appID: "canonical", id: h.ID(), owner: RegistrationOwner{Console: "other", Enrollment: strings.Repeat("e", 32), Controller: "forged"}, epoch: 1}
238 case "nil-handle":
239 h = nil
240 }
241 before := f.read(t)
242 if _, err := f.begin(h, o, "uk-box", "uk"); err == nil {
243 t.Fatal("began a move without its exact live admission")
244 }
245 after := f.read(t)
246 if f.raw.claimPuts != 0 || after.ETag != before.ETag {
247 t.Fatalf("refusal wrote the claim: puts=%d", f.raw.claimPuts)
248 }
249 })
250 }
251}
252
253func TestBeginClaimMoveTakesEnrollmentsOnlyFromTheSignedRoster(t *testing.T) {
254 for _, tc := range []string{"target-wrong-region", "source-wrong-region", "target-quarantined", "unknown-target", "same-box", "fenced-source"} {
255 t.Run(tc, func(t *testing.T) {
256 ctx := context.Background()
257 var mutate func(*hostactors.Roster)
258 target, to := "uk-box", "uk"
259 switch tc {
260 case "target-wrong-region":
261 to = "de"
262 case "source-wrong-region":
263 mutate = func(r *hostactors.Roster) {
264 b := r.Boxes["ie-box"]
265 b.Region, b.Cloud.Region = "fr", "fr"
266 r.Boxes["ie-box"] = b
267 }
268 case "target-quarantined":
269 mutate = func(r *hostactors.Roster) {
270 b := r.Boxes["uk-box"]
271 b.State, b.QuarantineRevision, b.LossIDs = "quarantined", 1, []string{"loss"}
272 r.Boxes["uk-box"] = b
273 }
274 case "unknown-target":
275 target = "us-box"
276 case "same-box":
277 target, to = "ie-box", "ie"
278 }
279 f := newMoveStartFixture(t, mutate)
280 o := f.read(t)
281 if tc == "fenced-source" {
282 // Any fence object for the source's enrollment fails Lookup closed.
283 if _, err := f.raw.Store.Put(ctx, "control/box-fences/"+f.enIE+".json", []byte(`{}`), ""); err != nil {
284 t.Fatal(err)
285 }
286 }
287 h, err := f.authority.RegisterMove(ctx, o, "canonical", moveStartID)
288 if err != nil {
289 t.Fatal(err)
290 }
291 if _, err := f.begin(h, o, target, to); err == nil {
292 t.Fatal("accepted")
293 }
294 if f.raw.claimPuts != 0 {
295 t.Fatal("refusal wrote the claim")
296 }
297 })
298 }
299}
300
301// The registration id is the move id, so the settle sweep keeps it past its
302// TTL while the move holds the claim; recovery's admission reread depends on
303// it (moveAdmission).
304func TestClaimMoveRegistrationOutlivesItsTTL(t *testing.T) {
305 ctx := context.Background()
306 f := newMoveStartFixture(t, nil)
307 o := f.read(t)
308 h, err := f.authority.RegisterMove(ctx, o, "canonical", moveStartID)
309 if err != nil {
310 t.Fatal(err)
311 }
312 if _, err = f.begin(h, o, "uk-box", "uk"); err != nil {
313 t.Fatal(err)
314 }
315 f.now = f.now.Add(RegistrationTTL + 2*RegistrationSkew)
316 if err := f.w.SettleRegistration(ctx, "bab", "app", "canonical", moveStartID, f.now); !errors.Is(err, ErrPending) {
317 t.Fatalf("settled a move that still holds the claim: %v", err)
318 }
319 a, err := ReadAdmission(ctx, f.raw, "bab", "app", "canonical")
320 if err != nil {
321 t.Fatal(err)
322 }
323 if _, ok := a.Value.Registrations[moveStartID]; !ok {
324 t.Fatal("move registration drained under a moving claim")
325 }
326 if _, err := f.authority.RegisterMove(ctx, o, "canonical", "not-a-move-id"); !errors.Is(err, ErrIdentity) {
327 t.Fatalf("registered under a non-move id: %v", err)
328 }
329}
330
331// The record start runs only under the registration the claim names.
332func TestStartClaimMoveRecordIsBoundToItsRegistration(t *testing.T) {
333 ctx := context.Background()
334 f := newMoveStartFixture(t, nil)
335 o := f.read(t)
336 h, err := f.authority.RegisterMove(ctx, o, "canonical", moveStartID)
337 if err != nil {
338 t.Fatal(err)
339 }
340 moving, err := f.begin(h, o, "uk-box", "uk")
341 if err != nil {
342 t.Fatal(err)
343 }
344 moving.Claim.Op.Intent["admission_id"] = "other"
345 if _, err := StartClaimMoveRecord(ctx, conditionalRecordStore{raw: f.raw}, h, moving, provision.MoveStart{ID: moveStartID, Incarnation: f.inc, From: "ie", To: "uk", Cut: "auto", Now: f.now}); !errors.Is(err, ErrIdentity) {
346 t.Fatalf("record start under another registration: %v", err)
347 }
348}
349
350// The start CAS and the record start through the production data handle:
351// the claim CAS needs the registration's lifecycle grant and the record Put
352// its issued effect. A caller holding only the data handle (no grant, or a
353// grant but no issued effect) writes neither.
354func TestClaimMoveStartThroughProductionBoundary(t *testing.T) {
355 ctx := context.Background()
356 f := newMoveStartFixture(t, nil)
357 data := f.productionData(t)
358 dir := hostactors.Directory{Store: data, PublicKey: f.pub, Bucket: "bucket"}
359 authority := f.authority
360 authority.Store, authority.Directory = data, dir
361 o := f.read(t)
362 h, err := authority.RegisterMove(ctx, o, "canonical", moveStartID)
363 if err != nil {
364 t.Fatal(err)
365 }
366 recordTag := func() string {
367 t.Helper()
368 _, tag, err := provision.GetRecordETag(ctx, f.raw, "bab", "app", f.host)
369 if err != nil {
370 t.Fatal(err)
371 }
372 return tag
373 }
374 f.raw.claimPuts = 0
375 admitted := hostclaim.MoveAdmission{AppID: "canonical", AdmissionID: moveStartID, SourceEnrollment: f.enIE, TargetEnrollment: f.enUK}
376 if _, err := hostclaim.Update(ctx, data, o, hostclaim.BeginAdmittedMove(moveStartID, "operator", "ie-box", "uk-box", "uk", f.now, admitted)); !errors.Is(err, ErrPending) {
377 t.Fatalf("unbound start CAS: %v", err)
378 }
379 if f.raw.claimPuts != 0 || f.read(t).ETag != o.ETag {
380 t.Fatal("unbound start CAS reached the claim")
381 }
382 moving, err := BeginClaimMove(ctx, data, dir, h, o, moveStartID, "operator", "uk-box", "uk", f.now)
383 if err != nil {
384 t.Fatal(err)
385 }
386 if f.raw.claimPuts != 1 || moving.Claim.Op.Kind != hostclaim.KindMoving || f.read(t).ETag != moving.ETag {
387 t.Fatalf("granted start CAS: puts=%d op=%+v", f.raw.claimPuts, moving.Claim.Op)
388 }
389 start := provision.MoveStart{ID: moveStartID, Incarnation: f.inc, From: "ie", To: "uk", Cut: "auto", By: "operator", Now: f.now}
390 before := recordTag()
391 for name, c := range map[string]context.Context{"no-grant": ctx, "grant-without-effect": h.Lifecycle(ctx)} {
392 if _, err := setLifecycleRecordMove(c, data, moving, provision.MoveStartTransition(start)); !errors.Is(err, ErrPending) {
393 t.Fatalf("%s: unbound record start: %v", name, err)
394 }
395 }
396 if recordTag() != before {
397 t.Fatal("unbound record start wrote the record")
398 }
399 rec, err := StartClaimMoveRecord(ctx, data, h, moving, start)
400 if err != nil {
401 t.Fatal(err)
402 }
403 if rec.Move == nil || rec.Move.ID != moveStartID || rec.Move.State != provision.MovePreparing {
404 t.Fatalf("record not started: %+v", rec.Move)
405 }
406 a, err := ReadAdmission(ctx, f.raw, "bab", "app", "canonical")
407 if err != nil {
408 t.Fatal(err)
409 }
410 if e := a.Value.Effects[moveStartID]; e.State != "complete" || e.OperationID != moveStartID || e.Resource != f.raw.recordKey || e.Owner != h.Owner() {
411 t.Fatalf("record start effect: %+v", e)
412 }
413 // A replay of the completed effect writes nothing and reads the result.
414 started := recordTag()
415 if rec, err = StartClaimMoveRecord(ctx, data, h, moving, start); err != nil || rec.Move.ID != moveStartID || recordTag() != started {
416 t.Fatalf("replay: %v", err)
417 }
418}
419