| 1 | package console |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "encoding/json" |
| 6 | "net/http" |
| 7 | "strings" |
| 8 | "testing" |
| 9 | "time" |
| 10 | |
| 11 | "github.com/carlosframework/platform/internal/hostactors" |
| 12 | "github.com/carlosframework/platform/internal/hostclaim" |
| 13 | "github.com/carlosframework/platform/internal/hostfence" |
| 14 | "github.com/carlosframework/platform/internal/hostfloor" |
| 15 | "github.com/carlosframework/platform/internal/hostresolve" |
| 16 | "github.com/carlosframework/platform/internal/moves" |
| 17 | "github.com/carlosframework/platform/internal/provision" |
| 18 | "github.com/carlosframework/platform/internal/release" |
| 19 | "github.com/carlosframework/platform/internal/svcrole" |
| 20 | "github.com/superfly/ltx" |
| 21 | ) |
| 22 | |
| 23 | // J4 at the doors: on a claim-on, moves-on console the move resolve door |
| 24 | // inspects then declares a lost target, through the configured |
| 25 | // ClaimAuthority only; the host operator door routes the move kinds to the |
| 26 | // same implementation and keeps every other resolution refused. |
| 27 | |
| 28 | const j4Host = "acme.go.tito.io" |
| 29 | |
| 30 | // j4LandingMove is a host-claim move whose landing is probing on uk-box: |
| 31 | // two target sessions since the cut, a seal that did not cover the source. |
| 32 | func j4LandingMove(t *testing.T, code string) (*consoleFixture, string, string) { |
| 33 | t.Helper() |
| 34 | ctx := context.Background() |
| 35 | fx, token, moveURL, id := j17StartedMove(t, code) |
| 36 | fx.srv.cfg.ClaimAuthority.Signer = release.RootSigner(fx.priv) |
| 37 | obs, err := hostclaim.Read(ctx, fx.rel, j4Host) |
| 38 | if err != nil { |
| 39 | t.Fatal(err) |
| 40 | } |
| 41 | at := time.Now().UTC().Add(-time.Hour) |
| 42 | seal := moves.Seal{V: moves.SealVersion, N: 42, FromDB: true, SrcCovered: false, SealedAt: at, LeaseUntil: at.Add(time.Minute), |
| 43 | Replica: []moves.SealedObject{{Key: "moves/" + j4Host + "/" + id + "/replica/0009/" + ltx.FormatFilename(1, 42), VersionID: "v1", Size: int64(ltx.HeaderSize)}}, |
| 44 | Files: []moves.SealedObject{{Key: "moves/" + j4Host + "/" + id + "/files/archive", VersionID: "v2", Size: 10}}} |
| 45 | if _, err := hostclaim.Update(ctx, fx.rel, obs, func(c *hostclaim.Claim) error { |
| 46 | c.Op.Kind, c.Op.Phase, c.Op.Exec = hostclaim.KindLanding, "probing", "uk-box" |
| 47 | c.Op.Journal = map[string]string{hostclaim.JournalSeal: seal.Encode(), hostclaim.JournalCutSession: "3", hostclaim.JournalSourceStamp: "1", hostclaim.JournalSrcGen: "src", |
| 48 | hostclaim.JournalEvidenceSource: "moves/bab/titogo/" + j4Host + "/" + id + "/stopped-source.json"} |
| 49 | h := c.Holder |
| 50 | h.Generation, h.Session, h.WriterBox, h.Stamp = "mv", 5, "uk-box", c.Gen+1 |
| 51 | h.Admissions = []hostclaim.AdmissionRecord{ |
| 52 | {Session: 3, Box: "ie-box", Generation: "src", Region: "ie", PredecessorRegion: "ie", Mode: "cold"}, |
| 53 | {Session: 5, Box: "uk-box", Generation: "mv", Predecessor: "src", Region: "uk", PredecessorRegion: "ie", Mode: "cold", Coverage: "sealed"}, |
| 54 | } |
| 55 | return nil |
| 56 | }); err != nil { |
| 57 | t.Fatal(err) |
| 58 | } |
| 59 | flipped, deadline := at, at.Add(2*time.Hour) |
| 60 | j17SetRecord(t, fx, func(r *provision.Record) { |
| 61 | r.Region = "uk" |
| 62 | r.Move.State, r.Move.CutPhase, r.Move.LandingPhase = provision.MoveLanding, provision.CutFlipped, provision.LandProbing |
| 63 | r.Move.FlippedAt, r.Move.LandingDeadline = &flipped, &deadline |
| 64 | r.Move.Inputs = &provision.MoveInputs{Release: "r1", BundleETags: map[string]string{"b": "e"}} |
| 65 | }) |
| 66 | return fx, token, moveURL |
| 67 | } |
| 68 | |
| 69 | func j4Submission(t *testing.T, fx *consoleFixture, body string) hostfence.Submission { |
| 70 | t.Helper() |
| 71 | var in hostresolve.Inspection |
| 72 | if err := json.Unmarshal([]byte(body), &in); err != nil || in.Details.ID == "" { |
| 73 | t.Fatalf("inspection = %s (%v)", body, err) |
| 74 | } |
| 75 | roster, err := hostactors.Directory{Store: fx.rel, PublicKey: fx.pub, Bucket: moveTestBucket}.Read(context.Background()) |
| 76 | if err != nil { |
| 77 | t.Fatal(err) |
| 78 | } |
| 79 | b := roster.Roster.Boxes[in.Details.Box] |
| 80 | time.Sleep(1100 * time.Millisecond) |
| 81 | return hostfence.Submission{Challenge: in.Challenge, Confirmation: in.Details.ID, |
| 82 | Observation: hostfence.CloudObservation{ObservedAt: in.Details.IssuedAt.Add(time.Second), Account: b.Cloud.Account, Region: b.Cloud.Region, InstanceID: b.Cloud.InstanceID, State: "stopped", RequestID: "request"}, |
| 83 | Attestation: hostfence.OperatorAttestation{MaintainComputeFence: true, ColdQuarantineStartOnly: true, Disks: in.Details.Consequences.DestroyedStorageAssertion}} |
| 84 | } |
| 85 | |
| 86 | func j4Body(t *testing.T, v any) string { |
| 87 | t.Helper() |
| 88 | b, err := json.Marshal(v) |
| 89 | if err != nil { |
| 90 | t.Fatal(err) |
| 91 | } |
| 92 | return string(b) |
| 93 | } |
| 94 | |
| 95 | func j4World(t *testing.T, fx *consoleFixture) string { |
| 96 | t.Helper() |
| 97 | claim := claimBytes(t, fx, j4Host) |
| 98 | rec, err := provision.GetRecord(context.Background(), fx.rel, "bab", "titogo", j4Host) |
| 99 | if err != nil { |
| 100 | t.Fatal(err) |
| 101 | } |
| 102 | r, _ := json.Marshal(rec.Move) |
| 103 | roster, _, _ := fx.rel.Get(context.Background(), hostactors.Key) |
| 104 | return claim + "|" + string(r) + "|" + string(roster) |
| 105 | } |
| 106 | |
| 107 | func TestJ4MoveDoorInspectsThenDeclares(t *testing.T) { |
| 108 | fx, token, moveURL := j4LandingMove(t, "code-j4-declare") |
| 109 | before := j4World(t, fx) |
| 110 | code, _, out := rawReq(t, http.MethodPost, moveURL+"/resolve", token, `{"declare_lost":"uk-box"}`) |
| 111 | if code != http.StatusOK { |
| 112 | t.Fatalf("inspect = %d %s", code, out) |
| 113 | } |
| 114 | if j4World(t, fx) != before { |
| 115 | t.Fatal("the inspection wrote") |
| 116 | } |
| 117 | sub := j4Submission(t, fx, out) |
| 118 | code, _, out = rawReq(t, http.MethodPost, moveURL+"/resolve", token, j4Body(t, map[string]any{"declare_lost": "uk-box", "submission": sub})) |
| 119 | if code != http.StatusAccepted { |
| 120 | t.Fatalf("declare = %d %s", code, out) |
| 121 | } |
| 122 | rec, err := provision.GetRecord(context.Background(), fx.rel, "bab", "titogo", j4Host) |
| 123 | if err != nil { |
| 124 | t.Fatal(err) |
| 125 | } |
| 126 | if r := rec.Move.Rollback; rec.Move.State != provision.MoveRollingBack || r == nil || r.Reason != provision.RollbackDeclaredLost || r.TargetPhase != provision.RbTargetReleased || r.Dirty || r.Evidence == "" { |
| 127 | t.Fatalf("record move = %+v", rec.Move) |
| 128 | } |
| 129 | obs, err := hostclaim.Read(context.Background(), fx.rel, j4Host) |
| 130 | if err != nil || obs.Claim.Op.Kind != hostclaim.KindRestoring || obs.Claim.Op.Phase != hostclaim.PhaseReleased || len(obs.Claim.LossRefs) != 1 { |
| 131 | t.Fatalf("claim op = %+v %v", obs.Claim.Op, err) |
| 132 | } |
| 133 | // The same request again replays and writes nothing. |
| 134 | after := j4World(t, fx) |
| 135 | if code, _, out := rawReq(t, http.MethodPost, moveURL+"/resolve", token, j4Body(t, map[string]any{"declare_lost": "uk-box", "submission": sub})); code != http.StatusAccepted { |
| 136 | t.Fatalf("replay = %d %s", code, out) |
| 137 | } |
| 138 | if j4World(t, fx) != after { |
| 139 | t.Fatal("a replay wrote") |
| 140 | } |
| 141 | } |
| 142 | |
| 143 | func TestJ4MoveDoorRejectsAmbiguousInput(t *testing.T) { |
| 144 | fx, token, moveURL := j4LandingMove(t, "code-j4-ambiguous") |
| 145 | before := j4World(t, fx) |
| 146 | for _, body := range []string{ |
| 147 | `{"retry":true,"declare_lost":"uk-box"}`, |
| 148 | `{"retry":true,"submission":{}}`, |
| 149 | `{"submission":{}}`, |
| 150 | `{"declare_lost":"uk-box","box":"ie-box"}`, |
| 151 | } { |
| 152 | if code, _, out := rawReq(t, http.MethodPost, moveURL+"/resolve", token, body); code != http.StatusBadRequest { |
| 153 | t.Fatalf("%s = %d %s", body, code, out) |
| 154 | } |
| 155 | } |
| 156 | if code, _, out := rawReq(t, http.MethodPost, moveURL+"/resolve", token, `{}`); code != http.StatusConflict || errorText(out) != "nothing to resolve yet" { |
| 157 | t.Fatalf("empty = %d %s", code, out) |
| 158 | } |
| 159 | if j4World(t, fx) != before { |
| 160 | t.Fatal("a rejected request wrote") |
| 161 | } |
| 162 | } |
| 163 | |
| 164 | // The source, and a committed move, answer recorded sentences and write |
| 165 | // nothing. |
| 166 | func TestJ4MoveDoorRefusals(t *testing.T) { |
| 167 | fx, token, moveURL := j4LandingMove(t, "code-j4-refusals") |
| 168 | before := j4World(t, fx) |
| 169 | code, _, out := rawReq(t, http.MethodPost, moveURL+"/resolve", token, `{"declare_lost":"ie-box"}`) |
| 170 | if code != http.StatusConflict || errorText(out) != "this move's state does not allow that. read its state and try again" { |
| 171 | t.Fatalf("source = %d %s", code, out) |
| 172 | } |
| 173 | j17SetRecord(t, fx, func(r *provision.Record) { |
| 174 | r.Move.State = provision.MoveCommitted |
| 175 | at := time.Now() |
| 176 | r.Move.CommittedAt = &at |
| 177 | }) |
| 178 | code, _, out = rawReq(t, http.MethodPost, moveURL+"/resolve", token, `{"declare_lost":"uk-box"}`) |
| 179 | if code != http.StatusConflict || errorText(out) != provision.RefusedCommitted { |
| 180 | t.Fatalf("committed = %d %s", code, out) |
| 181 | } |
| 182 | if after := j4World(t, fx); strings.Split(after, "|")[0] != strings.Split(before, "|")[0] { |
| 183 | t.Fatal("a refusal wrote the claim") |
| 184 | } |
| 185 | } |
| 186 | |
| 187 | // A stale submission (the record moved on after the inspection) is refused |
| 188 | // before acceptance: no quarantine. |
| 189 | func TestJ4MoveDoorStaleSubmission(t *testing.T) { |
| 190 | fx, token, moveURL := j4LandingMove(t, "code-j4-stale") |
| 191 | _, _, out := rawReq(t, http.MethodPost, moveURL+"/resolve", token, `{"declare_lost":"uk-box"}`) |
| 192 | sub := j4Submission(t, fx, out) |
| 193 | j17SetRecord(t, fx, func(r *provision.Record) { r.Move.LandingPhase = provision.LandProbed }) |
| 194 | before := j4World(t, fx) |
| 195 | code, _, out := rawReq(t, http.MethodPost, moveURL+"/resolve", token, j4Body(t, map[string]any{"declare_lost": "uk-box", "submission": sub})) |
| 196 | if code != http.StatusConflict || errorText(out) != moveChangedSentence { |
| 197 | t.Fatalf("stale = %d %s", code, out) |
| 198 | } |
| 199 | if j4World(t, fx) != before { |
| 200 | t.Fatal("a stale declaration wrote") |
| 201 | } |
| 202 | } |
| 203 | |
| 204 | // Without the claim runtime the door keeps HC5-N1 for a declaration too. |
| 205 | func TestJ4MoveDoorWithoutRuntime(t *testing.T) { |
| 206 | fx, token, moveURL := j4LandingMove(t, "code-j4-noruntime") |
| 207 | fx.srv.cfg.ClaimAuthority = nil |
| 208 | if code, _, out := rawReq(t, http.MethodPost, moveURL+"/resolve", token, `{"declare_lost":"uk-box"}`); code != http.StatusConflict || errorText(out) != claimMoveUnsupportedMsg { |
| 209 | t.Fatalf("no runtime = %d %s", code, out) |
| 210 | } |
| 211 | } |
| 212 | |
| 213 | // The host operator door: a move-kind claim's declaration runs the same |
| 214 | // implementation; a serving claim's, and every readmit, keep the refusal. |
| 215 | func TestJ4HostDoorRoutesOnlyMoveKinds(t *testing.T) { |
| 216 | ctx := context.Background() |
| 217 | fx, token, _ := j4LandingMove(t, "code-j4-hostdoor") |
| 218 | fx.srv.cfg.Bucket = moveTestBucket |
| 219 | // NewServer registers the host routes only with HostClaim on at build. |
| 220 | fx.srv.cliRoute("POST /api/cli/ops/hosts/{host}/resolve/inspect", svcrole.Set{}, fx.srv.handleAPIHostResolveInspect) |
| 221 | fx.srv.cliRoute("POST /api/cli/ops/hosts/{host}/resolve", svcrole.Set{}, fx.srv.handleAPIHostResolve) |
| 222 | if err := hostfloor.Publish(ctx, fx.rel, release.RootSigner(fx.priv), moveTestBucket, hostfloor.Floor{Min: 1, MinRelease: "v1.0.0"}); err != nil { |
| 223 | t.Fatal(err) |
| 224 | } |
| 225 | base := fx.ts.URL + "/api/cli/ops/hosts/" + j4Host + "/resolve" |
| 226 | code, _, out := rawReq(t, http.MethodPost, base+"/inspect", token, `{"action":"declare-lost","box":"uk-box"}`) |
| 227 | if code != http.StatusOK { |
| 228 | t.Fatalf("host inspect = %d %s", code, out) |
| 229 | } |
| 230 | sub := j4Submission(t, fx, out) |
| 231 | if code, _, out := rawReq(t, http.MethodPost, base, token, `{"action":"readmit","box":"uk-box"}`); code != http.StatusConflict || errorText(out) != claimHostResolveUnsupportedMsg { |
| 232 | t.Fatalf("readmit = %d %s", code, out) |
| 233 | } |
| 234 | code, _, out = rawReq(t, http.MethodPost, base, token, j4Body(t, map[string]any{"action": "declare-lost", "submission": sub})) |
| 235 | if code != http.StatusOK { |
| 236 | t.Fatalf("host declare = %d %s", code, out) |
| 237 | } |
| 238 | obs, err := hostclaim.Read(ctx, fx.rel, j4Host) |
| 239 | if err != nil || obs.Claim.Op.Phase != hostclaim.PhaseReleased { |
| 240 | t.Fatalf("claim = %+v %v", obs.Claim.Op, err) |
| 241 | } |
| 242 | // A serving host keeps the refusal. |
| 243 | if _, err := hostclaim.Update(ctx, fx.rel, obs, func(c *hostclaim.Claim) error { |
| 244 | c.Op = hostclaim.Op{Kind: hostclaim.KindServing, Exec: "ie-box"} |
| 245 | c.Obligations = nil |
| 246 | return nil |
| 247 | }); err != nil { |
| 248 | t.Fatal(err) |
| 249 | } |
| 250 | if code, _, out := rawReq(t, http.MethodPost, base, token, j4Body(t, map[string]any{"action": "declare-lost", "submission": sub})); code != http.StatusConflict || errorText(out) != claimHostResolveUnsupportedMsg { |
| 251 | t.Fatalf("serving declare = %d %s", code, out) |
| 252 | } |
| 253 | } |
| 254 | |