carlos / platform Public

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

Plain git — no account needed to clone.

Download

Download this file

1package console
2
3import (
4 "context"
5 "crypto/ed25519"
6 "crypto/sha256"
7 "encoding/hex"
8 "encoding/json"
9 "errors"
10 "net/http"
11 "reflect"
12 "strings"
13 "testing"
14 "time"
15
16 "github.com/carlosframework/platform/internal/boxid"
17 "github.com/carlosframework/platform/internal/hostactors"
18 "github.com/carlosframework/platform/internal/hostclaim"
19 "github.com/carlosframework/platform/internal/hostops"
20 "github.com/carlosframework/platform/internal/provision"
21 "github.com/carlosframework/platform/internal/release"
22 "github.com/carlosframework/platform/internal/termfence"
23)
24
25// With the host claim and the Phase 1 switch both on but no claim runtime
26// (no ProductionAuthority to admit the move), every move mutation is
27// refused before any write: the request door would otherwise CAS the claim
28// to moving with no app admission (claim Plan 5 Task 7).
29func TestMoveDoorPhase1RefusedUnderHostClaim(t *testing.T) {
30 fx, token := operatorInstanceFixture(t, "code-inst-move-claim")
31 m := titoManifest()
32 m.Regions = nil
33 m.Pool = ""
34 enableInstances(t, fx, "titogo", m)
35 u := instancesURL(fx, "bab", "titogo")
36 if code, _, out := rawReq(t, http.MethodPost, u, token, `{"host":"acme.go.tito.io","region":"ie"}`); code != http.StatusCreated {
37 t.Fatalf("create = %d %s", code, out)
38 }
39 armMoveFleet(t, fx, ieBox("ie-box"), ukBox("uk-box"))
40 claimFor(t, fx, "acme.go.tito.io", "ie-box", "ie")
41 fx.srv.cfg.HostClaim = true
42 moveURL := u + "/acme.go.tito.io/move"
43 claim := claimBytes(t, fx, "acme.go.tito.io")
44 for _, c := range []struct{ method, path, body string }{
45 {http.MethodPost, moveURL, `{"region":"uk"}`},
46 {http.MethodPost, moveURL + "/cut", ""},
47 {http.MethodDelete, moveURL, ""},
48 } {
49 code, _, out := rawReq(t, c.method, c.path, token, c.body)
50 if code != http.StatusConflict || errorText(out) != claimMoveUnsupportedMsg {
51 t.Fatalf("%s %s under the host claim = %d %s", c.method, c.path, code, out)
52 }
53 }
54 if claimBytes(t, fx, "acme.go.tito.io") != claim {
55 t.Fatal("a refused move door wrote the claim")
56 }
57 rec, err := provision.GetRecord(context.Background(), fx.rel, "bab", "titogo", "acme.go.tito.io")
58 if err != nil {
59 t.Fatal(err)
60 }
61 if rec.Move != nil {
62 t.Fatalf("a refused move door journaled a move: %+v", rec.Move)
63 }
64}
65
66// Ruling J17 (Plan 4 Task 7): under the host claim the resolve door's
67// {"retry": true} record write answers HC5-N1 like the other console move
68// writes, before any write, even on a stuck move. Claim Plan 10 adds the
69// console move grant that covers resolve --retry.
70func TestResolveRetryRefusedUnderHostClaim(t *testing.T) {
71 fx, token := operatorInstanceFixture(t, "code-inst-move-claim-retry")
72 m := titoManifest()
73 m.Regions = nil
74 m.Pool = ""
75 enableInstances(t, fx, "titogo", m)
76 u := instancesURL(fx, "bab", "titogo")
77 if code, _, out := rawReq(t, http.MethodPost, u, token, `{"host":"acme.go.tito.io","region":"ie"}`); code != http.StatusCreated {
78 t.Fatalf("create = %d %s", code, out)
79 }
80 stuckMove(t, fx, func(m *provision.MoveState) {
81 m.State, m.Rollback = provision.MoveRollingBack, &provision.MoveRollback{ID: "rb_abcdefghijklmnopqrstuvwxyz", SourcePhase: provision.RbSourceVerified}
82 }, provision.StuckBaselineMismatch)
83 ctx := context.Background()
84 rec, err := provision.GetRecord(ctx, fx.rel, "bab", "titogo", "acme.go.tito.io")
85 if err != nil {
86 t.Fatal(err)
87 }
88 before, _ := json.Marshal(rec.Move)
89 fx.srv.cfg.HostClaim = true
90 code, _, out := rawReq(t, http.MethodPost, u+"/acme.go.tito.io/move/resolve", token, `{"retry":true}`)
91 if code != http.StatusConflict || errorText(out) != claimMoveUnsupportedMsg {
92 t.Fatalf("resolve --retry under the host claim = %d %s", code, out)
93 }
94 rec, err = provision.GetRecord(ctx, fx.rel, "bab", "titogo", "acme.go.tito.io")
95 if err != nil {
96 t.Fatal(err)
97 }
98 if after, _ := json.Marshal(rec.Move); string(after) != string(before) {
99 t.Fatalf("a refused retry wrote the record:\n%s\n%s", before, after)
100 }
101 if rec.Move.State != provision.MoveStuck {
102 t.Fatalf("the move is %s, want stuck", rec.Move.State)
103 }
104}
105
106// claimMoveAppID is the canonical app id armClaimMoves binds titogo to.
107const claimMoveAppID = "titogo-canonical"
108
109// claimMoveWorld is a moveDoorWithClaim fixture switched to a host-claim
110// console the way ConfigureClaimRuntime wires one: Config.Releases is the
111// production data boundary (the conditional record store under the
112// production mutation check, with the typed control writer over the raw
113// bucket), and the authority the door uses is Config.ClaimAuthority over
114// that boundary. The SQL gate and the remaining runtime fields are not
115// needed by the move door and are not built.
116type claimMoveWorld struct {
117 fx *consoleFixture
118 control *hostops.ControlWriter
119 data release.Store
120}
121
122// armClaimMoves switches fx to a host-claim console. bind binds the app's
123// canonical id in SQL; admission writes the app's open admission object.
124// raw is the bucket under the record store (fx.rel unless a test injects
125// a failure); every other part of the runtime reads fx.rel.
126func armClaimMoves(t *testing.T, fx *consoleFixture, bind, admission bool, raw release.Store) *claimMoveWorld {
127 t.Helper()
128 ctx := context.Background()
129 if err := fx.st.migrateClaimAppIdentity(); err != nil {
130 t.Fatal(err)
131 }
132 if bind {
133 if err := fx.st.BindClaimAppIdentity(OpsAccountID, "titogo", claimMoveAppID); err != nil {
134 t.Fatal(err)
135 }
136 }
137 if admission {
138 a := hostops.AppAdmission{V: 1, AppID: claimMoveAppID, Revision: 1, Epoch: 1, State: "open", Registrations: map[string]hostops.Registration{}, Effects: map[string]hostops.Effect{}}
139 body, err := json.Marshal(a)
140 if err != nil {
141 t.Fatal(err)
142 }
143 if _, err := fx.rel.Put(ctx, "apps/bab/titogo/admission.json", body, ""); err != nil {
144 t.Fatal(err)
145 }
146 }
147 if raw == nil {
148 raw = fx.rel
149 }
150 cfg := fx.srv.cfg
151 cfg.HostClaim = true
152 control, err := hostops.NewControlWriter(fx.rel, fx.pub, time.Now, cfg.dbClaimHomeRegion())
153 if err != nil {
154 t.Fatal(err)
155 }
156 check, err := hostops.NewProductionMutationCheck(fx.rel, control)
157 if err != nil {
158 t.Fatal(err)
159 }
160 records, err := hostops.NewConditionalRecordStore(raw, fx.pub, cfg.dbClaimHomeRegion())
161 if err != nil {
162 t.Fatal(err)
163 }
164 data, err := hostops.NewMutationBoundary(true, records, check)
165 if err != nil {
166 t.Fatal(err)
167 }
168 fx.srv.cfg.HostClaim = true
169 fx.srv.cfg.Releases = data
170 fx.srv.cfg.ClaimAuthority = &hostops.ProductionAuthority{Store: data, Control: control,
171 Directory: hostactors.Directory{Store: data, PublicKey: fx.pub, Bucket: moveTestBucket},
172 ConsoleID: "console-a", Enrollment: strings.Repeat("e", 32), Now: time.Now}
173 return &claimMoveWorld{fx: fx, control: control, data: data}
174}
175
176// On a host-claim console the request door starts the move under the app's
177// admission: the op carries the move's own keys plus the app id, the
178// registration (the move id) and both boxes' roster enrollments, the record
179// journals the move as one effect of that registration, and the door built
180// no authority of its own. Cut and abort keep main's refusal: their record
181// writes have no move effect yet.
182func TestMoveDoorAdmitsTheMoveUnderTheHostClaim(t *testing.T) {
183 ctx := context.Background()
184 fx, token, moveURL := moveDoorWithClaim(t, "code-inst-move-admit")
185 armClaimMoves(t, fx, true, true, nil)
186 if c, _, out := rawReq(t, http.MethodPost, moveURL, token, `{"region":"uk","cut":"manual"}`); c != http.StatusAccepted {
187 t.Fatalf("request = %d %s", c, out)
188 }
189 rec, err := provision.GetRecord(ctx, fx.rel, "bab", "titogo", "acme.go.tito.io")
190 if err != nil {
191 t.Fatal(err)
192 }
193 if rec.Move == nil || rec.Move.State != provision.MovePreparing || rec.Move.To != "uk" {
194 t.Fatalf("record move = %+v", rec.Move)
195 }
196 id := rec.Move.ID
197 obs, err := hostclaim.Read(ctx, fx.rel, "acme.go.tito.io")
198 if err != nil {
199 t.Fatal(err)
200 }
201 op := obs.Claim.Op
202 if op.Kind != hostclaim.KindMoving || op.ID != id || op.Exec != "ie-box" {
203 t.Fatalf("claim op = %+v", op)
204 }
205 want := map[string]string{"to": "uk", "source": "ie-box", "target": "uk-box", "app_id": claimMoveAppID, "admission_id": id,
206 "source_enrollment": moveTestEnrollment("ie-box"), "target_enrollment": moveTestEnrollment("uk-box")}
207 if !reflect.DeepEqual(op.Intent, want) {
208 t.Fatalf("claim intent = %v, want %v", op.Intent, want)
209 }
210 a, err := hostops.ReadAdmission(ctx, fx.rel, "bab", "titogo", claimMoveAppID)
211 if err != nil {
212 t.Fatal(err)
213 }
214 if reg, ok := a.Value.Registrations[id]; !ok || reg.Kind != hostops.RegistrationMove || reg.Host != "acme.go.tito.io" || reg.AppID != claimMoveAppID {
215 t.Fatalf("move registration = %+v (present %v)", reg, ok)
216 }
217 if e := a.Value.Effects[id]; e.State != "complete" || e.OperationID != id {
218 t.Fatalf("record start effect = %+v", e)
219 }
220 // Claim Plan 10 (J17): cut and abort are the typed console move
221 // transitions now. A cut of a preparing move is the transition's own
222 // refusal; the abort is written. Neither writes the claim.
223 claim := claimBytes(t, fx, "acme.go.tito.io")
224 if code, _, out := rawReq(t, http.MethodPost, moveURL+"/cut", token, ""); code != http.StatusConflict || errorText(out) != "cut is only legal from ready" {
225 t.Fatalf("cut of a preparing move under the host claim = %d %s", code, out)
226 }
227 if code, _, out := rawReq(t, http.MethodDelete, moveURL, token, ""); code != http.StatusAccepted {
228 t.Fatalf("abort under the host claim = %d %s", code, out)
229 }
230 if claimBytes(t, fx, "acme.go.tito.io") != claim {
231 t.Fatal("a cut or abort wrote the claim")
232 }
233}
234
235// c7MoveAuthority binds every fence to the record's region, as the real
236// roster authority would for the source box's own roster entry.
237type c7MoveAuthority struct{ region string }
238
239func (a c7MoveAuthority) ValidateFence(context.Context, termfence.Record) (hostclaim.FenceBinding, error) {
240 return hostclaim.FenceBinding{Region: a.region, RosterRevision: 1, RosterETag: "roster-etag"}, nil
241}
242
243// c7MoveGuard stands in for the record's durable relocation decision: it
244// answers the record's region for this op and the source's enrollment.
245type c7MoveGuard struct{ rel release.Store }
246
247func (g c7MoveGuard) CheckMovingBeforeFlip(ctx context.Context, o hostclaim.Observed, id string, ref hostclaim.FenceRef) (string, error) {
248 if o.Claim == nil || o.Claim.Op.ID != id || ref.Enrollment != o.Claim.Op.Intent["source_enrollment"] {
249 return "", hostclaim.ErrFence
250 }
251 rec, err := provision.GetRecord(ctx, g.rel, "bab", "titogo", o.Host)
252 if err != nil {
253 return "", err
254 }
255 return rec.Region, nil
256}
257
258// terminatedSourceFence writes the regional worker's termination fence for
259// box and returns its reference.
260func terminatedSourceFence(t *testing.T, fx *consoleFixture, box, worker string) hostclaim.FenceRef {
261 t.Helper()
262 f := termfence.Record{V: 1, Box: box, Enrollment: moveTestEnrollment(box), Cloud: boxid.Cloud{Provider: "aws", Account: moveTestAccount, Region: "eu-west-1", InstanceID: "i-" + box},
263 State: "terminated", ObservedAt: time.Now().UTC().Format(time.RFC3339), Via: "describe", RequestID: "request",
264 WrittenBy: termfence.WrittenBy{Box: worker, Enrollment: moveTestEnrollment(worker)}}
265 raw, err := json.Marshal(f)
266 if err != nil {
267 t.Fatal(err)
268 }
269 sum := sha256.Sum256(raw)
270 ref := hostclaim.FenceRef{Enrollment: f.Enrollment, Key: "control/box-fences/" + f.Enrollment + ".json", SHA256: hex.EncodeToString(sum[:])}
271 if _, err := fx.rel.Put(context.Background(), ref.Key, raw, ""); err != nil {
272 t.Fatal(err)
273 }
274 return ref
275}
276
277// The C7 contract, end to end from the door: a claim the host-claim door
278// started converts through ConvertMovingToRelocating when its source is
279// terminated before the flip. A claim the plain door started (main's
280// BeginMove, no admission) does not, and the refusal writes nothing.
281func TestMoveDoorStartIsWhatC7Converts(t *testing.T) {
282 for _, admitted := range []bool{true, false} {
283 t.Run(map[bool]string{true: "host-claim", false: "plain"}[admitted], func(t *testing.T) {
284 ctx := context.Background()
285 fx, token, moveURL := moveDoorWithClaim(t, "code-inst-move-c7")
286 if admitted {
287 armClaimMoves(t, fx, true, true, nil)
288 }
289 if c, _, out := rawReq(t, http.MethodPost, moveURL, token, `{"region":"uk","cut":"manual"}`); c != http.StatusAccepted {
290 t.Fatalf("request = %d %s", c, out)
291 }
292 obs, err := hostclaim.Read(ctx, fx.rel, "acme.go.tito.io")
293 if err != nil {
294 t.Fatal(err)
295 }
296 id := obs.Claim.Op.ID
297 rec, err := provision.GetRecord(ctx, fx.rel, "bab", "titogo", "acme.go.tito.io")
298 if err != nil {
299 t.Fatal(err)
300 }
301 if rec.Region != "ie" {
302 t.Fatalf("record region = %q", rec.Region)
303 }
304 ref := terminatedSourceFence(t, fx, "ie-box", "uk-box")
305 before := claimBytes(t, fx, "acme.go.tito.io")
306 after, err := hostclaim.ConvertMovingToRelocating(ctx, fx.rel, obs, id, ref, c7MoveAuthority{region: rec.Region}, c7MoveGuard{rel: fx.rel})
307 if !admitted {
308 if !errors.Is(err, hostclaim.ErrWrongState) {
309 t.Fatalf("a plain door start converted: %v", err)
310 }
311 if claimBytes(t, fx, "acme.go.tito.io") != before {
312 t.Fatal("the refused conversion wrote the claim")
313 }
314 return
315 }
316 if err != nil {
317 t.Fatalf("the door's admitted start did not convert: %v", err)
318 }
319 op := after.Claim.Op
320 if op.Kind != hostclaim.KindRelocating || op.Phase != "pinning" || op.ID != id || op.Intent["source_box"] != "ie-box" || op.Intent["region"] != "ie" ||
321 op.Journal["admission_id"] != id || op.Journal["app_id"] != claimMoveAppID {
322 t.Fatalf("converted op = %+v", op)
323 }
324 })
325 }
326}
327
328// claimMoveHeldSentence is MV-N179: on a host-claim console a failed record
329// start leaves its admission effect uncertain (the KNOWN GAP below), so the
330// 502 promises no retry.
331const claimMoveHeldSentence = "the move could not be recorded and is held until an operator checks the record"
332
333// failRecordPut refuses writes of one record key; reads pass. With fails
334// > 0 it refuses that many writes and then passes; 0 refuses every write.
335type failRecordPut struct {
336 release.Store
337 key string
338 fails *int
339}
340
341func (f failRecordPut) Put(ctx context.Context, key string, body []byte, match string) (string, error) {
342 if key == f.key && (f.fails == nil || *f.fails > 0) {
343 if f.fails != nil {
344 *f.fails--
345 }
346 return "", errors.New("secret-detail: record put failed")
347 }
348 return f.Store.Put(ctx, key, body, match)
349}
350
351// hookFirstGet runs hook once, on the first read of key, before the read.
352type hookFirstGet struct {
353 release.Store
354 key string
355 hook *func()
356}
357
358func (h hookFirstGet) Get(ctx context.Context, key string) ([]byte, string, error) {
359 if key == h.key && *h.hook != nil {
360 f := *h.hook
361 *h.hook = nil
362 f()
363 }
364 return h.Store.Get(ctx, key)
365}
366
367// Every host-claim refusal answers its recorded sentence before the claim
368// CAS: the claim and the record are as they were. A roster that does not
369// place both boxes is refused from the door's own roster observation, before
370// any registration is written.
371func TestMoveDoorHostClaimRefusals(t *testing.T) {
372 const roster = "the host roster does not allow this move. the source and target must be active boxes in ie and uk"
373 for _, tc := range []struct {
374 name string
375 bind, admission bool
376 setup func(t *testing.T, fx *consoleFixture, w *claimMoveWorld)
377 status int
378 msg string
379 registered bool // the refusal comes after RegisterMove
380 }{
381 {name: "app-not-bound", admission: true, status: http.StatusConflict, msg: "this app has no host-claim admission yet. try again shortly"},
382 {name: "no-admission", bind: true, status: http.StatusConflict, msg: "this app has no host-claim admission yet. try again shortly"},
383 {name: "app-busy", bind: true, admission: true, status: http.StatusConflict, msg: "another operation holds this app. try again when it finishes",
384 setup: func(t *testing.T, fx *consoleFixture, w *claimMoveWorld) {
385 owner := hostops.RegistrationOwner{Console: "console-a", Enrollment: strings.Repeat("e", 32), Controller: "controller"}
386 if _, err := w.control.BeginExclusive(context.Background(), "bab", "titogo", claimMoveAppID, hostops.AppOperation{ID: "delete", Kind: hostops.OpDeleting, AppID: claimMoveAppID}, owner); err != nil {
387 t.Fatal(err)
388 }
389 }},
390 {name: "target-quarantined", bind: true, admission: true, status: http.StatusConflict, msg: roster,
391 setup: func(t *testing.T, fx *consoleFixture, _ *claimMoveWorld) {
392 uk := moveRosterBox(ukBox("uk-box"))
393 uk.State, uk.QuarantineRevision, uk.LossIDs = "quarantined", 1, []string{"loss"}
394 putMoveRoster(t, fx, hostactors.Roster{Boxes: map[string]hostactors.Box{"ie-box": moveRosterBox(ieBox("ie-box")), "uk-box": uk}})
395 }},
396 {name: "target-not-in-roster", bind: true, admission: true, status: http.StatusConflict, msg: roster,
397 setup: func(t *testing.T, fx *consoleFixture, _ *claimMoveWorld) {
398 putMoveRoster(t, fx, hostactors.Roster{Boxes: map[string]hostactors.Box{"ie-box": moveRosterBox(ieBox("ie-box"))}})
399 }},
400 {name: "target-in-another-region", bind: true, admission: true, status: http.StatusConflict, msg: roster,
401 setup: func(t *testing.T, fx *consoleFixture, _ *claimMoveWorld) {
402 uk := moveRosterBox(ukBox("uk-box"))
403 uk.Region = "fr"
404 putMoveRoster(t, fx, hostactors.Roster{Boxes: map[string]hostactors.Box{"ie-box": moveRosterBox(ieBox("ie-box")), "uk-box": uk}})
405 }},
406 {name: "target-fenced", bind: true, admission: true, status: http.StatusConflict, msg: roster,
407 setup: func(t *testing.T, fx *consoleFixture, _ *claimMoveWorld) {
408 writeMoveFence(t, fx, moveRosterBox(ukBox("uk-box")), nil)
409 }},
410 // A box never witnesses its own termination: a fence the target wrote
411 // about itself is not evidence, and an unusable own fence refuses as
412 // an unreadable roster rather than passing as an absent one.
413 {name: "target-self-fenced", bind: true, admission: true, status: http.StatusServiceUnavailable, msg: "could not read the host roster. try again",
414 setup: func(t *testing.T, fx *consoleFixture, _ *claimMoveWorld) {
415 uk := moveRosterBox(ukBox("uk-box"))
416 writeMoveFence(t, fx, uk, map[string]any{"written_by": map[string]string{"box": uk.BoxID, "enrollment": uk.Enrollment}})
417 }},
418 // The door's roster observation placed both boxes; the roster changed
419 // before BeginClaimMove's own read (here: the target quarantined while
420 // the move registered). The refusal does not guess which part of the
421 // world changed.
422 {name: "changed-under-the-request", bind: true, admission: true, status: http.StatusConflict, registered: true,
423 msg: "the instance or its boxes changed under this request. read its state and try again"},
424 } {
425 t.Run(tc.name, func(t *testing.T) {
426 fx, token, moveURL := moveDoorWithClaim(t, "code-inst-move-refuse")
427 var raw release.Store
428 if tc.name == "changed-under-the-request" {
429 var hook func()
430 hook = func() {
431 uk := moveRosterBox(ukBox("uk-box"))
432 uk.State, uk.QuarantineRevision, uk.LossIDs = "quarantined", 1, []string{"loss"}
433 putMoveRoster(t, fx, hostactors.Roster{Boxes: map[string]hostactors.Box{"ie-box": moveRosterBox(ieBox("ie-box")), "uk-box": uk}})
434 }
435 // RegisterMove reads the admission through the data handle,
436 // after the door's own roster checks and before BeginClaimMove.
437 raw = hookFirstGet{Store: fx.rel, key: "apps/bab/titogo/admission.json", hook: &hook}
438 }
439 w := armClaimMoves(t, fx, tc.bind, tc.admission, raw)
440 if tc.setup != nil {
441 tc.setup(t, fx, w)
442 }
443 claim := claimBytes(t, fx, "acme.go.tito.io")
444 code, _, out := rawReq(t, http.MethodPost, moveURL, token, `{"region":"uk"}`)
445 if code != tc.status || errorText(out) != tc.msg {
446 t.Fatalf("request = %d %s, want %d %q", code, out, tc.status, tc.msg)
447 }
448 if claimBytes(t, fx, "acme.go.tito.io") != claim {
449 t.Fatal("a refused request wrote the claim")
450 }
451 rec, err := provision.GetRecord(context.Background(), fx.rel, "bab", "titogo", "acme.go.tito.io")
452 if err != nil {
453 t.Fatal(err)
454 }
455 if rec.Move != nil {
456 t.Fatalf("a refused request journaled a move: %+v", rec.Move)
457 }
458 if !tc.admission {
459 return
460 }
461 a, err := hostops.ReadAdmission(context.Background(), fx.rel, "bab", "titogo", claimMoveAppID)
462 if err != nil {
463 t.Fatal(err)
464 }
465 if got := len(a.Value.Registrations) > 0; got != tc.registered {
466 t.Fatalf("registrations after the refusal = %+v, want registered=%v", a.Value.Registrations, tc.registered)
467 }
468 })
469 }
470}
471
472// A record start that fails after the admitted claim CAS is settled like
473// the plain door's: the record has no move, so the claim goes back to
474// serving under the source, and the release is written under the move
475// registration's lifecycle grant (the data boundary refuses an unbound
476// claim CAS). The error text never reaches the caller.
477func TestMoveDoorHostClaimFailedRecordStartReleasesTheClaim(t *testing.T) {
478 ctx := context.Background()
479 fx, token, moveURL := moveDoorWithClaim(t, "code-inst-move-release")
480 armClaimMoves(t, fx, true, true, failRecordPut{Store: fx.rel, key: provision.RecordKey("bab", "titogo", "acme.go.tito.io")})
481 code, _, out := rawReq(t, http.MethodPost, moveURL, token, `{"region":"uk"}`)
482 if code != http.StatusBadGateway || errorText(out) != claimMoveHeldSentence || strings.Contains(out, "secret-detail") {
483 t.Fatalf("request = %d %s", code, out)
484 }
485 obs, err := hostclaim.Read(ctx, fx.rel, "acme.go.tito.io")
486 if err != nil {
487 t.Fatal(err)
488 }
489 a, err := hostops.ReadAdmission(ctx, fx.rel, "bab", "titogo", claimMoveAppID)
490 if err != nil {
491 t.Fatal(err)
492 }
493 if len(a.Value.Registrations) != 1 {
494 t.Fatalf("registrations = %+v", a.Value.Registrations)
495 }
496 var id string
497 for k := range a.Value.Registrations {
498 id = k
499 }
500 if obs.Claim.Op.Kind != hostclaim.KindServing || obs.Claim.Op.Exec != "ie-box" || obs.Claim.Op.Journal["released_move"] != id {
501 t.Fatalf("claim after the failed start = %+v", obs.Claim.Op)
502 }
503 rec, err := provision.GetRecord(ctx, fx.rel, "bab", "titogo", "acme.go.tito.io")
504 if err != nil {
505 t.Fatal(err)
506 }
507 if rec.Move != nil {
508 t.Fatalf("record move = %+v", rec.Move)
509 }
510}
511
512// The failed record start's "try again" must be true: after the storage
513// failure clears, a retry starts the move, and once the moves settle and the
514// registrations pass their TTL, the failed attempt's registration drains
515// (its effect does not stay unsettled).
516//
517// KNOWN GAP (task E-switch fix round 1, NEEDS_CONTEXT): this fails today. The
518// failed attempt's move-start effect is left "uncertain" (the Put was issued),
519// the retry's effect on the same record is refused by prepareEffect, and the
520// registration cannot drain while its effect is unsettled. hostops has no
521// API that resolves an uncertain record effect; the claim code owns that,
522// and the resolver has been requested from the claim session (fix round 2).
523// Until it lands the door answers MV-N179, which promises no retry. When it
524// lands: the compensation calls it, the door answers a retryable sentence
525// again on a "not-applied" outcome, and this skip goes.
526func TestMoveDoorHostClaimRetryAfterAFailedRecordStart(t *testing.T) {
527 t.Skip("KNOWN GAP: no hostops API resolves an uncertain move-start effect; the resolver is requested from the claim session (task E-switch fix rounds 1-2)")
528 ctx := context.Background()
529 fx, token, moveURL := moveDoorWithClaim(t, "code-inst-move-retry")
530 fails := 1
531 w := armClaimMoves(t, fx, true, true, failRecordPut{Store: fx.rel, key: provision.RecordKey("bab", "titogo", "acme.go.tito.io"), fails: &fails})
532 if code, _, out := rawReq(t, http.MethodPost, moveURL, token, `{"region":"uk"}`); code != http.StatusBadGateway || errorText(out) != claimMoveHeldSentence {
533 t.Fatalf("first request = %d %s", code, out)
534 }
535 a, err := hostops.ReadAdmission(ctx, fx.rel, "bab", "titogo", claimMoveAppID)
536 if err != nil {
537 t.Fatal(err)
538 }
539 var failed string
540 for id := range a.Value.Registrations {
541 failed = id
542 }
543 if code, _, out := rawReq(t, http.MethodPost, moveURL, token, `{"region":"uk"}`); code != http.StatusAccepted {
544 t.Fatalf("retry after the failure cleared = %d %s (failed attempt's effect: %+v)", code, out, a.Value.Effects[failed])
545 }
546 if err := w.control.SettleRegistration(ctx, "bab", "titogo", claimMoveAppID, failed, time.Now().Add(hostops.RegistrationTTL+2*hostops.RegistrationSkew)); err != nil {
547 t.Fatalf("the failed attempt's registration does not drain: %v", err)
548 }
549 if a, err = hostops.ReadAdmission(ctx, fx.rel, "bab", "titogo", claimMoveAppID); err != nil {
550 t.Fatal(err)
551 }
552 if _, ok := a.Value.Registrations[failed]; ok {
553 t.Fatal("the failed attempt's registration is still held")
554 }
555}
556
557// The door and claim transition both resolve the record's empty home placement
558// against the roster's named home. The admitted operation retains that binding
559// while its move record keeps placement codes.
560func TestMoveDoorHostClaimPlacesAHomeSource(t *testing.T) {
561 ctx := context.Background()
562 fx, token := operatorInstanceFixture(t, "code-inst-move-home")
563 fx.srv.cfg.HomeRegion = "ie"
564 m := titoManifest()
565 m.Regions = nil
566 m.Pool = ""
567 enableInstances(t, fx, "titogo", m)
568 u := instancesURL(fx, "bab", "titogo")
569 if c, _, out := rawReq(t, http.MethodPost, u, token, `{"host":"acme.go.tito.io","region":"ie"}`); c != http.StatusCreated {
570 t.Fatalf("create = %d %s", c, out)
571 }
572 armMoveFleet(t, fx, ieBox("ie-box"), ukBox("uk-box"))
573 claimFor(t, fx, "acme.go.tito.io", "ie-box", "ie")
574 if rec, err := provision.GetRecord(ctx, fx.rel, "bab", "titogo", "acme.go.tito.io"); err != nil || rec.Region != "" {
575 t.Fatalf("fixture: the record is not home's: %+v %v", rec, err)
576 }
577 d := hostactors.Directory{Store: fx.rel, PublicKey: fx.priv.Public().(ed25519.PublicKey), Bucket: moveTestBucket}
578 roster, err := d.Read(ctx)
579 if err != nil {
580 t.Fatal(err)
581 }
582 next := roster.Roster
583 next.HomeRegion = "ie"
584 if _, err := d.Replace(ctx, release.RootSigner(fx.priv), roster, next); err != nil {
585 t.Fatal(err)
586 }
587 armClaimMoves(t, fx, true, true, nil)
588 code, _, out := rawReq(t, http.MethodPost, u+"/acme.go.tito.io/move", token, `{"region":"uk"}`)
589 if code != http.StatusAccepted {
590 t.Fatalf("request = %d %s, want an admitted home-source move", code, out)
591 }
592 a, err := hostops.ReadAdmission(ctx, fx.rel, "bab", "titogo", claimMoveAppID)
593 if err != nil || len(a.Value.Registrations) != 1 {
594 t.Fatalf("the move did not register: %+v %v", a.Value.Registrations, err)
595 }
596 o, err := hostclaim.Read(ctx, fx.rel, "acme.go.tito.io")
597 if err != nil || o.Absent || o.Claim == nil {
598 t.Fatalf("read admitted claim: %+v %v", o, err)
599 }
600 if o.Claim.Op.Kind != hostclaim.KindMoving || o.Claim.Op.Exec != "ie-box" || o.Claim.Op.Intent["to"] != "uk" || o.Claim.Op.Intent["home_region"] != "ie" {
601 t.Fatalf("admitted operation lost its home binding: %+v", o.Claim.Op)
602 }
603 if rec, err := provision.GetRecord(ctx, fx.rel, "bab", "titogo", "acme.go.tito.io"); err != nil || rec.Region != "" || rec.Move == nil || rec.Move.From != "" || rec.Move.To != "uk" || rec.Move.ID != o.Claim.Op.ID {
604 t.Fatalf("admitted move did not retain placement codes: %+v %v", rec, err)
605 }
606}
607
608// claimMoveRosterPlaces reads roster regions through the console's home
609// translation (homePlacementCode, homeRegionIn's rule): the home's name is
610// the record's "", a named region is itself, and with no home named
611// nothing is home.
612func TestClaimMoveRosterPlacesTranslatesHome(t *testing.T) {
613 box := func(b fleetBox) hostactors.Box { return moveRosterBox(b) }
614 r := hostactors.Roster{Boxes: map[string]hostactors.Box{"ie-box": box(ieBox("ie-box")), "uk-box": box(ukBox("uk-box"))}}
615 home, unnamed := "ie", ""
616 for _, tc := range []struct {
617 name string
618 in string
619 recRegion string
620 to string
621 ok bool
622 }{
623 {"home source, home named", home, "", "uk", true},
624 {"home source, no home named", unnamed, "", "uk", false},
625 {"named source, home named elsewhere", "de", "ie", "uk", true},
626 {"named source, no home named", unnamed, "ie", "uk", true},
627 {"the record says ie but ie is home", home, "ie", "uk", false},
628 {"target in the source's region", home, "", "", false},
629 } {
630 if got := claimMoveRosterPlaces(r, "ie-box", "uk-box", tc.recRegion, tc.to, tc.in); got != tc.ok {
631 t.Fatalf("%s: %v, want %v", tc.name, got, tc.ok)
632 }
633 }
634}
635