| 1 | package console |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "net/http" |
| 6 | "strings" |
| 7 | "testing" |
| 8 | "time" |
| 9 | |
| 10 | "github.com/carlosframework/platform/internal/hostclaim" |
| 11 | "github.com/carlosframework/platform/internal/provision" |
| 12 | "github.com/carlosframework/platform/internal/release" |
| 13 | ) |
| 14 | |
| 15 | // A move request is refused while a run is open (run spec §5.1, codex 1). |
| 16 | // The legacy door, live while CARLOS_MOVES_PHASE1 is off, says RUN-C43. |
| 17 | // The target is armed with a staging bucket the way TestAPIInstanceMoveVerbs |
| 18 | // (apiinstancemove_test.go:27) arms it, so the only refusal left is the run. |
| 19 | func TestMoveDoorRefusesWhileARunIsOpen(t *testing.T) { |
| 20 | fx := runFixture(t) |
| 21 | id := fx.startRun(t) |
| 22 | if _, err := fx.rel.Put(context.Background(), "control/edges/uk-box.json", |
| 23 | []byte(`{"v":1,"box":"uk-box","eip":"1.2.3.4","placement":"uk","data_bucket":"data-uk","data_bucket_region":"eu-west-2","hosts":{}}`), ""); err != nil { |
| 24 | t.Fatal(err) |
| 25 | } |
| 26 | u := fx.ts.URL + "/api/cli/apps/bab/shop/instances/" + runTestHost + "/move" |
| 27 | status, _, body := rawReq(t, http.MethodPost, u, fx.member, `{"region":"uk"}`) |
| 28 | if status != http.StatusConflict || !strings.Contains(body, "a run is open on this instance: "+id+". wait for it to finish or cancel it, then move the instance") { |
| 29 | t.Fatalf("legacy move: %d %s", status, body) |
| 30 | } |
| 31 | if _, err := provision.SetRecordRun(context.Background(), fx.rel, release.OpsAccount, "shop", runTestHost, provision.RunExpire(id)); err != nil { |
| 32 | t.Fatal(err) |
| 33 | } |
| 34 | if status, _, body := rawReq(t, http.MethodPost, u, fx.member, `{"region":"uk"}`); status != http.StatusAccepted { |
| 35 | t.Fatalf("a move after the run ended: %d %s", status, body) |
| 36 | } |
| 37 | } |
| 38 | |
| 39 | // movePhase1RunFixture is an operator fixture with acme created in ie, both |
| 40 | // boxes armed, acme's serving claim written and the Phase 1 switch on. |
| 41 | func movePhase1RunFixture(t *testing.T) (fx *consoleFixture, token, moveURL string) { |
| 42 | t.Helper() |
| 43 | fx, token = operatorInstanceFixture(t, "code-inst-move-run") |
| 44 | m := titoManifest() |
| 45 | m.Domain = "example.com" |
| 46 | m.Regions = nil |
| 47 | m.Pool = "" |
| 48 | enableInstances(t, fx, "titogo", m) |
| 49 | u := instancesURL(fx, "bab", "titogo") |
| 50 | if code, _, out := rawReq(t, http.MethodPost, u, token, `{"host":"acme.example.com","region":"ie"}`); code != http.StatusCreated { |
| 51 | t.Fatalf("create = %d %s", code, out) |
| 52 | } |
| 53 | armMoveFleet(t, fx, ieBox("ie-box"), ukBox("uk-box")) |
| 54 | claimFor(t, fx, "acme.example.com", "ie-box", "ie") |
| 55 | return fx, token, u + "/acme.example.com/move" |
| 56 | } |
| 57 | |
| 58 | func admitRun(t *testing.T, rel release.Store, id string) { |
| 59 | t.Helper() |
| 60 | if _, err := provision.SetRecordRun(context.Background(), rel, "bab", "titogo", "acme.example.com", |
| 61 | provision.RunAdmit(provision.RunAdmission{ID: id, By: "alice@keymail.dev", Deadline: time.Now().Add(time.Minute)})); err != nil { |
| 62 | t.Fatal(err) |
| 63 | } |
| 64 | } |
| 65 | |
| 66 | // The Phase 1 door (switch on) refuses a move while a run is open before it |
| 67 | // touches the claim, with its generic sentence (index item 11): the run's id |
| 68 | // is not a sentence the Phase 1 table knows. |
| 69 | func TestPhase1MoveDoorRefusesWhileARunIsOpen(t *testing.T) { |
| 70 | fx, token, moveURL := movePhase1RunFixture(t) |
| 71 | ctx := context.Background() |
| 72 | admitRun(t, fx.rel, "run-p1") |
| 73 | code, _, out := rawReq(t, http.MethodPost, moveURL, token, `{"region":"uk"}`) |
| 74 | if code != http.StatusConflict || errorText(out) != "this move's state does not allow that. read its state and try again" { |
| 75 | t.Fatalf("phase 1 move with a run open = %d %s", code, out) |
| 76 | } |
| 77 | obs, err := hostclaim.Read(ctx, fx.rel, "acme.example.com") |
| 78 | if err != nil { |
| 79 | t.Fatal(err) |
| 80 | } |
| 81 | if obs.Claim.Op.Kind != hostclaim.KindServing { |
| 82 | t.Fatalf("claim op = %+v, want it untouched (serving)", obs.Claim.Op) |
| 83 | } |
| 84 | if _, err := provision.SetRecordRun(ctx, fx.rel, "bab", "titogo", "acme.example.com", provision.RunExpire("run-p1")); err != nil { |
| 85 | t.Fatal(err) |
| 86 | } |
| 87 | if code, _, out := rawReq(t, http.MethodPost, moveURL, token, `{"region":"uk"}`); code != http.StatusAccepted { |
| 88 | t.Fatalf("phase 1 move after the run ended = %d %s", code, out) |
| 89 | } |
| 90 | } |
| 91 | |
| 92 | // beforePut runs fn once, just before the first write of key. |
| 93 | type beforePut struct { |
| 94 | release.Store |
| 95 | key string |
| 96 | fn func() |
| 97 | done bool |
| 98 | } |
| 99 | |
| 100 | func (b *beforePut) Put(ctx context.Context, key string, body []byte, ifMatch string) (string, error) { |
| 101 | if key == b.key && !b.done { |
| 102 | b.done = true |
| 103 | b.fn() |
| 104 | } |
| 105 | return b.Store.Put(ctx, key, body, ifMatch) |
| 106 | } |
| 107 | |
| 108 | // A run admitted after the Phase 1 door's probe passed and before its |
| 109 | // record write (here: just before the claim CAS) makes MoveStartTransition |
| 110 | // refuse inside SetRecordMove. That is "not applied": the moving claim goes |
| 111 | // back to serving, no move is journaled, and the run is left alone. |
| 112 | func TestPhase1MoveDoorUnwindsWhenARunIsAdmittedAfterTheProbe(t *testing.T) { |
| 113 | fx, token, moveURL := movePhase1RunFixture(t) |
| 114 | ctx := context.Background() |
| 115 | rel := fx.rel |
| 116 | hook := &beforePut{Store: rel, key: "control/hosts/acme.example.com.json", fn: func() { admitRun(t, rel, "run-race") }} |
| 117 | fx.srv.cfg.Releases = hook |
| 118 | code, _, out := rawReq(t, http.MethodPost, moveURL, token, `{"region":"uk"}`) |
| 119 | fx.srv.cfg.Releases = rel |
| 120 | if !hook.done { |
| 121 | t.Fatal("the run was never admitted") |
| 122 | } |
| 123 | if code != http.StatusBadGateway || errorText(out) != "could not record the move. try again" { |
| 124 | t.Fatalf("move racing a run = %d %s", code, out) |
| 125 | } |
| 126 | obs, err := hostclaim.Read(ctx, rel, "acme.example.com") |
| 127 | if err != nil { |
| 128 | t.Fatal(err) |
| 129 | } |
| 130 | if obs.Claim.Op.Kind != hostclaim.KindServing || obs.Claim.Op.Exec != "ie-box" || obs.Claim.Op.Journal["released_move"] == "" { |
| 131 | t.Fatalf("claim op = %+v, want serving under ie-box with the move released", obs.Claim.Op) |
| 132 | } |
| 133 | rec, err := provision.GetRecord(ctx, rel, "bab", "titogo", "acme.example.com") |
| 134 | if err != nil { |
| 135 | t.Fatal(err) |
| 136 | } |
| 137 | if rec.Move != nil || rec.ActiveRun == nil || rec.ActiveRun.ID != "run-race" || !rec.ActiveRun.Open() { |
| 138 | t.Fatalf("record move %+v run %+v, want no move and the run still open", rec.Move, rec.ActiveRun) |
| 139 | } |
| 140 | } |
| 141 | |