| 1 | package console |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "crypto/sha256" |
| 6 | "encoding/hex" |
| 7 | "encoding/json" |
| 8 | "errors" |
| 9 | "strings" |
| 10 | "testing" |
| 11 | "time" |
| 12 | |
| 13 | "github.com/carlosframework/platform/internal/boxid" |
| 14 | "github.com/carlosframework/platform/internal/boxid/boxidtest" |
| 15 | "github.com/carlosframework/platform/internal/hostactors" |
| 16 | "github.com/carlosframework/platform/internal/moves" |
| 17 | "github.com/carlosframework/platform/internal/release" |
| 18 | "github.com/carlosframework/platform/internal/status" |
| 19 | ) |
| 20 | |
| 21 | // moveTestBucket is the deployment bucket the roster armMoveFleet publishes |
| 22 | // names, and the console is configured with. |
| 23 | const moveTestBucket = "move-test-bucket" |
| 24 | |
| 25 | // moveTestEnrollment is the enrollment the test roster gives a box: 32 hex |
| 26 | // characters, distinct per box. |
| 27 | func moveTestEnrollment(box string) string { |
| 28 | sum := sha256.Sum256([]byte(box)) |
| 29 | return hex.EncodeToString(sum[:16]) |
| 30 | } |
| 31 | |
| 32 | // putMoveRoster signs r with the fixture's root key and stores it as the |
| 33 | // host roster, replacing any earlier one, and points the console at its |
| 34 | // bucket. |
| 35 | func putMoveRoster(t *testing.T, fx *consoleFixture, r hostactors.Roster) { |
| 36 | t.Helper() |
| 37 | ctx := context.Background() |
| 38 | fx.srv.cfg.Bucket = moveTestBucket |
| 39 | r.V, r.DeploymentBucket, r.Revision = 1, moveTestBucket, 1 |
| 40 | if r.Boxes == nil { |
| 41 | r.Boxes = map[string]hostactors.Box{} |
| 42 | } |
| 43 | r.Consoles, r.Holds, r.Activations = map[string]hostactors.Console{}, map[string]hostactors.Reservation{}, map[string]hostactors.Reservation{} |
| 44 | raw, err := release.SignRootEnvelope(release.RootSigner(fx.priv), r) |
| 45 | if err != nil { |
| 46 | t.Fatal(err) |
| 47 | } |
| 48 | _, etag, err := fx.rel.Get(ctx, hostactors.Key) |
| 49 | if err != nil { |
| 50 | etag = "" |
| 51 | } |
| 52 | if _, err := fx.rel.Put(ctx, hostactors.Key, raw, etag); err != nil { |
| 53 | t.Fatal(err) |
| 54 | } |
| 55 | } |
| 56 | |
| 57 | // moveRosterBox is the roster entry of a fleet box: active, with the |
| 58 | // enrollment moveTestEnrollment gives it. |
| 59 | func moveRosterBox(b fleetBox) hostactors.Box { |
| 60 | return hostactors.Box{BoxID: b.Box, Namespace: "platform", Label: b.Box + "-label", Region: b.Placement, Pool: "default", |
| 61 | Cloud: hostactors.Cloud{Provider: "aws", Account: moveTestAccount, Region: b.AWS, InstanceID: "i-" + b.Box}, |
| 62 | Enrollment: moveTestEnrollment(b.Box), EvidenceSHA256: strings.Repeat("f", 64), State: "active", LossIDs: []string{}} |
| 63 | } |
| 64 | |
| 65 | // moveTestAccount is the AWS account armMoveFleet's boxes are signed in and |
| 66 | // the console is configured to trust. |
| 67 | const moveTestAccount = "111122223333" |
| 68 | |
| 69 | // fleetBox is one box armMoveFleet publishes. PrevRelease defaults to |
| 70 | // "none". Unverified skips the signed identity record, Stale writes an old |
| 71 | // status snapshot, FloorUnarmed omits moves_floor_min_release, NoProtocol |
| 72 | // omits moves_protocol, and SignedAWS (when set) signs the identity record |
| 73 | // for another AWS region than the edges file names. |
| 74 | type fleetBox struct { |
| 75 | Box, Placement, AWS, EIP, Bucket string |
| 76 | PrevRelease, PrevAllowedFor string |
| 77 | SignedAWS string |
| 78 | Unverified, Stale, FloorUnarmed bool |
| 79 | NoProtocol bool |
| 80 | Protocol int // 0: moves.ProtocolVersion |
| 81 | } |
| 82 | |
| 83 | // armMoveFleet makes the fleet production-shaped for the move door: the |
| 84 | // console holds a secrets key, trusts one AWS account and one signer's |
| 85 | // certificates, and every box has an edges file with every eligibility |
| 86 | // field, a status snapshot (fresh unless Stale) and, unless Unverified, a |
| 87 | // signed boxid record. The published floor, if any, is read first so each |
| 88 | // file can say it armed it. |
| 89 | func armMoveFleet(t *testing.T, fx *consoleFixture, boxes ...fleetBox) *boxidtest.Signer { |
| 90 | t.Helper() |
| 91 | ctx := context.Background() |
| 92 | fx.srv.cfg.SecretsPub = testKeyPub |
| 93 | fx.srv.cfg.AWSAccount = moveTestAccount |
| 94 | signer := boxidtest.NewSigner(t) |
| 95 | fx.srv.boxCerts = signer.Certs() |
| 96 | floor, err := moves.ReadFloor(ctx, fx.rel) |
| 97 | if err != nil { |
| 98 | t.Fatal(err) |
| 99 | } |
| 100 | for _, b := range boxes { |
| 101 | m := map[string]any{ |
| 102 | "v": 1, "box": b.Box, "box_id": b.Box, "eip": b.EIP, "region": b.AWS, "placement": b.Placement, |
| 103 | "data_bucket": b.Bucket, "data_bucket_region": b.AWS, |
| 104 | "hibernation_configured": true, "serves_shared_pool": true, "accepts_inbound": true, |
| 105 | "secrets_key_fp": testKeyFP, "capabilities": []string{"region-ha-1"}, "hosts": map[string]any{}, |
| 106 | "moves_protocol": moves.ProtocolVersion, "prev_release": "none", |
| 107 | } |
| 108 | if b.Protocol != 0 { |
| 109 | m["moves_protocol"] = b.Protocol |
| 110 | } |
| 111 | if b.NoProtocol { |
| 112 | delete(m, "moves_protocol") |
| 113 | } |
| 114 | if floor != nil && !b.FloorUnarmed { |
| 115 | m["moves_floor_min_release"] = floor.MinRelease |
| 116 | } |
| 117 | if b.PrevRelease != "" { |
| 118 | m["prev_release"] = b.PrevRelease |
| 119 | } |
| 120 | if b.PrevAllowedFor != "" { |
| 121 | m["prev_allowed_for"] = b.PrevAllowedFor |
| 122 | } |
| 123 | body, err := json.Marshal(m) |
| 124 | if err != nil { |
| 125 | t.Fatal(err) |
| 126 | } |
| 127 | if _, err := fx.rel.Put(ctx, edgesFilePrefix+b.Box+".json", body, ""); err != nil { |
| 128 | t.Fatal(err) |
| 129 | } |
| 130 | at := time.Now() |
| 131 | if b.Stale { |
| 132 | at = at.Add(-time.Hour) |
| 133 | } |
| 134 | if err := status.WriteBox(ctx, fx.rel, status.Box{Label: b.Box, GeneratedAt: at}); err != nil { |
| 135 | t.Fatal(err) |
| 136 | } |
| 137 | if b.Unverified { |
| 138 | continue |
| 139 | } |
| 140 | signed := b.AWS |
| 141 | if b.SignedAWS != "" { |
| 142 | signed = b.SignedAWS |
| 143 | } |
| 144 | rec, _ := signer.Record(t, b.Box, moveTestAccount, signed, "i-"+b.Box) |
| 145 | if err := boxid.Publish(ctx, fx.rel, rec); err != nil { |
| 146 | t.Fatal(err) |
| 147 | } |
| 148 | } |
| 149 | // Every box is enrolled in the roster the door reads. |
| 150 | roster := hostactors.Roster{Boxes: map[string]hostactors.Box{}} |
| 151 | for _, b := range boxes { |
| 152 | roster.Boxes[b.Box] = moveRosterBox(b) |
| 153 | } |
| 154 | putMoveRoster(t, fx, roster) |
| 155 | return signer |
| 156 | } |
| 157 | |
| 158 | func ieBox(box string) fleetBox { |
| 159 | return fleetBox{Box: box, Placement: "ie", AWS: "eu-west-1", EIP: "1.2.3.3", Bucket: "data-ie"} |
| 160 | } |
| 161 | |
| 162 | func ukBox(box string) fleetBox { |
| 163 | return fleetBox{Box: box, Placement: "uk", AWS: "eu-west-2", EIP: "1.2.3.4", Bucket: "data-uk"} |
| 164 | } |
| 165 | |
| 166 | // ieBox2 and ukBox2 publish protocol 2, above anything this build speaks. |
| 167 | func ieBox2(box string) fleetBox { b := ieBox(box); b.Protocol = 2; return b } |
| 168 | func ukBox2(box string) fleetBox { b := ukBox(box); b.Protocol = 2; return b } |
| 169 | |
| 170 | // selectMoveBoxes is the first production caller of eligibleBoxes and |
| 171 | // chooseBox: it composes every input itself (the live snapshot, identities |
| 172 | // proven by boxid.Verifier.Enroll, the secrets fingerprint, each code's AWS |
| 173 | // region) and adds the one-box refusal and the protocol floor. |
| 174 | func TestSelectMoveBoxesComposesTheCreateDoorsInputs(t *testing.T) { |
| 175 | const host, app = "acme.go.tito.io", "bab/titogo" |
| 176 | floorJSON := `{"min":1,"min_release":"v0.23.0"}` |
| 177 | withPrev := func(b fleetBox, prev, allowed string) fleetBox { |
| 178 | b.PrevRelease, b.PrevAllowedFor = prev, allowed |
| 179 | return b |
| 180 | } |
| 181 | cases := []struct { |
| 182 | name string |
| 183 | floor bool |
| 184 | boxes []fleetBox |
| 185 | setup func(fx *consoleFixture) |
| 186 | want moveSelection |
| 187 | }{ |
| 188 | {name: "one verified box each side", boxes: []fleetBox{ieBox("ie-box"), ukBox("uk-box")}, |
| 189 | want: moveSelection{Target: "uk-box", Source: "ie-box", FloorOK: true}}, |
| 190 | {name: "two verified boxes in the target", boxes: []fleetBox{ieBox("ie-box"), ukBox("uk-a"), ukBox("uk-b")}, |
| 191 | want: moveSelection{Many: true, ManyRegion: "uk", FloorOK: true}}, |
| 192 | {name: "two verified boxes in the source", boxes: []fleetBox{ieBox("ie-a"), ieBox("ie-b"), ukBox("uk-box")}, |
| 193 | want: moveSelection{Target: "uk-box", Many: true, ManyRegion: "ie", FloorOK: true}}, |
| 194 | {name: "an unverifiable second box is not eligible", |
| 195 | boxes: []fleetBox{ieBox("ie-box"), ukBox("uk-a"), func() fleetBox { b := ukBox("uk-b"); b.Unverified = true; return b }()}, |
| 196 | want: moveSelection{Target: "uk-a", Source: "ie-box", FloorOK: true}}, |
| 197 | {name: "an identity signed in another AWS region is not eligible", |
| 198 | boxes: []fleetBox{ieBox("ie-box"), ukBox("uk-a"), func() fleetBox { b := ukBox("uk-b"); b.SignedAWS = "eu-north-1"; return b }()}, |
| 199 | want: moveSelection{Target: "uk-a", Source: "ie-box", FloorOK: true}}, |
| 200 | {name: "a stale box is not eligible", |
| 201 | boxes: []fleetBox{ieBox("ie-box"), ukBox("uk-a"), func() fleetBox { b := ukBox("uk-b"); b.Stale = true; return b }()}, |
| 202 | want: moveSelection{Target: "uk-a", Source: "ie-box", FloorOK: true}}, |
| 203 | {name: "no account configured verifies nothing", boxes: []fleetBox{ieBox("ie-box"), ukBox("uk-box")}, |
| 204 | setup: func(fx *consoleFixture) { fx.srv.cfg.AWSAccount = "" }, |
| 205 | want: moveSelection{FloorOK: true}}, |
| 206 | {name: "no file in the source region", boxes: []fleetBox{ukBox("uk-box")}, |
| 207 | want: moveSelection{Target: "uk-box"}}, |
| 208 | {name: "a file without moves_protocol", |
| 209 | boxes: []fleetBox{ieBox("ie-box"), func() fleetBox { b := ukBox("uk-box"); b.NoProtocol = true; return b }()}, |
| 210 | want: moveSelection{Target: "uk-box", Source: "ie-box"}}, |
| 211 | {name: "a published floor, prev none", floor: true, boxes: []fleetBox{ieBox("ie-box"), ukBox("uk-box")}, |
| 212 | want: moveSelection{Target: "uk-box", Source: "ie-box", FloorOK: true}}, |
| 213 | {name: "a published floor, prev judged at it", floor: true, |
| 214 | boxes: []fleetBox{ieBox("ie-box"), withPrev(ukBox("uk-box"), "v0.23.1", "v0.23.0")}, |
| 215 | want: moveSelection{Target: "uk-box", Source: "ie-box", FloorOK: true}}, |
| 216 | {name: "a published floor, prev judged at an older floor", floor: true, |
| 217 | boxes: []fleetBox{ieBox("ie-box"), withPrev(ukBox("uk-box"), "v0.23.1", "v0.22.0")}, |
| 218 | want: moveSelection{Target: "uk-box", Source: "ie-box"}}, |
| 219 | {name: "a published floor, prev never judged", floor: true, |
| 220 | boxes: []fleetBox{withPrev(ieBox("ie-box"), "v0.22.0", ""), ukBox("uk-box")}, |
| 221 | want: moveSelection{Target: "uk-box", Source: "ie-box"}}, |
| 222 | {name: "a published floor the box has not armed", floor: true, |
| 223 | boxes: []fleetBox{ieBox("ie-box"), func() fleetBox { b := ukBox("uk-box"); b.FloorUnarmed = true; return b }()}, |
| 224 | want: moveSelection{Target: "uk-box", Source: "ie-box"}}, |
| 225 | {name: "a floor above this protocol", boxes: []fleetBox{ieBox("ie-box"), ukBox("uk-box")}, |
| 226 | setup: func(fx *consoleFixture) { |
| 227 | if _, err := fx.rel.Put(context.Background(), moves.FloorKey, []byte(`{"min":2}`), ""); err != nil { |
| 228 | t.Fatal(err) |
| 229 | } |
| 230 | }, |
| 231 | want: moveSelection{Target: "uk-box", Source: "ie-box", ConsoleBelowFloor: true}}, |
| 232 | {name: "a floor above this console, boxes at it", |
| 233 | boxes: []fleetBox{ieBox2("ie-box"), ukBox2("uk-box")}, |
| 234 | setup: func(fx *consoleFixture) { |
| 235 | if _, err := fx.rel.Put(context.Background(), moves.FloorKey, []byte(`{"min":2}`), ""); err != nil { |
| 236 | t.Fatal(err) |
| 237 | } |
| 238 | }, |
| 239 | want: moveSelection{Target: "uk-box", Source: "ie-box", FloorOK: true, ConsoleBelowFloor: true}}, |
| 240 | } |
| 241 | for _, c := range cases { |
| 242 | t.Run(c.name, func(t *testing.T) { |
| 243 | fx := newConsoleFixture(t) |
| 244 | if c.floor { |
| 245 | if _, err := fx.rel.Put(context.Background(), moves.FloorKey, []byte(floorJSON), ""); err != nil { |
| 246 | t.Fatal(err) |
| 247 | } |
| 248 | } |
| 249 | armMoveFleet(t, fx, c.boxes...) |
| 250 | if c.setup != nil { |
| 251 | c.setup(fx) |
| 252 | } |
| 253 | got, err := fx.srv.selectMoveBoxes(context.Background(), host, "uk", "ie", app) |
| 254 | if err != nil { |
| 255 | t.Fatal(err) |
| 256 | } |
| 257 | if got != c.want { |
| 258 | t.Fatalf("selection = %+v, want %+v", got, c.want) |
| 259 | } |
| 260 | }) |
| 261 | } |
| 262 | |
| 263 | t.Run("two boxes in a home source", func(t *testing.T) { |
| 264 | fx := newConsoleFixture(t) |
| 265 | fx.srv.cfg.HomeRegion = "ie" |
| 266 | armMoveFleet(t, fx, ieBox("ie-a"), ieBox("ie-b"), ukBox("uk-box")) |
| 267 | got, err := fx.srv.selectMoveBoxes(context.Background(), host, "uk", "", app) |
| 268 | if err != nil { |
| 269 | t.Fatal(err) |
| 270 | } |
| 271 | if want := (moveSelection{Target: "uk-box", Many: true, ManyRegion: "ie", FloorOK: true}); got != want { |
| 272 | t.Fatalf("selection = %+v, want %+v", got, want) |
| 273 | } |
| 274 | }) |
| 275 | t.Run("an incomplete snapshot answers nothing", func(t *testing.T) { |
| 276 | fx := newConsoleFixture(t) |
| 277 | armMoveFleet(t, fx, ieBox("ie-box"), ukBox("uk-box")) |
| 278 | if _, err := fx.rel.Put(context.Background(), edgesFilePrefix+"broken.json", []byte("{"), ""); err != nil { |
| 279 | t.Fatal(err) |
| 280 | } |
| 281 | if _, err := fx.srv.selectMoveBoxes(context.Background(), host, "uk", "ie", app); !errors.Is(err, errEdgesIncomplete) { |
| 282 | t.Fatalf("err = %v, want errEdgesIncomplete", err) |
| 283 | } |
| 284 | }) |
| 285 | t.Run("an unreadable identity record answers nothing", func(t *testing.T) { |
| 286 | fx := newConsoleFixture(t) |
| 287 | armMoveFleet(t, fx, ieBox("ie-box"), ukBox("uk-a"), ukBox("uk-b")) |
| 288 | fx.srv.cfg.Releases = failingGet{Store: fx.rel, key: boxid.Key("uk-b")} |
| 289 | if _, err := fx.srv.selectMoveBoxes(context.Background(), host, "uk", "ie", app); !errors.Is(err, errEdgesIncomplete) { |
| 290 | t.Fatalf("err = %v, want errEdgesIncomplete", err) |
| 291 | } |
| 292 | }) |
| 293 | t.Run("an unreadable floor is an error, never no floor", func(t *testing.T) { |
| 294 | fx := newConsoleFixture(t) |
| 295 | armMoveFleet(t, fx, ieBox("ie-box"), ukBox("uk-box")) |
| 296 | fx.srv.cfg.Releases = failingGet{Store: fx.rel, key: moves.FloorKey} |
| 297 | if _, err := fx.srv.selectMoveBoxes(context.Background(), host, "uk", "ie", app); err == nil || errors.Is(err, errEdgesIncomplete) { |
| 298 | t.Fatalf("err = %v, want the floor's read error", err) |
| 299 | } |
| 300 | }) |
| 301 | } |
| 302 | |
| 303 | func TestPlan14MoveSelectionMixedRootFloors(t *testing.T) { |
| 304 | for _, state := range []string{"", "quarantined", "accepted"} { |
| 305 | t.Run(state, func(t *testing.T) { |
| 306 | fx := newConsoleFixture(t) |
| 307 | if _, err := fx.rel.Put(t.Context(), moves.FloorKey, []byte(`{"min":1,"min_release":"v0.23.0"}`), ""); err != nil { |
| 308 | t.Fatal(err) |
| 309 | } |
| 310 | armMoveFleet(t, fx, ieBox("ie-box"), ukBox("uk-box")) |
| 311 | key := edgesFilePrefix + "uk-box.json" |
| 312 | raw, tag, err := fx.rel.Get(t.Context(), key) |
| 313 | if err != nil { |
| 314 | t.Fatal(err) |
| 315 | } |
| 316 | var body map[string]any |
| 317 | if err := json.Unmarshal(raw, &body); err != nil { |
| 318 | t.Fatal(err) |
| 319 | } |
| 320 | body["moves_floor_generation"] = 1 |
| 321 | body["moves_floor_state"] = state |
| 322 | raw, _ = json.Marshal(body) |
| 323 | if _, err := fx.rel.Put(t.Context(), key, raw, tag); err != nil { |
| 324 | t.Fatal(err) |
| 325 | } |
| 326 | got, err := fx.srv.selectMoveBoxes(t.Context(), "acme.go.tito.io", "uk", "ie", "bab/titogo") |
| 327 | if err != nil || got.Source != "ie-box" || got.Target != "uk-box" || got.FloorOK != (state == "accepted") { |
| 328 | t.Fatal(got, err) |
| 329 | } |
| 330 | }) |
| 331 | } |
| 332 | } |
| 333 | |