package console import ( "context" "net/http" "strings" "testing" "time" "github.com/carlosframework/platform/internal/hostclaim" "github.com/carlosframework/platform/internal/provision" "github.com/carlosframework/platform/internal/release" ) // A move request is refused while a run is open (run spec ยง5.1, codex 1). // The legacy door, live while CARLOS_MOVES_PHASE1 is off, says RUN-C43. // The target is armed with a staging bucket the way TestAPIInstanceMoveVerbs // (apiinstancemove_test.go:27) arms it, so the only refusal left is the run. func TestMoveDoorRefusesWhileARunIsOpen(t *testing.T) { fx := runFixture(t) id := fx.startRun(t) if _, err := fx.rel.Put(context.Background(), "control/edges/uk-box.json", []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 { t.Fatal(err) } u := fx.ts.URL + "/api/cli/apps/bab/shop/instances/" + runTestHost + "/move" status, _, body := rawReq(t, http.MethodPost, u, fx.member, `{"region":"uk"}`) 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") { t.Fatalf("legacy move: %d %s", status, body) } if _, err := provision.SetRecordRun(context.Background(), fx.rel, release.OpsAccount, "shop", runTestHost, provision.RunExpire(id)); err != nil { t.Fatal(err) } if status, _, body := rawReq(t, http.MethodPost, u, fx.member, `{"region":"uk"}`); status != http.StatusAccepted { t.Fatalf("a move after the run ended: %d %s", status, body) } } // movePhase1RunFixture is an operator fixture with acme created in ie, both // boxes armed, acme's serving claim written and the Phase 1 switch on. func movePhase1RunFixture(t *testing.T) (fx *consoleFixture, token, moveURL string) { t.Helper() fx, token = operatorInstanceFixture(t, "code-inst-move-run") m := titoManifest() m.Domain = "example.com" m.Regions = nil m.Pool = "" enableInstances(t, fx, "titogo", m) u := instancesURL(fx, "bab", "titogo") if code, _, out := rawReq(t, http.MethodPost, u, token, `{"host":"acme.example.com","region":"ie"}`); code != http.StatusCreated { t.Fatalf("create = %d %s", code, out) } armMoveFleet(t, fx, ieBox("ie-box"), ukBox("uk-box")) claimFor(t, fx, "acme.example.com", "ie-box", "ie") return fx, token, u + "/acme.example.com/move" } func admitRun(t *testing.T, rel release.Store, id string) { t.Helper() if _, err := provision.SetRecordRun(context.Background(), rel, "bab", "titogo", "acme.example.com", provision.RunAdmit(provision.RunAdmission{ID: id, By: "alice@keymail.dev", Deadline: time.Now().Add(time.Minute)})); err != nil { t.Fatal(err) } } // The Phase 1 door (switch on) refuses a move while a run is open before it // touches the claim, with its generic sentence (index item 11): the run's id // is not a sentence the Phase 1 table knows. func TestPhase1MoveDoorRefusesWhileARunIsOpen(t *testing.T) { fx, token, moveURL := movePhase1RunFixture(t) ctx := context.Background() admitRun(t, fx.rel, "run-p1") code, _, out := rawReq(t, http.MethodPost, moveURL, token, `{"region":"uk"}`) if code != http.StatusConflict || errorText(out) != "this move's state does not allow that. read its state and try again" { t.Fatalf("phase 1 move with a run open = %d %s", code, out) } obs, err := hostclaim.Read(ctx, fx.rel, "acme.example.com") if err != nil { t.Fatal(err) } if obs.Claim.Op.Kind != hostclaim.KindServing { t.Fatalf("claim op = %+v, want it untouched (serving)", obs.Claim.Op) } if _, err := provision.SetRecordRun(ctx, fx.rel, "bab", "titogo", "acme.example.com", provision.RunExpire("run-p1")); err != nil { t.Fatal(err) } if code, _, out := rawReq(t, http.MethodPost, moveURL, token, `{"region":"uk"}`); code != http.StatusAccepted { t.Fatalf("phase 1 move after the run ended = %d %s", code, out) } } // beforePut runs fn once, just before the first write of key. type beforePut struct { release.Store key string fn func() done bool } func (b *beforePut) Put(ctx context.Context, key string, body []byte, ifMatch string) (string, error) { if key == b.key && !b.done { b.done = true b.fn() } return b.Store.Put(ctx, key, body, ifMatch) } // A run admitted after the Phase 1 door's probe passed and before its // record write (here: just before the claim CAS) makes MoveStartTransition // refuse inside SetRecordMove. That is "not applied": the moving claim goes // back to serving, no move is journaled, and the run is left alone. func TestPhase1MoveDoorUnwindsWhenARunIsAdmittedAfterTheProbe(t *testing.T) { fx, token, moveURL := movePhase1RunFixture(t) ctx := context.Background() rel := fx.rel hook := &beforePut{Store: rel, key: "control/hosts/acme.example.com.json", fn: func() { admitRun(t, rel, "run-race") }} fx.srv.cfg.Releases = hook code, _, out := rawReq(t, http.MethodPost, moveURL, token, `{"region":"uk"}`) fx.srv.cfg.Releases = rel if !hook.done { t.Fatal("the run was never admitted") } if code != http.StatusBadGateway || errorText(out) != "could not record the move. try again" { t.Fatalf("move racing a run = %d %s", code, out) } obs, err := hostclaim.Read(ctx, rel, "acme.example.com") if err != nil { t.Fatal(err) } if obs.Claim.Op.Kind != hostclaim.KindServing || obs.Claim.Op.Exec != "ie-box" || obs.Claim.Op.Journal["released_move"] == "" { t.Fatalf("claim op = %+v, want serving under ie-box with the move released", obs.Claim.Op) } rec, err := provision.GetRecord(ctx, rel, "bab", "titogo", "acme.example.com") if err != nil { t.Fatal(err) } if rec.Move != nil || rec.ActiveRun == nil || rec.ActiveRun.ID != "run-race" || !rec.ActiveRun.Open() { t.Fatalf("record move %+v run %+v, want no move and the run still open", rec.Move, rec.ActiveRun) } }