package console import ( "context" "encoding/json" "net/http" "strings" "testing" "time" "github.com/carlosframework/platform/internal/hostactors" "github.com/carlosframework/platform/internal/hostclaim" "github.com/carlosframework/platform/internal/hostfence" "github.com/carlosframework/platform/internal/hostfloor" "github.com/carlosframework/platform/internal/hostresolve" "github.com/carlosframework/platform/internal/moves" "github.com/carlosframework/platform/internal/provision" "github.com/carlosframework/platform/internal/release" "github.com/carlosframework/platform/internal/svcrole" "github.com/superfly/ltx" ) // J4 at the doors: on a claim-on, moves-on console the move resolve door // inspects then declares a lost target, through the configured // ClaimAuthority only; the host operator door routes the move kinds to the // same implementation and keeps every other resolution refused. const j4Host = "acme.go.tito.io" // j4LandingMove is a host-claim move whose landing is probing on uk-box: // two target sessions since the cut, a seal that did not cover the source. func j4LandingMove(t *testing.T, code string) (*consoleFixture, string, string) { t.Helper() ctx := context.Background() fx, token, moveURL, id := j17StartedMove(t, code) fx.srv.cfg.ClaimAuthority.Signer = release.RootSigner(fx.priv) obs, err := hostclaim.Read(ctx, fx.rel, j4Host) if err != nil { t.Fatal(err) } at := time.Now().UTC().Add(-time.Hour) seal := moves.Seal{V: moves.SealVersion, N: 42, FromDB: true, SrcCovered: false, SealedAt: at, LeaseUntil: at.Add(time.Minute), Replica: []moves.SealedObject{{Key: "moves/" + j4Host + "/" + id + "/replica/0009/" + ltx.FormatFilename(1, 42), VersionID: "v1", Size: int64(ltx.HeaderSize)}}, Files: []moves.SealedObject{{Key: "moves/" + j4Host + "/" + id + "/files/archive", VersionID: "v2", Size: 10}}} if _, err := hostclaim.Update(ctx, fx.rel, obs, func(c *hostclaim.Claim) error { c.Op.Kind, c.Op.Phase, c.Op.Exec = hostclaim.KindLanding, "probing", "uk-box" c.Op.Journal = map[string]string{hostclaim.JournalSeal: seal.Encode(), hostclaim.JournalCutSession: "3", hostclaim.JournalSourceStamp: "1", hostclaim.JournalSrcGen: "src", hostclaim.JournalEvidenceSource: "moves/bab/titogo/" + j4Host + "/" + id + "/stopped-source.json"} h := c.Holder h.Generation, h.Session, h.WriterBox, h.Stamp = "mv", 5, "uk-box", c.Gen+1 h.Admissions = []hostclaim.AdmissionRecord{ {Session: 3, Box: "ie-box", Generation: "src", Region: "ie", PredecessorRegion: "ie", Mode: "cold"}, {Session: 5, Box: "uk-box", Generation: "mv", Predecessor: "src", Region: "uk", PredecessorRegion: "ie", Mode: "cold", Coverage: "sealed"}, } return nil }); err != nil { t.Fatal(err) } flipped, deadline := at, at.Add(2*time.Hour) j17SetRecord(t, fx, func(r *provision.Record) { r.Region = "uk" r.Move.State, r.Move.CutPhase, r.Move.LandingPhase = provision.MoveLanding, provision.CutFlipped, provision.LandProbing r.Move.FlippedAt, r.Move.LandingDeadline = &flipped, &deadline r.Move.Inputs = &provision.MoveInputs{Release: "r1", BundleETags: map[string]string{"b": "e"}} }) return fx, token, moveURL } func j4Submission(t *testing.T, fx *consoleFixture, body string) hostfence.Submission { t.Helper() var in hostresolve.Inspection if err := json.Unmarshal([]byte(body), &in); err != nil || in.Details.ID == "" { t.Fatalf("inspection = %s (%v)", body, err) } roster, err := hostactors.Directory{Store: fx.rel, PublicKey: fx.pub, Bucket: moveTestBucket}.Read(context.Background()) if err != nil { t.Fatal(err) } b := roster.Roster.Boxes[in.Details.Box] time.Sleep(1100 * time.Millisecond) return hostfence.Submission{Challenge: in.Challenge, Confirmation: in.Details.ID, 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"}, Attestation: hostfence.OperatorAttestation{MaintainComputeFence: true, ColdQuarantineStartOnly: true, Disks: in.Details.Consequences.DestroyedStorageAssertion}} } func j4Body(t *testing.T, v any) string { t.Helper() b, err := json.Marshal(v) if err != nil { t.Fatal(err) } return string(b) } func j4World(t *testing.T, fx *consoleFixture) string { t.Helper() claim := claimBytes(t, fx, j4Host) rec, err := provision.GetRecord(context.Background(), fx.rel, "bab", "titogo", j4Host) if err != nil { t.Fatal(err) } r, _ := json.Marshal(rec.Move) roster, _, _ := fx.rel.Get(context.Background(), hostactors.Key) return claim + "|" + string(r) + "|" + string(roster) } func TestJ4MoveDoorInspectsThenDeclares(t *testing.T) { fx, token, moveURL := j4LandingMove(t, "code-j4-declare") before := j4World(t, fx) code, _, out := rawReq(t, http.MethodPost, moveURL+"/resolve", token, `{"declare_lost":"uk-box"}`) if code != http.StatusOK { t.Fatalf("inspect = %d %s", code, out) } if j4World(t, fx) != before { t.Fatal("the inspection wrote") } sub := j4Submission(t, fx, out) code, _, out = rawReq(t, http.MethodPost, moveURL+"/resolve", token, j4Body(t, map[string]any{"declare_lost": "uk-box", "submission": sub})) if code != http.StatusAccepted { t.Fatalf("declare = %d %s", code, out) } rec, err := provision.GetRecord(context.Background(), fx.rel, "bab", "titogo", j4Host) if err != nil { t.Fatal(err) } if r := rec.Move.Rollback; rec.Move.State != provision.MoveRollingBack || r == nil || r.Reason != provision.RollbackDeclaredLost || r.TargetPhase != provision.RbTargetReleased || r.Dirty || r.Evidence == "" { t.Fatalf("record move = %+v", rec.Move) } obs, err := hostclaim.Read(context.Background(), fx.rel, j4Host) if err != nil || obs.Claim.Op.Kind != hostclaim.KindRestoring || obs.Claim.Op.Phase != hostclaim.PhaseReleased || len(obs.Claim.LossRefs) != 1 { t.Fatalf("claim op = %+v %v", obs.Claim.Op, err) } // The same request again replays and writes nothing. after := j4World(t, fx) if code, _, out := rawReq(t, http.MethodPost, moveURL+"/resolve", token, j4Body(t, map[string]any{"declare_lost": "uk-box", "submission": sub})); code != http.StatusAccepted { t.Fatalf("replay = %d %s", code, out) } if j4World(t, fx) != after { t.Fatal("a replay wrote") } } func TestJ4MoveDoorRejectsAmbiguousInput(t *testing.T) { fx, token, moveURL := j4LandingMove(t, "code-j4-ambiguous") before := j4World(t, fx) for _, body := range []string{ `{"retry":true,"declare_lost":"uk-box"}`, `{"retry":true,"submission":{}}`, `{"submission":{}}`, `{"declare_lost":"uk-box","box":"ie-box"}`, } { if code, _, out := rawReq(t, http.MethodPost, moveURL+"/resolve", token, body); code != http.StatusBadRequest { t.Fatalf("%s = %d %s", body, code, out) } } if code, _, out := rawReq(t, http.MethodPost, moveURL+"/resolve", token, `{}`); code != http.StatusConflict || errorText(out) != "nothing to resolve yet" { t.Fatalf("empty = %d %s", code, out) } if j4World(t, fx) != before { t.Fatal("a rejected request wrote") } } // The source, and a committed move, answer recorded sentences and write // nothing. func TestJ4MoveDoorRefusals(t *testing.T) { fx, token, moveURL := j4LandingMove(t, "code-j4-refusals") before := j4World(t, fx) code, _, out := rawReq(t, http.MethodPost, moveURL+"/resolve", token, `{"declare_lost":"ie-box"}`) if code != http.StatusConflict || errorText(out) != "this move's state does not allow that. read its state and try again" { t.Fatalf("source = %d %s", code, out) } j17SetRecord(t, fx, func(r *provision.Record) { r.Move.State = provision.MoveCommitted at := time.Now() r.Move.CommittedAt = &at }) code, _, out = rawReq(t, http.MethodPost, moveURL+"/resolve", token, `{"declare_lost":"uk-box"}`) if code != http.StatusConflict || errorText(out) != provision.RefusedCommitted { t.Fatalf("committed = %d %s", code, out) } if after := j4World(t, fx); strings.Split(after, "|")[0] != strings.Split(before, "|")[0] { t.Fatal("a refusal wrote the claim") } } // A stale submission (the record moved on after the inspection) is refused // before acceptance: no quarantine. func TestJ4MoveDoorStaleSubmission(t *testing.T) { fx, token, moveURL := j4LandingMove(t, "code-j4-stale") _, _, out := rawReq(t, http.MethodPost, moveURL+"/resolve", token, `{"declare_lost":"uk-box"}`) sub := j4Submission(t, fx, out) j17SetRecord(t, fx, func(r *provision.Record) { r.Move.LandingPhase = provision.LandProbed }) before := j4World(t, fx) code, _, out := rawReq(t, http.MethodPost, moveURL+"/resolve", token, j4Body(t, map[string]any{"declare_lost": "uk-box", "submission": sub})) if code != http.StatusConflict || errorText(out) != moveChangedSentence { t.Fatalf("stale = %d %s", code, out) } if j4World(t, fx) != before { t.Fatal("a stale declaration wrote") } } // Without the claim runtime the door keeps HC5-N1 for a declaration too. func TestJ4MoveDoorWithoutRuntime(t *testing.T) { fx, token, moveURL := j4LandingMove(t, "code-j4-noruntime") fx.srv.cfg.ClaimAuthority = nil if code, _, out := rawReq(t, http.MethodPost, moveURL+"/resolve", token, `{"declare_lost":"uk-box"}`); code != http.StatusConflict || errorText(out) != claimMoveUnsupportedMsg { t.Fatalf("no runtime = %d %s", code, out) } } // The host operator door: a move-kind claim's declaration runs the same // implementation; a serving claim's, and every readmit, keep the refusal. func TestJ4HostDoorRoutesOnlyMoveKinds(t *testing.T) { ctx := context.Background() fx, token, _ := j4LandingMove(t, "code-j4-hostdoor") fx.srv.cfg.Bucket = moveTestBucket // NewServer registers the host routes only with HostClaim on at build. fx.srv.cliRoute("POST /api/cli/ops/hosts/{host}/resolve/inspect", svcrole.Set{}, fx.srv.handleAPIHostResolveInspect) fx.srv.cliRoute("POST /api/cli/ops/hosts/{host}/resolve", svcrole.Set{}, fx.srv.handleAPIHostResolve) if err := hostfloor.Publish(ctx, fx.rel, release.RootSigner(fx.priv), moveTestBucket, hostfloor.Floor{Min: 1, MinRelease: "v1.0.0"}); err != nil { t.Fatal(err) } base := fx.ts.URL + "/api/cli/ops/hosts/" + j4Host + "/resolve" code, _, out := rawReq(t, http.MethodPost, base+"/inspect", token, `{"action":"declare-lost","box":"uk-box"}`) if code != http.StatusOK { t.Fatalf("host inspect = %d %s", code, out) } sub := j4Submission(t, fx, out) if code, _, out := rawReq(t, http.MethodPost, base, token, `{"action":"readmit","box":"uk-box"}`); code != http.StatusConflict || errorText(out) != claimHostResolveUnsupportedMsg { t.Fatalf("readmit = %d %s", code, out) } code, _, out = rawReq(t, http.MethodPost, base, token, j4Body(t, map[string]any{"action": "declare-lost", "submission": sub})) if code != http.StatusOK { t.Fatalf("host declare = %d %s", code, out) } obs, err := hostclaim.Read(ctx, fx.rel, j4Host) if err != nil || obs.Claim.Op.Phase != hostclaim.PhaseReleased { t.Fatalf("claim = %+v %v", obs.Claim.Op, err) } // A serving host keeps the refusal. if _, err := hostclaim.Update(ctx, fx.rel, obs, func(c *hostclaim.Claim) error { c.Op = hostclaim.Op{Kind: hostclaim.KindServing, Exec: "ie-box"} c.Obligations = nil return nil }); err != nil { t.Fatal(err) } 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 { t.Fatalf("serving declare = %d %s", code, out) } }