package console import ( "context" "encoding/json" "net/http" "testing" "github.com/carlosframework/platform/internal/hostclaim" "github.com/carlosframework/platform/internal/hostops" "github.com/carlosframework/platform/internal/provision" ) // J17 at the door: on a host-claim console with its runtime configured, the // cut, abort and retry doors run the typed ControlWriter transition through // Config.ClaimAuthority, bound to the claim and record they read. // j17StartedMove starts a move through the host-claim request door and // returns its id. The record is preparing, the claim moving/"". func j17StartedMove(t *testing.T, code string) (*consoleFixture, string, string, string) { t.Helper() fx, token, moveURL := moveDoorWithClaim(t, code) armClaimMoves(t, fx, true, true, nil) if c, _, out := rawReq(t, http.MethodPost, moveURL, token, `{"region":"uk","cut":"manual"}`); c != http.StatusAccepted { t.Fatalf("request = %d %s", c, out) } rec, err := provision.GetRecord(context.Background(), fx.rel, "bab", "titogo", "acme.go.tito.io") if err != nil { t.Fatal(err) } return fx, token, moveURL, rec.Move.ID } // j17SetRecord writes the record's move journal under the raw bucket, as a // box's own worker would. func j17SetRecord(t *testing.T, fx *consoleFixture, mut func(*provision.Record)) { t.Helper() if _, err := provision.SetRecordMove(provision.WithRecordSequence(context.Background()), fx.rel, "bab", "titogo", "acme.go.tito.io", func(r *provision.Record) error { mut(r) return nil }); err != nil { t.Fatal(err) } } func j17Ready(r *provision.Record) { r.Move.State = provision.MoveReady r.Move.Inputs = &provision.MoveInputs{Release: "r1", BundleETags: map[string]string{"b": "e"}} } func j17MoveState(t *testing.T, fx *consoleFixture) *provision.MoveState { t.Helper() rec, err := provision.GetRecord(context.Background(), fx.rel, "bab", "titogo", "acme.go.tito.io") if err != nil { t.Fatal(err) } return rec.Move } func TestJ17DoorCutUnderHostClaim(t *testing.T) { fx, token, moveURL, _ := j17StartedMove(t, "code-j17-cut") j17SetRecord(t, fx, j17Ready) claim := claimBytes(t, fx, "acme.go.tito.io") code, _, out := rawReq(t, http.MethodPost, moveURL+"/cut", token, "") if code != http.StatusAccepted { t.Fatalf("cut = %d %s", code, out) } if m := j17MoveState(t, fx); m.State != provision.MoveCutting || m.CutPhase != provision.CutRequested { t.Fatalf("record move = %+v", m) } var view map[string]any if json.Unmarshal([]byte(out), &view) != nil || view["state"] != provision.MoveCutting { t.Fatalf("answer = %s", out) } if claimBytes(t, fx, "acme.go.tito.io") != claim { t.Fatal("the cut door wrote the claim") } } func TestJ17DoorAbortUnderHostClaim(t *testing.T) { fx, token, moveURL, _ := j17StartedMove(t, "code-j17-abort") claim := claimBytes(t, fx, "acme.go.tito.io") if code, _, out := rawReq(t, http.MethodDelete, moveURL, token, ""); code != http.StatusAccepted { t.Fatalf("abort = %d %s", code, out) } if m := j17MoveState(t, fx); m.State != provision.MoveAborting { t.Fatalf("record move = %+v", m) } if claimBytes(t, fx, "acme.go.tito.io") != claim { t.Fatal("the abort door wrote the claim") } } func TestJ17DoorRetryUnderHostClaim(t *testing.T) { ctx := context.Background() fx, token, moveURL, id := j17StartedMove(t, "code-j17-retry") obs, err := hostclaim.Read(ctx, fx.rel, "acme.go.tito.io") if err != nil { t.Fatal(err) } rb := "rb_abcdefghijklmnopqrstuvwxyz" if _, err := hostclaim.Update(ctx, fx.rel, obs, func(c *hostclaim.Claim) error { c.Op.Kind, c.Op.Phase = hostclaim.KindRestoring, hostclaim.PhaseProbing c.Op.Intent["rollback"] = rb return nil }); err != nil { t.Fatal(err) } j17SetRecord(t, fx, func(r *provision.Record) { r.Move.State, r.Move.StuckReason = provision.MoveStuck, provision.StuckRecoveryProbeAttempts r.Move.Rollback = &provision.MoveRollback{ID: rb, TargetPhase: provision.RbTargetReleased, SourcePhase: provision.RbSourceProbing, StuckAt: provision.RbSourceProbing} }) claim := claimBytes(t, fx, "acme.go.tito.io") code, _, out := rawReq(t, http.MethodPost, moveURL+"/resolve", token, `{"retry":true}`) if code != http.StatusAccepted { t.Fatalf("retry = %d %s", code, out) } if m := j17MoveState(t, fx); m.ID != id || m.State != provision.MoveRollingBack || m.StuckReason != "" || m.Rollback.SourcePhase != provision.RbSourceProbing { t.Fatalf("record move = %+v", m) } if claimBytes(t, fx, "acme.go.tito.io") != claim { t.Fatal("the retry door wrote the claim") } } // A refusal the transition records is its recorded sentence; nothing is // written. func TestJ17DoorRecordedRefusal(t *testing.T) { fx, token, moveURL, _ := j17StartedMove(t, "code-j17-refusal") before := j17MoveState(t, fx) code, _, out := rawReq(t, http.MethodPost, moveURL+"/cut", token, "") if code != http.StatusConflict || errorText(out) != "cut is only legal from ready" { t.Fatalf("cut of a preparing move = %d %s", code, out) } if after := j17MoveState(t, fx); after.State != before.State || !after.UpdatedAt.Equal(before.UpdatedAt) { t.Fatal("a refused cut wrote the record") } code, _, out = rawReq(t, http.MethodPost, moveURL+"/resolve", token, `{"retry":true}`) if code != http.StatusConflict || errorText(out) != moveChangedSentence { t.Fatalf("retry of a moving claim = %d %s", code, out) } } // Without the move's registration nothing authorizes the record write: the // door answers the fixed sentence and writes nothing. func TestJ17DoorRefusesWithoutRegistration(t *testing.T) { ctx := context.Background() fx, token, moveURL, id := j17StartedMove(t, "code-j17-noreg") j17SetRecord(t, fx, j17Ready) a, err := hostops.ReadAdmission(ctx, fx.rel, "bab", "titogo", claimMoveAppID) if err != nil { t.Fatal(err) } delete(a.Value.Registrations, id) b, _ := json.Marshal(a.Value) if _, err := fx.rel.Put(ctx, a.Key, b, a.ETag); err != nil { t.Fatal(err) } before := j17MoveState(t, fx) code, _, out := rawReq(t, http.MethodPost, moveURL+"/cut", token, "") if code != http.StatusConflict || errorText(out) != moveChangedSentence { t.Fatalf("cut without registration = %d %s", code, out) } if after := j17MoveState(t, fx); after.State != before.State { t.Fatal("refused cut wrote the record") } } // The plain (flag-on, claim-off) doors are untouched: they still write // through SetRecordMove (covered by the Phase 1 door tests), and a host-claim // console without its runtime still refuses with HC5-N1 // (TestMoveDoorPhase1RefusedUnderHostClaim, TestResolveRetryRefusedUnderHostClaim). // Final review I5 (preflight L5, claim-on half): on a host-claim console, // once the cut has started (cutting past requested, landing, committed, // rolling back, reverting) the abort door answers M-C13 before the typed // grant, which after the seal would refuse with "the move changed". Nothing // is written. A stuck move keeps the grant's own answer, and an abort from // cutting/requested before the seal is still the typed abort. func TestJ17DoorAbortAfterTheCutStartedSaysSo(t *testing.T) { ctx := context.Background() fx, token, moveURL, _ := j17StartedMove(t, "code-j17-mc13") setPhase := func(phase string) { t.Helper() obs, err := hostclaim.Read(ctx, fx.rel, "acme.go.tito.io") if err != nil { t.Fatal(err) } if _, err := hostclaim.Update(ctx, fx.rel, obs, func(c *hostclaim.Claim) error { c.Op.Phase = phase return nil }); err != nil { t.Fatal(err) } } setPhase(hostclaim.PhaseSealed) for _, c := range []struct { name string mut func(*provision.MoveState) }{ {"cutting/admitted", func(m *provision.MoveState) { m.State, m.CutPhase = provision.MoveCutting, provision.CutAdmitted }}, {"cutting/staged", func(m *provision.MoveState) { m.State, m.CutPhase = provision.MoveCutting, provision.CutStaged }}, {"landing", func(m *provision.MoveState) { m.State = provision.MoveLanding }}, {"committed", func(m *provision.MoveState) { m.State = provision.MoveCommitted }}, {"rolling back", func(m *provision.MoveState) { m.State = provision.MoveRollingBack }}, {"reverting", func(m *provision.MoveState) { m.State = provision.MoveReverting }}, } { j17SetRecord(t, fx, func(r *provision.Record) { c.mut(r.Move) }) claim, before := claimBytes(t, fx, "acme.go.tito.io"), j17MoveState(t, fx) if code, _, out := rawReq(t, http.MethodDelete, moveURL, token, ""); code != http.StatusConflict || errorText(out) != moveCutStartedSentence { t.Fatalf("abort in %s = %d %s, want M-C13", c.name, code, out) } if after := j17MoveState(t, fx); after.State != before.State || !after.UpdatedAt.Equal(before.UpdatedAt) || claimBytes(t, fx, "acme.go.tito.io") != claim { t.Fatalf("a refused abort in %s wrote", c.name) } } j17SetRecord(t, fx, func(r *provision.Record) { r.Move.State, r.Move.CutPhase, r.Move.StuckReason = provision.MoveStuck, provision.CutArchived, provision.StuckBaselineMismatch }) if code, _, out := rawReq(t, http.MethodDelete, moveURL, token, ""); code != http.StatusConflict || errorText(out) == moveCutStartedSentence { t.Fatalf("abort of a stuck move = %d %s, want the grant's own refusal", code, out) } setPhase(hostclaim.PhaseMoving) j17SetRecord(t, fx, func(r *provision.Record) { r.Move.State, r.Move.CutPhase, r.Move.StuckReason = provision.MoveCutting, provision.CutRequested, "" }) if code, _, out := rawReq(t, http.MethodDelete, moveURL, token, ""); code != http.StatusAccepted { t.Fatalf("abort from cutting/requested = %d %s", code, out) } if m := j17MoveState(t, fx); m.State != provision.MoveAborting { t.Fatalf("record move = %+v", m) } }