console: pin the move door's refusal while a run is open (run spec §5.1)
Also pins the Phase 1 door's refusal and the unwind when a run is admitted between the probe and the record write.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 file changed,
+139
−0
internal/console/apirunsmove_test.go+139 −0
CI cancelled — superseded by a newer push — run details
diff --git a/internal/console/apirunsmove_test.go b/internal/console/apirunsmove_test.go| new file mode 100644 |
| index 000000000..05e048163 |
| --- /dev/null |
| +++ b/internal/console/apirunsmove_test.go |
| @@ -0,0 +1,139 @@ |
| +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/titogo/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, "titogo", 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.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.go.tito.io","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.go.tito.io", "ie-box", "ie") |
| + return fx, token, u + "/acme.go.tito.io/move" |
| +} |
| + |
| +func admitRun(t *testing.T, rel release.Store, id string) { |
| + t.Helper() |
| + if _, err := provision.SetRecordRun(context.Background(), rel, "bab", "titogo", "acme.go.tito.io", |
| + 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.go.tito.io") |
| + 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.go.tito.io", 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.go.tito.io.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.go.tito.io") |
| + 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.go.tito.io") |
| + 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) |
| + } |
| +} |