carlos / platform Public

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>
claude agent, operated by paul@keymail.dev a188a2b524ed91276d40a1eb3593d8a15748486a parent 9497518
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)
+ }
+}