| 1 | package console |
| 2 | |
| 3 | import ( |
| 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). |
| 29 | func 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. |
| 70 | func 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. |
| 107 | const 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. |
| 116 | type 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. |
| 126 | func 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. |
| 182 | func 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. |
| 237 | type c7MoveAuthority struct{ region string } |
| 238 | |
| 239 | func (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. |
| 245 | type c7MoveGuard struct{ rel release.Store } |
| 246 | |
| 247 | func (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. |
| 260 | func 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. |
| 281 | func 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. |
| 331 | const 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. |
| 335 | type failRecordPut struct { |
| 336 | release.Store |
| 337 | key string |
| 338 | fails *int |
| 339 | } |
| 340 | |
| 341 | func (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. |
| 352 | type hookFirstGet struct { |
| 353 | release.Store |
| 354 | key string |
| 355 | hook *func() |
| 356 | } |
| 357 | |
| 358 | func (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. |
| 371 | func 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. |
| 477 | func 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. |
| 526 | func 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. |
| 560 | func 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. |
| 612 | func 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 | |