carlos / platform Public

Clone
git clone https://amadan.net/carlos/platform

Plain git — no account needed to clone.

Download

Download this file

1package console
2
3import (
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
28const 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.
32func 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
69func 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
86func 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
95func 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
107func 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
143func 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.
166func 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.
189func 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.
205func 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.
215func 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