package console import ( "context" "crypto/sha256" "encoding/hex" "encoding/json" "errors" "strings" "testing" "time" "github.com/carlosframework/platform/internal/boxid" "github.com/carlosframework/platform/internal/boxid/boxidtest" "github.com/carlosframework/platform/internal/hostactors" "github.com/carlosframework/platform/internal/moves" "github.com/carlosframework/platform/internal/release" "github.com/carlosframework/platform/internal/status" ) // moveTestBucket is the deployment bucket the roster armMoveFleet publishes // names, and the console is configured with. const moveTestBucket = "move-test-bucket" // moveTestEnrollment is the enrollment the test roster gives a box: 32 hex // characters, distinct per box. func moveTestEnrollment(box string) string { sum := sha256.Sum256([]byte(box)) return hex.EncodeToString(sum[:16]) } // putMoveRoster signs r with the fixture's root key and stores it as the // host roster, replacing any earlier one, and points the console at its // bucket. func putMoveRoster(t *testing.T, fx *consoleFixture, r hostactors.Roster) { t.Helper() ctx := context.Background() fx.srv.cfg.Bucket = moveTestBucket r.V, r.DeploymentBucket, r.Revision = 1, moveTestBucket, 1 if r.Boxes == nil { r.Boxes = map[string]hostactors.Box{} } r.Consoles, r.Holds, r.Activations = map[string]hostactors.Console{}, map[string]hostactors.Reservation{}, map[string]hostactors.Reservation{} raw, err := release.SignRootEnvelope(release.RootSigner(fx.priv), r) if err != nil { t.Fatal(err) } _, etag, err := fx.rel.Get(ctx, hostactors.Key) if err != nil { etag = "" } if _, err := fx.rel.Put(ctx, hostactors.Key, raw, etag); err != nil { t.Fatal(err) } } // moveRosterBox is the roster entry of a fleet box: active, with the // enrollment moveTestEnrollment gives it. func moveRosterBox(b fleetBox) hostactors.Box { return hostactors.Box{BoxID: b.Box, Namespace: "platform", Label: b.Box + "-label", Region: b.Placement, Pool: "default", Cloud: hostactors.Cloud{Provider: "aws", Account: moveTestAccount, Region: b.AWS, InstanceID: "i-" + b.Box}, Enrollment: moveTestEnrollment(b.Box), EvidenceSHA256: strings.Repeat("f", 64), State: "active", LossIDs: []string{}} } // moveTestAccount is the AWS account armMoveFleet's boxes are signed in and // the console is configured to trust. const moveTestAccount = "111122223333" // fleetBox is one box armMoveFleet publishes. PrevRelease defaults to // "none". Unverified skips the signed identity record, Stale writes an old // status snapshot, FloorUnarmed omits moves_floor_min_release, NoProtocol // omits moves_protocol, and SignedAWS (when set) signs the identity record // for another AWS region than the edges file names. type fleetBox struct { Box, Placement, AWS, EIP, Bucket string PrevRelease, PrevAllowedFor string SignedAWS string Unverified, Stale, FloorUnarmed bool NoProtocol bool Protocol int // 0: moves.ProtocolVersion } // armMoveFleet makes the fleet production-shaped for the move door: the // console holds a secrets key, trusts one AWS account and one signer's // certificates, and every box has an edges file with every eligibility // field, a status snapshot (fresh unless Stale) and, unless Unverified, a // signed boxid record. The published floor, if any, is read first so each // file can say it armed it. func armMoveFleet(t *testing.T, fx *consoleFixture, boxes ...fleetBox) *boxidtest.Signer { t.Helper() ctx := context.Background() fx.srv.cfg.SecretsPub = testKeyPub fx.srv.cfg.AWSAccount = moveTestAccount signer := boxidtest.NewSigner(t) fx.srv.boxCerts = signer.Certs() floor, err := moves.ReadFloor(ctx, fx.rel) if err != nil { t.Fatal(err) } for _, b := range boxes { m := map[string]any{ "v": 1, "box": b.Box, "box_id": b.Box, "eip": b.EIP, "region": b.AWS, "placement": b.Placement, "data_bucket": b.Bucket, "data_bucket_region": b.AWS, "hibernation_configured": true, "serves_shared_pool": true, "accepts_inbound": true, "secrets_key_fp": testKeyFP, "capabilities": []string{"region-ha-1"}, "hosts": map[string]any{}, "moves_protocol": moves.ProtocolVersion, "prev_release": "none", } if b.Protocol != 0 { m["moves_protocol"] = b.Protocol } if b.NoProtocol { delete(m, "moves_protocol") } if floor != nil && !b.FloorUnarmed { m["moves_floor_min_release"] = floor.MinRelease } if b.PrevRelease != "" { m["prev_release"] = b.PrevRelease } if b.PrevAllowedFor != "" { m["prev_allowed_for"] = b.PrevAllowedFor } body, err := json.Marshal(m) if err != nil { t.Fatal(err) } if _, err := fx.rel.Put(ctx, edgesFilePrefix+b.Box+".json", body, ""); err != nil { t.Fatal(err) } at := time.Now() if b.Stale { at = at.Add(-time.Hour) } if err := status.WriteBox(ctx, fx.rel, status.Box{Label: b.Box, GeneratedAt: at}); err != nil { t.Fatal(err) } if b.Unverified { continue } signed := b.AWS if b.SignedAWS != "" { signed = b.SignedAWS } rec, _ := signer.Record(t, b.Box, moveTestAccount, signed, "i-"+b.Box) if err := boxid.Publish(ctx, fx.rel, rec); err != nil { t.Fatal(err) } } // Every box is enrolled in the roster the door reads. roster := hostactors.Roster{Boxes: map[string]hostactors.Box{}} for _, b := range boxes { roster.Boxes[b.Box] = moveRosterBox(b) } putMoveRoster(t, fx, roster) return signer } func ieBox(box string) fleetBox { return fleetBox{Box: box, Placement: "ie", AWS: "eu-west-1", EIP: "1.2.3.3", Bucket: "data-ie"} } func ukBox(box string) fleetBox { return fleetBox{Box: box, Placement: "uk", AWS: "eu-west-2", EIP: "1.2.3.4", Bucket: "data-uk"} } // ieBox2 and ukBox2 publish protocol 2, above anything this build speaks. func ieBox2(box string) fleetBox { b := ieBox(box); b.Protocol = 2; return b } func ukBox2(box string) fleetBox { b := ukBox(box); b.Protocol = 2; return b } // selectMoveBoxes is the first production caller of eligibleBoxes and // chooseBox: it composes every input itself (the live snapshot, identities // proven by boxid.Verifier.Enroll, the secrets fingerprint, each code's AWS // region) and adds the one-box refusal and the protocol floor. func TestSelectMoveBoxesComposesTheCreateDoorsInputs(t *testing.T) { const host, app = "acme.go.tito.io", "bab/titogo" floorJSON := `{"min":1,"min_release":"v0.23.0"}` withPrev := func(b fleetBox, prev, allowed string) fleetBox { b.PrevRelease, b.PrevAllowedFor = prev, allowed return b } cases := []struct { name string floor bool boxes []fleetBox setup func(fx *consoleFixture) want moveSelection }{ {name: "one verified box each side", boxes: []fleetBox{ieBox("ie-box"), ukBox("uk-box")}, want: moveSelection{Target: "uk-box", Source: "ie-box", FloorOK: true}}, {name: "two verified boxes in the target", boxes: []fleetBox{ieBox("ie-box"), ukBox("uk-a"), ukBox("uk-b")}, want: moveSelection{Many: true, ManyRegion: "uk", FloorOK: true}}, {name: "two verified boxes in the source", boxes: []fleetBox{ieBox("ie-a"), ieBox("ie-b"), ukBox("uk-box")}, want: moveSelection{Target: "uk-box", Many: true, ManyRegion: "ie", FloorOK: true}}, {name: "an unverifiable second box is not eligible", boxes: []fleetBox{ieBox("ie-box"), ukBox("uk-a"), func() fleetBox { b := ukBox("uk-b"); b.Unverified = true; return b }()}, want: moveSelection{Target: "uk-a", Source: "ie-box", FloorOK: true}}, {name: "an identity signed in another AWS region is not eligible", boxes: []fleetBox{ieBox("ie-box"), ukBox("uk-a"), func() fleetBox { b := ukBox("uk-b"); b.SignedAWS = "eu-north-1"; return b }()}, want: moveSelection{Target: "uk-a", Source: "ie-box", FloorOK: true}}, {name: "a stale box is not eligible", boxes: []fleetBox{ieBox("ie-box"), ukBox("uk-a"), func() fleetBox { b := ukBox("uk-b"); b.Stale = true; return b }()}, want: moveSelection{Target: "uk-a", Source: "ie-box", FloorOK: true}}, {name: "no account configured verifies nothing", boxes: []fleetBox{ieBox("ie-box"), ukBox("uk-box")}, setup: func(fx *consoleFixture) { fx.srv.cfg.AWSAccount = "" }, want: moveSelection{FloorOK: true}}, {name: "no file in the source region", boxes: []fleetBox{ukBox("uk-box")}, want: moveSelection{Target: "uk-box"}}, {name: "a file without moves_protocol", boxes: []fleetBox{ieBox("ie-box"), func() fleetBox { b := ukBox("uk-box"); b.NoProtocol = true; return b }()}, want: moveSelection{Target: "uk-box", Source: "ie-box"}}, {name: "a published floor, prev none", floor: true, boxes: []fleetBox{ieBox("ie-box"), ukBox("uk-box")}, want: moveSelection{Target: "uk-box", Source: "ie-box", FloorOK: true}}, {name: "a published floor, prev judged at it", floor: true, boxes: []fleetBox{ieBox("ie-box"), withPrev(ukBox("uk-box"), "v0.23.1", "v0.23.0")}, want: moveSelection{Target: "uk-box", Source: "ie-box", FloorOK: true}}, {name: "a published floor, prev judged at an older floor", floor: true, boxes: []fleetBox{ieBox("ie-box"), withPrev(ukBox("uk-box"), "v0.23.1", "v0.22.0")}, want: moveSelection{Target: "uk-box", Source: "ie-box"}}, {name: "a published floor, prev never judged", floor: true, boxes: []fleetBox{withPrev(ieBox("ie-box"), "v0.22.0", ""), ukBox("uk-box")}, want: moveSelection{Target: "uk-box", Source: "ie-box"}}, {name: "a published floor the box has not armed", floor: true, boxes: []fleetBox{ieBox("ie-box"), func() fleetBox { b := ukBox("uk-box"); b.FloorUnarmed = true; return b }()}, want: moveSelection{Target: "uk-box", Source: "ie-box"}}, {name: "a floor above this protocol", boxes: []fleetBox{ieBox("ie-box"), ukBox("uk-box")}, setup: func(fx *consoleFixture) { if _, err := fx.rel.Put(context.Background(), moves.FloorKey, []byte(`{"min":2}`), ""); err != nil { t.Fatal(err) } }, want: moveSelection{Target: "uk-box", Source: "ie-box", ConsoleBelowFloor: true}}, {name: "a floor above this console, boxes at it", boxes: []fleetBox{ieBox2("ie-box"), ukBox2("uk-box")}, setup: func(fx *consoleFixture) { if _, err := fx.rel.Put(context.Background(), moves.FloorKey, []byte(`{"min":2}`), ""); err != nil { t.Fatal(err) } }, want: moveSelection{Target: "uk-box", Source: "ie-box", FloorOK: true, ConsoleBelowFloor: true}}, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { fx := newConsoleFixture(t) if c.floor { if _, err := fx.rel.Put(context.Background(), moves.FloorKey, []byte(floorJSON), ""); err != nil { t.Fatal(err) } } armMoveFleet(t, fx, c.boxes...) if c.setup != nil { c.setup(fx) } got, err := fx.srv.selectMoveBoxes(context.Background(), host, "uk", "ie", app) if err != nil { t.Fatal(err) } if got != c.want { t.Fatalf("selection = %+v, want %+v", got, c.want) } }) } t.Run("two boxes in a home source", func(t *testing.T) { fx := newConsoleFixture(t) fx.srv.cfg.HomeRegion = "ie" armMoveFleet(t, fx, ieBox("ie-a"), ieBox("ie-b"), ukBox("uk-box")) got, err := fx.srv.selectMoveBoxes(context.Background(), host, "uk", "", app) if err != nil { t.Fatal(err) } if want := (moveSelection{Target: "uk-box", Many: true, ManyRegion: "ie", FloorOK: true}); got != want { t.Fatalf("selection = %+v, want %+v", got, want) } }) t.Run("an incomplete snapshot answers nothing", func(t *testing.T) { fx := newConsoleFixture(t) armMoveFleet(t, fx, ieBox("ie-box"), ukBox("uk-box")) if _, err := fx.rel.Put(context.Background(), edgesFilePrefix+"broken.json", []byte("{"), ""); err != nil { t.Fatal(err) } if _, err := fx.srv.selectMoveBoxes(context.Background(), host, "uk", "ie", app); !errors.Is(err, errEdgesIncomplete) { t.Fatalf("err = %v, want errEdgesIncomplete", err) } }) t.Run("an unreadable identity record answers nothing", func(t *testing.T) { fx := newConsoleFixture(t) armMoveFleet(t, fx, ieBox("ie-box"), ukBox("uk-a"), ukBox("uk-b")) fx.srv.cfg.Releases = failingGet{Store: fx.rel, key: boxid.Key("uk-b")} if _, err := fx.srv.selectMoveBoxes(context.Background(), host, "uk", "ie", app); !errors.Is(err, errEdgesIncomplete) { t.Fatalf("err = %v, want errEdgesIncomplete", err) } }) t.Run("an unreadable floor is an error, never no floor", func(t *testing.T) { fx := newConsoleFixture(t) armMoveFleet(t, fx, ieBox("ie-box"), ukBox("uk-box")) fx.srv.cfg.Releases = failingGet{Store: fx.rel, key: moves.FloorKey} if _, err := fx.srv.selectMoveBoxes(context.Background(), host, "uk", "ie", app); err == nil || errors.Is(err, errEdgesIncomplete) { t.Fatalf("err = %v, want the floor's read error", err) } }) } func TestPlan14MoveSelectionMixedRootFloors(t *testing.T) { for _, state := range []string{"", "quarantined", "accepted"} { t.Run(state, func(t *testing.T) { fx := newConsoleFixture(t) if _, err := fx.rel.Put(t.Context(), moves.FloorKey, []byte(`{"min":1,"min_release":"v0.23.0"}`), ""); err != nil { t.Fatal(err) } armMoveFleet(t, fx, ieBox("ie-box"), ukBox("uk-box")) key := edgesFilePrefix + "uk-box.json" raw, tag, err := fx.rel.Get(t.Context(), key) if err != nil { t.Fatal(err) } var body map[string]any if err := json.Unmarshal(raw, &body); err != nil { t.Fatal(err) } body["moves_floor_generation"] = 1 body["moves_floor_state"] = state raw, _ = json.Marshal(body) if _, err := fx.rel.Put(t.Context(), key, raw, tag); err != nil { t.Fatal(err) } got, err := fx.srv.selectMoveBoxes(t.Context(), "acme.go.tito.io", "uk", "ie", "bab/titogo") if err != nil || got.Source != "ie-box" || got.Target != "uk-box" || got.FloorOK != (state == "accepted") { t.Fatal(got, err) } }) } }