| 1 | package console |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "encoding/json" |
| 6 | "net/http" |
| 7 | "testing" |
| 8 | |
| 9 | "github.com/carlosframework/platform/internal/hostclaim" |
| 10 | "github.com/carlosframework/platform/internal/hostops" |
| 11 | "github.com/carlosframework/platform/internal/provision" |
| 12 | ) |
| 13 | |
| 14 | // J17 at the door: on a host-claim console with its runtime configured, the |
| 15 | // cut, abort and retry doors run the typed ControlWriter transition through |
| 16 | // Config.ClaimAuthority, bound to the claim and record they read. |
| 17 | |
| 18 | // j17StartedMove starts a move through the host-claim request door and |
| 19 | // returns its id. The record is preparing, the claim moving/"". |
| 20 | func j17StartedMove(t *testing.T, code string) (*consoleFixture, string, string, string) { |
| 21 | t.Helper() |
| 22 | fx, token, moveURL := moveDoorWithClaim(t, code) |
| 23 | armClaimMoves(t, fx, true, true, nil) |
| 24 | if c, _, out := rawReq(t, http.MethodPost, moveURL, token, `{"region":"uk","cut":"manual"}`); c != http.StatusAccepted { |
| 25 | t.Fatalf("request = %d %s", c, out) |
| 26 | } |
| 27 | rec, err := provision.GetRecord(context.Background(), fx.rel, "bab", "titogo", "acme.go.tito.io") |
| 28 | if err != nil { |
| 29 | t.Fatal(err) |
| 30 | } |
| 31 | return fx, token, moveURL, rec.Move.ID |
| 32 | } |
| 33 | |
| 34 | // j17SetRecord writes the record's move journal under the raw bucket, as a |
| 35 | // box's own worker would. |
| 36 | func j17SetRecord(t *testing.T, fx *consoleFixture, mut func(*provision.Record)) { |
| 37 | t.Helper() |
| 38 | if _, err := provision.SetRecordMove(provision.WithRecordSequence(context.Background()), fx.rel, "bab", "titogo", "acme.go.tito.io", func(r *provision.Record) error { |
| 39 | mut(r) |
| 40 | return nil |
| 41 | }); err != nil { |
| 42 | t.Fatal(err) |
| 43 | } |
| 44 | } |
| 45 | |
| 46 | func j17Ready(r *provision.Record) { |
| 47 | r.Move.State = provision.MoveReady |
| 48 | r.Move.Inputs = &provision.MoveInputs{Release: "r1", BundleETags: map[string]string{"b": "e"}} |
| 49 | } |
| 50 | |
| 51 | func j17MoveState(t *testing.T, fx *consoleFixture) *provision.MoveState { |
| 52 | t.Helper() |
| 53 | rec, err := provision.GetRecord(context.Background(), fx.rel, "bab", "titogo", "acme.go.tito.io") |
| 54 | if err != nil { |
| 55 | t.Fatal(err) |
| 56 | } |
| 57 | return rec.Move |
| 58 | } |
| 59 | |
| 60 | func TestJ17DoorCutUnderHostClaim(t *testing.T) { |
| 61 | fx, token, moveURL, _ := j17StartedMove(t, "code-j17-cut") |
| 62 | j17SetRecord(t, fx, j17Ready) |
| 63 | claim := claimBytes(t, fx, "acme.go.tito.io") |
| 64 | code, _, out := rawReq(t, http.MethodPost, moveURL+"/cut", token, "") |
| 65 | if code != http.StatusAccepted { |
| 66 | t.Fatalf("cut = %d %s", code, out) |
| 67 | } |
| 68 | if m := j17MoveState(t, fx); m.State != provision.MoveCutting || m.CutPhase != provision.CutRequested { |
| 69 | t.Fatalf("record move = %+v", m) |
| 70 | } |
| 71 | var view map[string]any |
| 72 | if json.Unmarshal([]byte(out), &view) != nil || view["state"] != provision.MoveCutting { |
| 73 | t.Fatalf("answer = %s", out) |
| 74 | } |
| 75 | if claimBytes(t, fx, "acme.go.tito.io") != claim { |
| 76 | t.Fatal("the cut door wrote the claim") |
| 77 | } |
| 78 | } |
| 79 | |
| 80 | func TestJ17DoorAbortUnderHostClaim(t *testing.T) { |
| 81 | fx, token, moveURL, _ := j17StartedMove(t, "code-j17-abort") |
| 82 | claim := claimBytes(t, fx, "acme.go.tito.io") |
| 83 | if code, _, out := rawReq(t, http.MethodDelete, moveURL, token, ""); code != http.StatusAccepted { |
| 84 | t.Fatalf("abort = %d %s", code, out) |
| 85 | } |
| 86 | if m := j17MoveState(t, fx); m.State != provision.MoveAborting { |
| 87 | t.Fatalf("record move = %+v", m) |
| 88 | } |
| 89 | if claimBytes(t, fx, "acme.go.tito.io") != claim { |
| 90 | t.Fatal("the abort door wrote the claim") |
| 91 | } |
| 92 | } |
| 93 | |
| 94 | func TestJ17DoorRetryUnderHostClaim(t *testing.T) { |
| 95 | ctx := context.Background() |
| 96 | fx, token, moveURL, id := j17StartedMove(t, "code-j17-retry") |
| 97 | obs, err := hostclaim.Read(ctx, fx.rel, "acme.go.tito.io") |
| 98 | if err != nil { |
| 99 | t.Fatal(err) |
| 100 | } |
| 101 | rb := "rb_abcdefghijklmnopqrstuvwxyz" |
| 102 | if _, err := hostclaim.Update(ctx, fx.rel, obs, func(c *hostclaim.Claim) error { |
| 103 | c.Op.Kind, c.Op.Phase = hostclaim.KindRestoring, hostclaim.PhaseProbing |
| 104 | c.Op.Intent["rollback"] = rb |
| 105 | return nil |
| 106 | }); err != nil { |
| 107 | t.Fatal(err) |
| 108 | } |
| 109 | j17SetRecord(t, fx, func(r *provision.Record) { |
| 110 | r.Move.State, r.Move.StuckReason = provision.MoveStuck, provision.StuckRecoveryProbeAttempts |
| 111 | r.Move.Rollback = &provision.MoveRollback{ID: rb, TargetPhase: provision.RbTargetReleased, SourcePhase: provision.RbSourceProbing, StuckAt: provision.RbSourceProbing} |
| 112 | }) |
| 113 | claim := claimBytes(t, fx, "acme.go.tito.io") |
| 114 | code, _, out := rawReq(t, http.MethodPost, moveURL+"/resolve", token, `{"retry":true}`) |
| 115 | if code != http.StatusAccepted { |
| 116 | t.Fatalf("retry = %d %s", code, out) |
| 117 | } |
| 118 | if m := j17MoveState(t, fx); m.ID != id || m.State != provision.MoveRollingBack || m.StuckReason != "" || m.Rollback.SourcePhase != provision.RbSourceProbing { |
| 119 | t.Fatalf("record move = %+v", m) |
| 120 | } |
| 121 | if claimBytes(t, fx, "acme.go.tito.io") != claim { |
| 122 | t.Fatal("the retry door wrote the claim") |
| 123 | } |
| 124 | } |
| 125 | |
| 126 | // A refusal the transition records is its recorded sentence; nothing is |
| 127 | // written. |
| 128 | func TestJ17DoorRecordedRefusal(t *testing.T) { |
| 129 | fx, token, moveURL, _ := j17StartedMove(t, "code-j17-refusal") |
| 130 | before := j17MoveState(t, fx) |
| 131 | code, _, out := rawReq(t, http.MethodPost, moveURL+"/cut", token, "") |
| 132 | if code != http.StatusConflict || errorText(out) != "cut is only legal from ready" { |
| 133 | t.Fatalf("cut of a preparing move = %d %s", code, out) |
| 134 | } |
| 135 | if after := j17MoveState(t, fx); after.State != before.State || !after.UpdatedAt.Equal(before.UpdatedAt) { |
| 136 | t.Fatal("a refused cut wrote the record") |
| 137 | } |
| 138 | code, _, out = rawReq(t, http.MethodPost, moveURL+"/resolve", token, `{"retry":true}`) |
| 139 | if code != http.StatusConflict || errorText(out) != moveChangedSentence { |
| 140 | t.Fatalf("retry of a moving claim = %d %s", code, out) |
| 141 | } |
| 142 | } |
| 143 | |
| 144 | // Without the move's registration nothing authorizes the record write: the |
| 145 | // door answers the fixed sentence and writes nothing. |
| 146 | func TestJ17DoorRefusesWithoutRegistration(t *testing.T) { |
| 147 | ctx := context.Background() |
| 148 | fx, token, moveURL, id := j17StartedMove(t, "code-j17-noreg") |
| 149 | j17SetRecord(t, fx, j17Ready) |
| 150 | a, err := hostops.ReadAdmission(ctx, fx.rel, "bab", "titogo", claimMoveAppID) |
| 151 | if err != nil { |
| 152 | t.Fatal(err) |
| 153 | } |
| 154 | delete(a.Value.Registrations, id) |
| 155 | b, _ := json.Marshal(a.Value) |
| 156 | if _, err := fx.rel.Put(ctx, a.Key, b, a.ETag); err != nil { |
| 157 | t.Fatal(err) |
| 158 | } |
| 159 | before := j17MoveState(t, fx) |
| 160 | code, _, out := rawReq(t, http.MethodPost, moveURL+"/cut", token, "") |
| 161 | if code != http.StatusConflict || errorText(out) != moveChangedSentence { |
| 162 | t.Fatalf("cut without registration = %d %s", code, out) |
| 163 | } |
| 164 | if after := j17MoveState(t, fx); after.State != before.State { |
| 165 | t.Fatal("refused cut wrote the record") |
| 166 | } |
| 167 | } |
| 168 | |
| 169 | // The plain (flag-on, claim-off) doors are untouched: they still write |
| 170 | // through SetRecordMove (covered by the Phase 1 door tests), and a host-claim |
| 171 | // console without its runtime still refuses with HC5-N1 |
| 172 | // (TestMoveDoorPhase1RefusedUnderHostClaim, TestResolveRetryRefusedUnderHostClaim). |
| 173 | |
| 174 | // Final review I5 (preflight L5, claim-on half): on a host-claim console, |
| 175 | // once the cut has started (cutting past requested, landing, committed, |
| 176 | // rolling back, reverting) the abort door answers M-C13 before the typed |
| 177 | // grant, which after the seal would refuse with "the move changed". Nothing |
| 178 | // is written. A stuck move keeps the grant's own answer, and an abort from |
| 179 | // cutting/requested before the seal is still the typed abort. |
| 180 | func TestJ17DoorAbortAfterTheCutStartedSaysSo(t *testing.T) { |
| 181 | ctx := context.Background() |
| 182 | fx, token, moveURL, _ := j17StartedMove(t, "code-j17-mc13") |
| 183 | setPhase := func(phase string) { |
| 184 | t.Helper() |
| 185 | obs, err := hostclaim.Read(ctx, fx.rel, "acme.go.tito.io") |
| 186 | if err != nil { |
| 187 | t.Fatal(err) |
| 188 | } |
| 189 | if _, err := hostclaim.Update(ctx, fx.rel, obs, func(c *hostclaim.Claim) error { |
| 190 | c.Op.Phase = phase |
| 191 | return nil |
| 192 | }); err != nil { |
| 193 | t.Fatal(err) |
| 194 | } |
| 195 | } |
| 196 | setPhase(hostclaim.PhaseSealed) |
| 197 | for _, c := range []struct { |
| 198 | name string |
| 199 | mut func(*provision.MoveState) |
| 200 | }{ |
| 201 | {"cutting/admitted", func(m *provision.MoveState) { m.State, m.CutPhase = provision.MoveCutting, provision.CutAdmitted }}, |
| 202 | {"cutting/staged", func(m *provision.MoveState) { m.State, m.CutPhase = provision.MoveCutting, provision.CutStaged }}, |
| 203 | {"landing", func(m *provision.MoveState) { m.State = provision.MoveLanding }}, |
| 204 | {"committed", func(m *provision.MoveState) { m.State = provision.MoveCommitted }}, |
| 205 | {"rolling back", func(m *provision.MoveState) { m.State = provision.MoveRollingBack }}, |
| 206 | {"reverting", func(m *provision.MoveState) { m.State = provision.MoveReverting }}, |
| 207 | } { |
| 208 | j17SetRecord(t, fx, func(r *provision.Record) { c.mut(r.Move) }) |
| 209 | claim, before := claimBytes(t, fx, "acme.go.tito.io"), j17MoveState(t, fx) |
| 210 | if code, _, out := rawReq(t, http.MethodDelete, moveURL, token, ""); code != http.StatusConflict || errorText(out) != moveCutStartedSentence { |
| 211 | t.Fatalf("abort in %s = %d %s, want M-C13", c.name, code, out) |
| 212 | } |
| 213 | if after := j17MoveState(t, fx); after.State != before.State || !after.UpdatedAt.Equal(before.UpdatedAt) || claimBytes(t, fx, "acme.go.tito.io") != claim { |
| 214 | t.Fatalf("a refused abort in %s wrote", c.name) |
| 215 | } |
| 216 | } |
| 217 | j17SetRecord(t, fx, func(r *provision.Record) { |
| 218 | r.Move.State, r.Move.CutPhase, r.Move.StuckReason = provision.MoveStuck, provision.CutArchived, provision.StuckBaselineMismatch |
| 219 | }) |
| 220 | if code, _, out := rawReq(t, http.MethodDelete, moveURL, token, ""); code != http.StatusConflict || errorText(out) == moveCutStartedSentence { |
| 221 | t.Fatalf("abort of a stuck move = %d %s, want the grant's own refusal", code, out) |
| 222 | } |
| 223 | setPhase(hostclaim.PhaseMoving) |
| 224 | j17SetRecord(t, fx, func(r *provision.Record) { |
| 225 | r.Move.State, r.Move.CutPhase, r.Move.StuckReason = provision.MoveCutting, provision.CutRequested, "" |
| 226 | }) |
| 227 | if code, _, out := rawReq(t, http.MethodDelete, moveURL, token, ""); code != http.StatusAccepted { |
| 228 | t.Fatalf("abort from cutting/requested = %d %s", code, out) |
| 229 | } |
| 230 | if m := j17MoveState(t, fx); m.State != provision.MoveAborting { |
| 231 | t.Fatalf("record move = %+v", m) |
| 232 | } |
| 233 | } |
| 234 | |