| 1 | package console |
| 2 | |
| 3 | import ( |
| 4 | "bytes" |
| 5 | "context" |
| 6 | "encoding/json" |
| 7 | "errors" |
| 8 | "io" |
| 9 | "log" |
| 10 | "os" |
| 11 | "strconv" |
| 12 | "strings" |
| 13 | "testing" |
| 14 | "time" |
| 15 | |
| 16 | "github.com/carlosframework/platform/internal/provision" |
| 17 | "github.com/carlosframework/platform/internal/release" |
| 18 | "github.com/carlosframework/platform/internal/status" |
| 19 | ) |
| 20 | |
| 21 | // mustTestSecretsKey is Task 9's (regionconfig_test.go). |
| 22 | var testKeyPub, testKeyFP = mustTestSecretsKey() |
| 23 | |
| 24 | func bp(b bool) *bool { return &b } |
| 25 | |
| 26 | type edgeOpts struct { |
| 27 | hib, shared, inbound *bool // nil = the writer predates the field |
| 28 | fp string |
| 29 | } |
| 30 | |
| 31 | var eligibleBox = edgeOpts{hib: bp(true), shared: bp(true), inbound: bp(true), fp: testKeyFP} |
| 32 | |
| 33 | // edgeJSON writes one box's edges file the way internal/host does. |
| 34 | func edgeJSON(box, eip, awsRegion, placement string, o edgeOpts) string { |
| 35 | m := map[string]any{"v": 1, "box": box, "eip": eip, "region": awsRegion, "hosts": map[string]any{}} |
| 36 | if placement != "" { |
| 37 | m["placement"] = placement |
| 38 | } |
| 39 | if o.hib != nil { |
| 40 | m["hibernation_configured"] = *o.hib |
| 41 | } |
| 42 | if o.shared != nil { |
| 43 | m["serves_shared_pool"] = *o.shared |
| 44 | } |
| 45 | if o.inbound != nil { |
| 46 | m["accepts_inbound"] = *o.inbound |
| 47 | } |
| 48 | if o.fp != "" { |
| 49 | m["secrets_key_fp"] = o.fp |
| 50 | } |
| 51 | b, _ := json.Marshal(m) |
| 52 | return string(b) |
| 53 | } |
| 54 | |
| 55 | var ( |
| 56 | homeBox = edgeJSON("ie-box", "5.6.7.8", "eu-west-1", "", eligibleBox) |
| 57 | ukEligible = edgeJSON("uk-box", "1.2.3.4", "eu-west-2", "uk", eligibleBox) |
| 58 | ukNoKey = edgeJSON("uk-box", "1.2.3.4", "eu-west-2", "uk", edgeOpts{hib: bp(true), shared: bp(true), inbound: bp(true)}) |
| 59 | ukWrongKey = edgeJSON("uk-box", "1.2.3.4", "eu-west-2", "uk", edgeOpts{hib: bp(true), shared: bp(true), inbound: bp(true), fp: "sha256:" + strings.Repeat("0", 64)}) |
| 60 | ukOldWriter = edgeJSON("uk-box", "1.2.3.4", "eu-west-2", "uk", edgeOpts{}) |
| 61 | auPoolOnly = edgeJSON("au-box", "9.9.9.9", "ap-southeast-2", "au", edgeOpts{hib: bp(true), shared: bp(false), inbound: bp(true), fp: testKeyFP}) |
| 62 | usNoInbound = edgeJSON("us-box", "8.8.4.4", "us-east-1", "us", edgeOpts{hib: bp(true), shared: bp(true), inbound: bp(false), fp: testKeyFP}) |
| 63 | nzNoHosting = edgeJSON("nz-box", "6.6.6.6", "ap-southeast-6", "nz", edgeOpts{hib: bp(false), shared: bp(true), inbound: bp(true), fp: testKeyFP}) |
| 64 | jpNoEIP = edgeJSON("jp-box", "", "ap-northeast-1", "jp", eligibleBox) |
| 65 | fleetBoxFile = edgeJSON("fl-box", "4.4.4.4", "eu-west-2", "fleet/acme", eligibleBox) |
| 66 | // ukKeylessSibling is a second uk box reconcile would admit (shared pool, |
| 67 | // inbound) that holds no key: the F2 hazard beside ukEligible. |
| 68 | ukKeylessSibling = edgeJSON("uk-box-2", "1.2.3.5", "eu-west-2", "uk", edgeOpts{hib: bp(true), shared: bp(true), inbound: bp(true)}) |
| 69 | ) |
| 70 | |
| 71 | // Partial reads (re-review r1): automatic non-home eligibility needs |
| 72 | // COMPLETE evidence, the edges snapshot Complete and every listed box's |
| 73 | // status read succeeded. Otherwise a failing sibling can simply drop out |
| 74 | // of the fold (its edges GET failed) or read as not fresh (its status GET |
| 75 | // failed), and the region becomes eligible through the box that was read. |
| 76 | // On any incomplete read no non-home region is eligible for that request |
| 77 | // (whole-request scope, the stricter choice); the home stays as it is. |
| 78 | func TestIncompleteReadsNeverMakeARegionEligible(t *testing.T) { |
| 79 | ctx := context.Background() |
| 80 | auBox := edgeJSON("au-box", "9.9.9.9", "ap-southeast-2", "au", eligibleBox) |
| 81 | s := release.NewDirStore(t.TempDir()) |
| 82 | for key, body := range map[string]string{"ie-box": homeBox, "uk-box": ukEligible, "uk-box-2": ukKeylessSibling, "au-box": auBox} { |
| 83 | if _, err := s.Put(ctx, "control/edges/"+key+".json", []byte(body), ""); err != nil { |
| 84 | t.Fatal(err) |
| 85 | } |
| 86 | setBoxSnapshot(t, s, key, time.Now()) |
| 87 | } |
| 88 | check := func(name string, store release.Store, wantAU bool) { |
| 89 | t.Helper() |
| 90 | snap, err := readLiveEdgesSnapshot(ctx, store, time.Now()) |
| 91 | if err != nil { |
| 92 | t.Fatalf("%s: %v", name, err) |
| 93 | } |
| 94 | cands := regionCandidates(snap, "ie", testKeyFP) |
| 95 | uk, _ := findCandidate(cands, "uk") |
| 96 | au, _ := findCandidate(cands, "au") |
| 97 | home, _ := findCandidate(cands, "") |
| 98 | if uk.Eligible { |
| 99 | t.Errorf("%s: uk = %+v, want ineligible (a keyless sibling was listed)", name, uk) |
| 100 | } |
| 101 | if au.Eligible != wantAU { |
| 102 | t.Errorf("%s: au eligible = %v, want %v", name, au.Eligible, wantAU) |
| 103 | } |
| 104 | if !home.Eligible { |
| 105 | t.Errorf("%s: home = %+v, want eligible regardless", name, home) |
| 106 | } |
| 107 | } |
| 108 | // Control: everything read. uk fails on its keyless sibling, au is clean. |
| 109 | check("complete", s, true) |
| 110 | // Hole 1: the keyless sibling's EDGES object cannot be read. It drops out |
| 111 | // of the fold, and only the keyed box remains to speak for uk. |
| 112 | check("edges GET failure", failingGet{Store: s, key: "control/edges/uk-box-2.json"}, false) |
| 113 | // Hole 2: the keyless sibling's STATUS snapshot cannot be read. Unknown |
| 114 | // liveness is not staleness: it must not be excluded as stale. |
| 115 | check("status GET failure", failingGet{Store: s, key: "status/boxes/uk-box-2.json"}, false) |
| 116 | // A corrupt status body is an incomplete read too (loadBoxes skips it). |
| 117 | corrupt := release.NewDirStore(t.TempDir()) |
| 118 | for _, key := range []string{"ie-box", "uk-box", "uk-box-2", "au-box"} { |
| 119 | body, _, err := s.Get(ctx, "control/edges/"+key+".json") |
| 120 | if err != nil { |
| 121 | t.Fatal(err) |
| 122 | } |
| 123 | if _, err := corrupt.Put(ctx, "control/edges/"+key+".json", body, ""); err != nil { |
| 124 | t.Fatal(err) |
| 125 | } |
| 126 | setBoxSnapshot(t, corrupt, key, time.Now()) |
| 127 | } |
| 128 | if _, err := corrupt.Put(ctx, "status/boxes/uk-box-2.json", []byte("{not json"), mustEtag(t, corrupt, "status/boxes/uk-box-2.json")); err != nil { |
| 129 | t.Fatal(err) |
| 130 | } |
| 131 | check("corrupt status snapshot", corrupt, false) |
| 132 | } |
| 133 | |
| 134 | // Unmatched boxes, ruling (A) (re-review r2): a FRESH status snapshot |
| 135 | // whose box has no edges file is incomplete evidence, whole-request, the |
| 136 | // same as a failed read. Reconcile does not need the edges file to admit a |
| 137 | // box, so a keyless live box whose edges publication failed would |
| 138 | // otherwise never reach the fold while its keyed sibling made the region |
| 139 | // eligible. Matched by the status Box.Label against the edges file's own |
| 140 | // box field, the pair edgesfile.EdgeIPs joins on. A STALE snapshot with no |
| 141 | // edges file is a retired box and is ignored. |
| 142 | func TestFreshStatusWithoutAnEdgesFileIsIncompleteEvidence(t *testing.T) { |
| 143 | ctx := context.Background() |
| 144 | s := release.NewDirStore(t.TempDir()) |
| 145 | for key, body := range map[string]string{"ie-box": homeBox, "uk-box": ukEligible} { |
| 146 | if _, err := s.Put(ctx, "control/edges/"+key+".json", []byte(body), ""); err != nil { |
| 147 | t.Fatal(err) |
| 148 | } |
| 149 | setBoxSnapshot(t, s, key, time.Now()) |
| 150 | } |
| 151 | check := func(name string, wantUK bool) { |
| 152 | t.Helper() |
| 153 | snap, err := readLiveEdgesSnapshot(ctx, s, time.Now()) |
| 154 | if err != nil { |
| 155 | t.Fatalf("%s: %v", name, err) |
| 156 | } |
| 157 | cands := regionCandidates(snap, "ie", testKeyFP) |
| 158 | uk, _ := findCandidate(cands, "uk") |
| 159 | home, _ := findCandidate(cands, "") |
| 160 | if uk.Eligible != wantUK { |
| 161 | t.Errorf("%s: uk = %+v, want eligible %v", name, uk, wantUK) |
| 162 | } |
| 163 | if !home.Eligible { |
| 164 | t.Errorf("%s: home = %+v, want eligible regardless", name, home) |
| 165 | } |
| 166 | } |
| 167 | check("control: every fresh box has an edges file", true) |
| 168 | // Box B reports status, fresh, and has published no edges file. |
| 169 | setBoxSnapshot(t, s, "uk-box-2", time.Now()) |
| 170 | check("fresh status-only box", false) |
| 171 | // The same box gone dark: a retired box, ignored. |
| 172 | setBoxSnapshot(t, s, "uk-box-2", time.Now().Add(-time.Hour)) |
| 173 | check("stale status-only box", true) |
| 174 | } |
| 175 | |
| 176 | // Unmatched boxes, ruling (B) REVERSED (re-review r2, production |
| 177 | // evidence): on the flagship every box has both objects, and a retired |
| 178 | // box leaves both behind, so it reads as a STALE snapshot, never an absent |
| 179 | // one. An edges file whose box has no status object at all is therefore |
| 180 | // not a retired box but unknown liveness, and it makes the evidence |
| 181 | // incomplete, whole-request, like (A). A stale snapshot beside the file is |
| 182 | // the retired box: not fresh, counts for neither side, and the region |
| 183 | // stays eligible through its fresh keyed sibling (the F1 case). |
| 184 | func TestEdgesFileWithoutAnyStatusSnapshotIsIncompleteEvidence(t *testing.T) { |
| 185 | ctx := context.Background() |
| 186 | s := release.NewDirStore(t.TempDir()) |
| 187 | for key, body := range map[string]string{"ie-box": homeBox, "uk-box": ukEligible} { |
| 188 | if _, err := s.Put(ctx, "control/edges/"+key+".json", []byte(body), ""); err != nil { |
| 189 | t.Fatal(err) |
| 190 | } |
| 191 | setBoxSnapshot(t, s, key, time.Now()) |
| 192 | } |
| 193 | if _, err := s.Put(ctx, "control/edges/uk-box-2.json", []byte(ukKeylessSibling), ""); err != nil { |
| 194 | t.Fatal(err) |
| 195 | } |
| 196 | snap, err := readLiveEdgesSnapshot(ctx, s, time.Now()) |
| 197 | if err != nil { |
| 198 | t.Fatal(err) |
| 199 | } |
| 200 | if snap.EvidenceComplete() { |
| 201 | t.Fatalf("a keyless edges file with NO status object left the evidence complete (edges %v, boxes %v)", snap.Complete, snap.BoxesComplete) |
| 202 | } |
| 203 | if c, _ := findCandidate(regionCandidates(snap, "ie", testKeyFP), "uk"); c.Eligible { |
| 204 | t.Fatalf("uk = %+v, want ineligible: a box with no status object has unknown liveness", c) |
| 205 | } |
| 206 | if h, _ := findCandidate(regionCandidates(snap, "ie", testKeyFP), ""); !h.Eligible { |
| 207 | t.Fatalf("home = %+v, want eligible regardless", h) |
| 208 | } |
| 209 | // The retired box: the same edges file beside a STALE snapshot. F1's |
| 210 | // case, and it must keep working. |
| 211 | setBoxSnapshot(t, s, "uk-box-2", time.Now().Add(-time.Hour)) |
| 212 | snap, err = readLiveEdgesSnapshot(ctx, s, time.Now()) |
| 213 | if err != nil { |
| 214 | t.Fatal(err) |
| 215 | } |
| 216 | if !snap.EvidenceComplete() { |
| 217 | t.Fatalf("a stale snapshot beside its edges file made the evidence incomplete (edges %v, boxes %v); a retired box is complete evidence", snap.Complete, snap.BoxesComplete) |
| 218 | } |
| 219 | if c, _ := findCandidate(regionCandidates(snap, "ie", testKeyFP), "uk"); !c.Eligible { |
| 220 | t.Fatalf("retired keyless sibling: uk = %+v, want eligible through the fresh keyed box", c) |
| 221 | } |
| 222 | } |
| 223 | |
| 224 | func mustEtag(t *testing.T, s release.Store, key string) string { |
| 225 | t.Helper() |
| 226 | _, etag, err := s.Get(context.Background(), key) |
| 227 | if err != nil { |
| 228 | t.Fatal(err) |
| 229 | } |
| 230 | return etag |
| 231 | } |
| 232 | |
| 233 | // Incomplete evidence is said once, like the other liveness diagnostics. |
| 234 | func TestIncompleteEvidenceIsLoggedOnce(t *testing.T) { |
| 235 | ctx := context.Background() |
| 236 | s := release.NewDirStore(t.TempDir()) |
| 237 | for key, body := range map[string]string{"ie-box": homeBox, "uk-box": ukEligible} { |
| 238 | if _, err := s.Put(ctx, "control/edges/"+key+".json", []byte(body), ""); err != nil { |
| 239 | t.Fatal(err) |
| 240 | } |
| 241 | setBoxSnapshot(t, s, key, time.Now()) |
| 242 | } |
| 243 | var buf bytes.Buffer |
| 244 | restore := captureLogTo(&buf) |
| 245 | defer restore() |
| 246 | livenessSaid.Delete("incomplete") |
| 247 | for i := 0; i < 2; i++ { |
| 248 | snap, err := readLiveEdgesSnapshot(ctx, failingGet{Store: s, key: "status/boxes/uk-box.json"}, time.Now()) |
| 249 | if err != nil { |
| 250 | t.Fatal(err) |
| 251 | } |
| 252 | if c, _ := findCandidate(regionCandidates(snap, "ie", testKeyFP), "uk"); c.Eligible { |
| 253 | t.Fatalf("round %d: uk eligible on an incomplete read", i) |
| 254 | } |
| 255 | } |
| 256 | if n := strings.Count(buf.String(), "incomplete"); n != 1 { |
| 257 | t.Fatalf("incomplete evidence logged %d times, want once: %s", n, buf.String()) |
| 258 | } |
| 259 | } |
| 260 | |
| 261 | // setEdges creates OR replaces edges files, and gives each box a FRESH |
| 262 | // status snapshot: automatic eligibility needs one (F1), and the fixture |
| 263 | // boxes are all meant to be live unless a test says otherwise through |
| 264 | // setBoxSnapshot. putEdges is create-only (an empty ifMatch on DirStore and |
| 265 | // S3), so it cannot change a box's file. |
| 266 | func setEdges(t *testing.T, fx *consoleFixture, files map[string]string) { |
| 267 | t.Helper() |
| 268 | ctx := context.Background() |
| 269 | for name := range files { |
| 270 | setBoxSnapshot(t, fx.rel, name, time.Now()) |
| 271 | } |
| 272 | for name, body := range files { |
| 273 | key := "control/edges/" + name + ".json" |
| 274 | etag := "" |
| 275 | if _, cur, err := fx.rel.Get(ctx, key); err == nil { |
| 276 | etag = cur |
| 277 | } else if !errors.Is(err, os.ErrNotExist) { |
| 278 | t.Fatal(err) |
| 279 | } |
| 280 | if _, err := fx.rel.Put(ctx, key, []byte(body), etag); err != nil { |
| 281 | t.Fatalf("set %s: %v", key, err) |
| 282 | } |
| 283 | } |
| 284 | } |
| 285 | |
| 286 | // setBoxSnapshot writes (or rewrites) a box's status/boxes/<label>.json |
| 287 | // generated at the given time: now for a live box, an hour ago for one that |
| 288 | // has stopped reporting (status.BoxStale's rule). |
| 289 | func setBoxSnapshot(t *testing.T, s release.Store, label string, at time.Time) { |
| 290 | t.Helper() |
| 291 | if err := status.WriteBox(context.Background(), s, status.Box{Label: label, GeneratedAt: at}); err != nil { |
| 292 | t.Fatalf("snapshot %s: %v", label, err) |
| 293 | } |
| 294 | } |
| 295 | |
| 296 | // retireBox is what a retired box looks like in production (ruling (B) |
| 297 | // reversed, re-review r2): its status snapshot lingers and goes STALE, and |
| 298 | // here its edges file is gone too. Deleting the edges object alone would |
| 299 | // leave a fresh snapshot with no file, which reads as a live box that has |
| 300 | // not published (incomplete evidence), not as an absent region. |
| 301 | func retireBox(t *testing.T, fx *consoleFixture, label string) { |
| 302 | t.Helper() |
| 303 | if err := fx.rel.Delete(context.Background(), "control/edges/"+label+".json"); err != nil && !errors.Is(err, os.ErrNotExist) { |
| 304 | t.Fatal(err) |
| 305 | } |
| 306 | setBoxSnapshot(t, fx.rel, label, time.Now().Add(-time.Hour)) |
| 307 | } |
| 308 | |
| 309 | type failingList struct{ release.Store } |
| 310 | |
| 311 | func (failingList) List(context.Context, string) ([]string, error) { |
| 312 | return nil, errors.New("bucket unreachable") |
| 313 | } |
| 314 | |
| 315 | func captureLogTo(w io.Writer) (restore func()) { |
| 316 | prev, flags := log.Writer(), log.Flags() |
| 317 | log.SetOutput(w) |
| 318 | return func() { log.SetOutput(prev); log.SetFlags(flags) } |
| 319 | } |
| 320 | |
| 321 | // views is a COMPLETE snapshot of the given edges-file bodies (every file |
| 322 | // and every status snapshot read), every box FRESH (F1): the pure fold is |
| 323 | // handed what readLiveEdgesSnapshot would produce for a fleet that is all |
| 324 | // reporting and all readable. markStale takes one box back. |
| 325 | func views(t *testing.T, bodies ...string) edgesSnapshot { |
| 326 | t.Helper() |
| 327 | snap := edgesSnapshot{Complete: true, BoxesComplete: true} |
| 328 | for _, b := range bodies { |
| 329 | var f edgesFilePlacementView |
| 330 | if err := json.Unmarshal([]byte(b), &f); err != nil { |
| 331 | t.Fatal(err) |
| 332 | } |
| 333 | f.Fresh = true |
| 334 | snap.Files = append(snap.Files, f) |
| 335 | } |
| 336 | return snap |
| 337 | } |
| 338 | |
| 339 | // markStale is views' box that has stopped reporting: its edges file is |
| 340 | // still in the snapshot, its status snapshot is not fresh. |
| 341 | func markStale(snap edgesSnapshot, box string) edgesSnapshot { |
| 342 | for i := range snap.Files { |
| 343 | if snap.Files[i].Box == box { |
| 344 | snap.Files[i].Fresh = false |
| 345 | } |
| 346 | } |
| 347 | return snap |
| 348 | } |
| 349 | |
| 350 | // F1: a retired box's edges file lingers (nothing deletes it), so the file |
| 351 | // alone is not liveness. A non-home box counts toward automatic eligibility |
| 352 | // only while its status snapshot is fresh, the rule edgesfile.EdgeIPs uses. |
| 353 | // The existing disappearance test deletes the object; this one leaves it. |
| 354 | func TestRegionCandidatesNeedAFreshBoxSnapshot(t *testing.T) { |
| 355 | ctx := context.Background() |
| 356 | s := release.NewDirStore(t.TempDir()) |
| 357 | for key, body := range map[string]string{"ie-box": homeBox, "uk-box": ukEligible} { |
| 358 | if _, err := s.Put(ctx, "control/edges/"+key+".json", []byte(body), ""); err != nil { |
| 359 | t.Fatal(err) |
| 360 | } |
| 361 | } |
| 362 | now := time.Now() |
| 363 | setBoxSnapshot(t, s, "ie-box", now) // the home box reports too; see ruling (B) reversed |
| 364 | uk := func(name string) regionCandidate { |
| 365 | t.Helper() |
| 366 | snap, err := readLiveEdgesSnapshot(ctx, s, now) |
| 367 | if err != nil { |
| 368 | t.Fatalf("%s: %v", name, err) |
| 369 | } |
| 370 | c, _ := findCandidate(regionCandidates(snap, "ie", testKeyFP), "uk") |
| 371 | return c |
| 372 | } |
| 373 | // No snapshot at all: unknown liveness (incomplete evidence, ruling (B) |
| 374 | // reversed), so not eligible either way, and nothing live publishes uk. |
| 375 | if c := uk("no snapshot"); c.Eligible || c.unavailableReason() != "absent" { |
| 376 | t.Fatalf("no snapshot at all: uk = %+v, want absent", c) |
| 377 | } |
| 378 | setBoxSnapshot(t, s, "uk-box", now.Add(-time.Hour)) |
| 379 | if c := uk("stale"); c.Eligible || c.unavailableReason() != "absent" { |
| 380 | t.Fatalf("stale snapshot, edges file still present: uk = %+v, want absent", c) |
| 381 | } |
| 382 | setBoxSnapshot(t, s, "uk-box", now) |
| 383 | if c := uk("fresh"); !c.Eligible { |
| 384 | t.Fatalf("fresh snapshot: uk = %+v, want eligible", c) |
| 385 | } |
| 386 | // The home candidate stays as it is: eligible throughout, exempt from |
| 387 | // every eligibility fact. |
| 388 | snap, _ := readLiveEdgesSnapshot(ctx, s, now) |
| 389 | if h, _ := findCandidate(regionCandidates(snap, "ie", testKeyFP), ""); !h.Eligible || h.AWSRegion != "eu-west-1" { |
| 390 | t.Fatalf("home = %+v", h) |
| 391 | } |
| 392 | } |
| 393 | |
| 394 | // F1 leaves the explicit door where it was: R9's keyless proof is read |
| 395 | // from the files, like the armed gate it sits beside, so a stale keyless |
| 396 | // box still proves its region keyless (C31 on an explicit --region) while |
| 397 | // automatic placement reads the same region as absent. |
| 398 | func TestStaleBoxesStillCountForTheKeylessProof(t *testing.T) { |
| 399 | snap := markStale(views(t, homeBox, ukNoKey), "uk-box") |
| 400 | c, _ := findCandidate(regionCandidates(snap, "ie", testKeyFP), "uk") |
| 401 | if c.Eligible || c.unavailableReason() != "absent" || !c.NoKeyProven { |
| 402 | t.Fatalf("stale keyless uk = %+v, want absent and proven keyless", c) |
| 403 | } |
| 404 | // A stale KEYED box beside a fresh keyless one: not proven, as before. |
| 405 | keyless2 := edgeJSON("uk-box-2", "1.2.3.5", "eu-west-2", "uk", edgeOpts{hib: bp(true), shared: bp(true), inbound: bp(true)}) |
| 406 | c, _ = findCandidate(regionCandidates(markStale(views(t, homeBox, ukEligible, keyless2), "uk-box"), "ie", testKeyFP), "uk") |
| 407 | if c.NoKeyProven || c.Eligible { |
| 408 | t.Fatalf("stale keyed beside fresh keyless = %+v, want neither proven nor eligible", c) |
| 409 | } |
| 410 | } |
| 411 | |
| 412 | // F1: snapshots that cannot be loaded make no box fresh, and the failure |
| 413 | // is never said twice. The once-per-process throttle lives in edgesfile |
| 414 | // with the read (stage B Task 10), where TestReadLiveSnapshotSaysALoad |
| 415 | // FailureOnce resets it; here an earlier test may already have tripped |
| 416 | // it, so the count is at most one (Task 10 review M10). |
| 417 | func TestLiveEdgesSnapshotLoadFailureIsNotFreshAndLoggedOnce(t *testing.T) { |
| 418 | ctx := context.Background() |
| 419 | s := release.NewDirStore(t.TempDir()) |
| 420 | if _, err := s.Put(ctx, "control/edges/uk-box.json", []byte(ukEligible), ""); err != nil { |
| 421 | t.Fatal(err) |
| 422 | } |
| 423 | setBoxSnapshot(t, s, "uk-box", time.Now()) |
| 424 | var buf bytes.Buffer |
| 425 | restore := captureLogTo(&buf) |
| 426 | defer restore() |
| 427 | for i := 0; i < 2; i++ { |
| 428 | snap, err := readLiveEdgesSnapshot(ctx, failingListPrefix{Store: s, prefix: "status/boxes/"}, time.Now()) |
| 429 | if err != nil { |
| 430 | t.Fatal(err) |
| 431 | } |
| 432 | if c, _ := findCandidate(regionCandidates(snap, "ie", testKeyFP), "uk"); c.Eligible { |
| 433 | t.Fatalf("round %d: snapshots unloadable, uk = %+v, want ineligible", i, c) |
| 434 | } |
| 435 | } |
| 436 | // Count the load-failure line itself. "box snapshots" alone also |
| 437 | // matches the request-level incomplete-read line, which has its own |
| 438 | // once-per-process latch: the test passed only when an earlier test in |
| 439 | // the same process had already tripped it. |
| 440 | if n := strings.Count(buf.String(), "could not load box snapshots"); n > 1 { |
| 441 | t.Fatalf("load failure logged %d times, want at most once: %s", n, buf.String()) |
| 442 | } |
| 443 | } |
| 444 | |
| 445 | // failingListPrefix fails the listing of ONE prefix (the status half of the |
| 446 | // join) and serves every other read. |
| 447 | type failingListPrefix struct { |
| 448 | release.Store |
| 449 | prefix string |
| 450 | } |
| 451 | |
| 452 | func (f failingListPrefix) List(ctx context.Context, prefix string) ([]string, error) { |
| 453 | if prefix == f.prefix { |
| 454 | return nil, errors.New("status bucket unreachable") |
| 455 | } |
| 456 | return f.Store.List(ctx, prefix) |
| 457 | } |
| 458 | |
| 459 | func TestRegionCandidatesNeedPositiveEligibility(t *testing.T) { |
| 460 | ca := edgeJSON("ca-box", "7.7.7.7", "ca-central-1", "ca", edgeOpts{}) |
| 461 | cands := regionCandidates(views(t, homeBox, ukEligible, auPoolOnly, usNoInbound, ca, nzNoHosting, jpNoEIP, fleetBoxFile), "ie", testKeyFP) |
| 462 | want := map[string]string{"": "", "uk": "", "au": "not-eligible", "us": "not-eligible", |
| 463 | "ca": "no-secrets-key", "nz": "not-hosting", "jp": "absent"} |
| 464 | if len(cands) != len(want) { |
| 465 | t.Fatalf("candidates = %+v, want exactly %v (no fleet code)", cands, want) |
| 466 | } |
| 467 | for code, reason := range want { |
| 468 | c, ok := findCandidate(cands, code) |
| 469 | if !ok { |
| 470 | t.Fatalf("no candidate %q", code) |
| 471 | } |
| 472 | if got := c.unavailableReason(); got != reason { |
| 473 | t.Errorf("%q: reason %q, want %q", code, got, reason) |
| 474 | } |
| 475 | } |
| 476 | if h, _ := findCandidate(cands, ""); !h.Home || h.AWSRegion != "eu-west-1" || !h.Eligible { |
| 477 | t.Errorf("home = %+v", h) |
| 478 | } |
| 479 | } |
| 480 | |
| 481 | // Ruling F2, refined: the "no fresh box fails" side counts only boxes |
| 482 | // reconcile would admit for the record, which is the same region code with |
| 483 | // the shared pool served and inbound accepted (reconcile.go inScope and |
| 484 | // the CARLOS_INBOUND=none refusal). A named-pool-only or no-inbound box can |
| 485 | // never be handed the record, so it neither qualifies nor disqualifies the |
| 486 | // region. ABSENT is not the same as false: a writer that predates the |
| 487 | // field is on an unknown config, and reconcile's defaults (CARLOS_POOLS |
| 488 | // unset serves the shared pool, CARLOS_INBOUND unset accepts) admit it, so |
| 489 | // absent still counts as possibly admitted, and so as failing. |
| 490 | func TestBoxesReconcileWouldNotAdmitNeitherQualifyNorDisqualify(t *testing.T) { |
| 491 | keyless := func(shared, inbound *bool) string { |
| 492 | return edgeJSON("uk-box-2", "1.2.3.5", "eu-west-2", "uk", edgeOpts{hib: bp(true), shared: shared, inbound: inbound}) |
| 493 | } |
| 494 | harmless := map[string]string{ |
| 495 | "keyless named-pool-only box": keyless(bp(false), bp(true)), |
| 496 | "keyless no-inbound box": keyless(bp(true), bp(false)), |
| 497 | "keyless neither": keyless(bp(false), bp(false)), |
| 498 | } |
| 499 | for name, body := range harmless { |
| 500 | cands := regionCandidates(views(t, homeBox, ukEligible, body), "ie", testKeyFP) |
| 501 | if c, _ := findCandidate(cands, "uk"); !c.Eligible { |
| 502 | t.Errorf("%s beside a keyed eligible box: uk = %+v, want eligible (reconcile would never admit it)", name, c) |
| 503 | } |
| 504 | if n := eligibleCount(cands); n != 2 { |
| 505 | t.Errorf("%s: %d eligible candidates, want the home and uk", name, n) |
| 506 | } |
| 507 | } |
| 508 | // Possibly admitted, so failing: a keyless box that serves the shared |
| 509 | // pool and takes inbound (the flipped mixed-box case), and one that |
| 510 | // is silent on either field. |
| 511 | hazard := map[string]string{ |
| 512 | "keyless shared-pool inbound box": keyless(bp(true), bp(true)), |
| 513 | "keyless, shared pool absent": keyless(nil, bp(true)), |
| 514 | "keyless, inbound absent": keyless(bp(true), nil), |
| 515 | } |
| 516 | for name, body := range hazard { |
| 517 | if c, _ := findCandidate(regionCandidates(views(t, homeBox, ukEligible, body), "ie", testKeyFP), "uk"); c.Eligible { |
| 518 | t.Errorf("%s beside a keyed eligible box: uk = %+v, want ineligible", name, c) |
| 519 | } |
| 520 | } |
| 521 | // On its own, a box reconcile would not admit still makes no region |
| 522 | // eligible: nothing qualifies it. Keyed, so the reason is the pool. |
| 523 | poolOnlyKeyed := edgeJSON("uk-box-2", "1.2.3.5", "eu-west-2", "uk", edgeOpts{hib: bp(true), shared: bp(false), inbound: bp(true), fp: testKeyFP}) |
| 524 | if c, _ := findCandidate(regionCandidates(views(t, homeBox, poolOnlyKeyed), "ie", testKeyFP), "uk"); c.Eligible || c.unavailableReason() != "not-eligible" { |
| 525 | t.Errorf("pool-only box alone: uk = %+v, want not-eligible", c) |
| 526 | } |
| 527 | } |
| 528 | |
| 529 | // Task 10 (final review, MUST FIX): each capability field ABSENT on its |
| 530 | // own, and each explicitly FALSE on its own, with everything else eligible |
| 531 | // and the key present. The older fixtures that left fields absent also |
| 532 | // lacked the key, so a permissive nil (absent read as yes) would have been |
| 533 | // hidden behind the key check. Unknown is not yes (spec §6.1). |
| 534 | func TestEachCapabilityFieldAloneBlocksEligibility(t *testing.T) { |
| 535 | cases := []struct { |
| 536 | name string |
| 537 | o edgeOpts |
| 538 | reason string |
| 539 | }{ |
| 540 | {"hibernation absent", edgeOpts{shared: bp(true), inbound: bp(true), fp: testKeyFP}, "not-eligible"}, |
| 541 | {"hibernation false", edgeOpts{hib: bp(false), shared: bp(true), inbound: bp(true), fp: testKeyFP}, "not-hosting"}, |
| 542 | {"shared pool absent", edgeOpts{hib: bp(true), inbound: bp(true), fp: testKeyFP}, "not-eligible"}, |
| 543 | {"shared pool false", edgeOpts{hib: bp(true), shared: bp(false), inbound: bp(true), fp: testKeyFP}, "not-eligible"}, |
| 544 | {"inbound absent", edgeOpts{hib: bp(true), shared: bp(true), fp: testKeyFP}, "not-eligible"}, |
| 545 | {"inbound false", edgeOpts{hib: bp(true), shared: bp(true), inbound: bp(false), fp: testKeyFP}, "not-eligible"}, |
| 546 | } |
| 547 | for _, c := range cases { |
| 548 | box := edgeJSON("uk-box", "1.2.3.4", "eu-west-2", "uk", c.o) |
| 549 | cands := regionCandidates(views(t, homeBox, box), "ie", testKeyFP) |
| 550 | uk, ok := findCandidate(cands, "uk") |
| 551 | if !ok { |
| 552 | t.Fatalf("%s: no uk candidate", c.name) |
| 553 | } |
| 554 | if uk.Eligible || uk.unavailableReason() != c.reason { |
| 555 | t.Errorf("%s: uk = %+v (reason %q), want ineligible with reason %q", c.name, uk, uk.unavailableReason(), c.reason) |
| 556 | } |
| 557 | if n := eligibleCount(cands); n != 1 { |
| 558 | t.Errorf("%s: %d eligible candidates, want the home alone", c.name, n) |
| 559 | } |
| 560 | } |
| 561 | // The control: the same box with every field present and true is |
| 562 | // eligible, so the cases above fail on the one field they vary. |
| 563 | if uk, _ := findCandidate(regionCandidates(views(t, homeBox, ukEligible), "ie", testKeyFP), "uk"); !uk.Eligible { |
| 564 | t.Fatalf("control: uk = %+v, want eligible", uk) |
| 565 | } |
| 566 | } |
| 567 | |
| 568 | // R9: a box must hold the deployment's secrets key to receive placed |
| 569 | // instances, and only a CURRENT writer can prove it does not. |
| 570 | func TestRegionCandidatesSecretsKey(t *testing.T) { |
| 571 | cases := []struct { |
| 572 | name string |
| 573 | body string |
| 574 | expected string |
| 575 | reason string |
| 576 | noKeyProven bool |
| 577 | }{ |
| 578 | {"matching key", ukEligible, testKeyFP, "", false}, |
| 579 | {"no key, current writer", ukNoKey, testKeyFP, "no-secrets-key", true}, |
| 580 | {"wrong key, current writer", ukWrongKey, testKeyFP, "no-secrets-key", true}, |
| 581 | {"old writer: unknown", ukOldWriter, testKeyFP, "no-secrets-key", false}, |
| 582 | {"console cannot judge", ukEligible, "", "no-secrets-key", false}, |
| 583 | } |
| 584 | for _, c := range cases { |
| 585 | cand, _ := findCandidate(regionCandidates(views(t, homeBox, c.body), "ie", c.expected), "uk") |
| 586 | if got := cand.unavailableReason(); got != c.reason || cand.NoKeyProven != c.noKeyProven { |
| 587 | t.Errorf("%s: reason %q proven %v, want %q %v", c.name, got, cand.NoKeyProven, c.reason, c.noKeyProven) |
| 588 | } |
| 589 | } |
| 590 | // One box with the key and one without: the region is not PROVEN |
| 591 | // keyless (the explicit door may still use it), but it is NOT eligible |
| 592 | // for automatic placement (final review F2). A record names a region, |
| 593 | // not a box, and reconcile admits every box in the region that serves |
| 594 | // the shared pool and takes inbound: the keyless box can win the lease |
| 595 | // and wake the instance with no config, secrets or store binding. |
| 596 | ukKeyless2 := edgeJSON("uk-box-2", "1.2.3.5", "eu-west-2", "uk", edgeOpts{hib: bp(true), shared: bp(true), inbound: bp(true)}) |
| 597 | cand, _ := findCandidate(regionCandidates(views(t, homeBox, ukEligible, ukKeyless2), "ie", testKeyFP), "uk") |
| 598 | if cand.Eligible || cand.NoKeyProven || cand.unavailableReason() != "not-eligible" { |
| 599 | t.Errorf("mixed region = %+v, want ineligible (not-eligible) and not proven keyless", cand) |
| 600 | } |
| 601 | } |
| 602 | |
| 603 | // F2: a region is automatically eligible only when at least one fresh box |
| 604 | // is fully eligible AND no fresh box publishing the code fails the |
| 605 | // predicate. One box reconcile would admit that cannot serve the record |
| 606 | // takes the region out; stale boxes count for neither side. |
| 607 | func TestEligibilityNeedsEveryFreshBoxToQualify(t *testing.T) { |
| 608 | sibling := func(o edgeOpts) string { return edgeJSON("uk-box-2", "1.2.3.5", "eu-west-2", "uk", o) } |
| 609 | keyed := func(hib, shared, inbound *bool) edgeOpts { |
| 610 | return edgeOpts{hib: hib, shared: shared, inbound: inbound, fp: testKeyFP} |
| 611 | } |
| 612 | bad := map[string]string{ |
| 613 | "keyless": sibling(edgeOpts{hib: bp(true), shared: bp(true), inbound: bp(true)}), |
| 614 | "wrong key": sibling(edgeOpts{hib: bp(true), shared: bp(true), inbound: bp(true), fp: "sha256:" + strings.Repeat("0", 64)}), |
| 615 | "hibernation false": sibling(keyed(bp(false), bp(true), bp(true))), |
| 616 | "hibernation absent": sibling(keyed(nil, bp(true), bp(true))), |
| 617 | "shared pool absent": sibling(keyed(bp(true), nil, bp(true))), |
| 618 | "inbound absent": sibling(keyed(bp(true), bp(true), nil)), |
| 619 | "old writer, every field": sibling(edgeOpts{}), |
| 620 | "no dial address": edgeJSON("uk-box-2", "", "eu-west-2", "uk", eligibleBox), |
| 621 | } |
| 622 | // A sibling that POSITIVELY publishes serves_shared_pool false or |
| 623 | // accepts_inbound false is one reconcile would never hand the record: |
| 624 | // see TestBoxesReconcileWouldNotAdmitNeitherQualifyNorDisqualify. |
| 625 | for name, body := range bad { |
| 626 | cands := regionCandidates(views(t, homeBox, ukEligible, body), "ie", testKeyFP) |
| 627 | c, _ := findCandidate(cands, "uk") |
| 628 | if c.Eligible || c.unavailableReason() == "" { |
| 629 | t.Errorf("%s sibling: uk = %+v, want ineligible", name, c) |
| 630 | } |
| 631 | if n := eligibleCount(cands); n != 1 { |
| 632 | t.Errorf("%s sibling: %d eligible candidates, want the home alone", name, n) |
| 633 | } |
| 634 | // The diagnostics still describe the boxes that ARE there: the |
| 635 | // eligible box is reachable, hosting and keyed, so the C16 word is |
| 636 | // not-eligible, never absent. |
| 637 | if c.unavailableReason() == "absent" { |
| 638 | t.Errorf("%s sibling: uk read as absent with a reachable box present", name) |
| 639 | } |
| 640 | } |
| 641 | // Two fully eligible boxes: eligible. |
| 642 | if c, _ := findCandidate(regionCandidates(views(t, homeBox, ukEligible, sibling(eligibleBox)), "ie", testKeyFP), "uk"); !c.Eligible { |
| 643 | t.Fatalf("two eligible boxes: uk = %+v, want eligible", c) |
| 644 | } |
| 645 | // A STALE failing sibling counts for neither side: the live box alone |
| 646 | // decides, and the region stays eligible. |
| 647 | if c, _ := findCandidate(regionCandidates(markStale(views(t, homeBox, ukEligible, bad["keyless"]), "uk-box-2"), "ie", testKeyFP), "uk"); !c.Eligible { |
| 648 | t.Fatalf("stale keyless sibling: uk = %+v, want eligible", c) |
| 649 | } |
| 650 | // A stale ELIGIBLE box beside a fresh failing one lends nothing. |
| 651 | if c, _ := findCandidate(regionCandidates(markStale(views(t, homeBox, ukEligible, bad["keyless"]), "uk-box"), "ie", testKeyFP), "uk"); c.Eligible || c.unavailableReason() != "no-secrets-key" { |
| 652 | t.Fatalf("stale eligible, fresh keyless: uk = %+v, want no-secrets-key", c) |
| 653 | } |
| 654 | } |
| 655 | |
| 656 | // codex plan-R2 1: a negative proof needs EVERY box. With one file |
| 657 | // unreadable or malformed, a readable keyless box proves nothing. |
| 658 | func TestNoKeyProvenNeedsACompleteRead(t *testing.T) { |
| 659 | incomplete := views(t, homeBox, ukNoKey) |
| 660 | incomplete.Complete = false |
| 661 | if c, _ := findCandidate(regionCandidates(incomplete, "ie", testKeyFP), "uk"); c.NoKeyProven { |
| 662 | t.Fatal("an incomplete read proved a region keyless") |
| 663 | } |
| 664 | ctx := context.Background() |
| 665 | for name, extra := range map[string]string{"malformed": "{not json", "unversioned": `{"box":"uk-box-2"}`} { |
| 666 | s := release.NewDirStore(t.TempDir()) |
| 667 | for key, body := range map[string]string{"ie-box": homeBox, "uk-box": ukNoKey, "uk-box-2": extra} { |
| 668 | if _, err := s.Put(ctx, "control/edges/"+key+".json", []byte(body), ""); err != nil { |
| 669 | t.Fatal(err) |
| 670 | } |
| 671 | } |
| 672 | snap, err := readEdgesSnapshot(ctx, s) |
| 673 | if err != nil || snap.Complete { |
| 674 | t.Fatalf("%s: snapshot complete=%v err=%v, want incomplete", name, snap.Complete, err) |
| 675 | } |
| 676 | if c, _ := findCandidate(regionCandidates(snap, "ie", testKeyFP), "uk"); c.NoKeyProven { |
| 677 | t.Fatalf("%s: proved keyless with a box unassessed", name) |
| 678 | } |
| 679 | } |
| 680 | s := release.NewDirStore(t.TempDir()) |
| 681 | for key, body := range map[string]string{"ie-box": homeBox, "uk-box": ukNoKey, "uk-box-2": ukEligible} { |
| 682 | if _, err := s.Put(ctx, "control/edges/"+key+".json", []byte(body), ""); err != nil { |
| 683 | t.Fatal(err) |
| 684 | } |
| 685 | } |
| 686 | snap, err := readEdgesSnapshot(ctx, failingGet{Store: s, key: "control/edges/uk-box-2.json"}) |
| 687 | if err != nil || snap.Complete { |
| 688 | t.Fatalf("failed GET: complete=%v err=%v, want incomplete", snap.Complete, err) |
| 689 | } |
| 690 | if c, _ := findCandidate(regionCandidates(snap, "ie", testKeyFP), "uk"); c.NoKeyProven { |
| 691 | t.Fatal("a failed GET let the readable keyless box prove the region keyless") |
| 692 | } |
| 693 | } |
| 694 | |
| 695 | type failingGet struct { |
| 696 | release.Store |
| 697 | key string |
| 698 | } |
| 699 | |
| 700 | func (f failingGet) Get(ctx context.Context, key string) ([]byte, string, error) { |
| 701 | if key == f.key { |
| 702 | return nil, "", errors.New("read timeout") |
| 703 | } |
| 704 | return f.Store.Get(ctx, key) |
| 705 | } |
| 706 | |
| 707 | // codex plan-R2 2: a candidate's coordinates come from its fully eligible |
| 708 | // boxes only; ties among them go to the lexically smallest AWS region. The |
| 709 | // mixed region is no longer eligible (F2), but it is still NAMED (placeOf, |
| 710 | // C16's refusal) from the box that qualifies, not from the ones that do not. |
| 711 | func TestCandidateLocationComesFromEligibleBoxes(t *testing.T) { |
| 712 | syd1 := edgeJSON("uk-syd-1", "2.2.2.1", "ap-southeast-2", "uk", edgeOpts{hib: bp(true), shared: bp(true), inbound: bp(true)}) |
| 713 | syd2 := edgeJSON("uk-syd-2", "2.2.2.2", "ap-southeast-2", "uk", edgeOpts{hib: bp(true), shared: bp(true), inbound: bp(false), fp: testKeyFP}) |
| 714 | c, _ := findCandidate(regionCandidates(views(t, homeBox, ukEligible, syd1, syd2), "ie", testKeyFP), "uk") |
| 715 | if c.Eligible || c.AWSRegion != "eu-west-2" { |
| 716 | t.Fatalf("mixed eligibility = %+v, want ineligible (F2) and located at the qualifying box's eu-west-2", c) |
| 717 | } |
| 718 | dub := edgeJSON("uk-dub", "2.2.2.3", "eu-west-1", "uk", eligibleBox) |
| 719 | if c, _ := findCandidate(regionCandidates(views(t, homeBox, ukEligible, dub), "ie", testKeyFP), "uk"); c.AWSRegion != "eu-west-1" { |
| 720 | t.Fatalf("eligible tie = %q, want the lexically smallest eu-west-1", c.AWSRegion) |
| 721 | } |
| 722 | } |
| 723 | |
| 724 | func TestRegionCandidatesFoldTheHomesName(t *testing.T) { |
| 725 | coded := edgeJSON("ie2", "5.6.7.9", "eu-west-1", "ie", eligibleBox) |
| 726 | cands := regionCandidates(views(t, homeBox, coded), "ie", testKeyFP) |
| 727 | if len(cands) != 1 || !cands[0].Home { |
| 728 | t.Fatalf("candidates = %+v, want the home alone", cands) |
| 729 | } |
| 730 | } |
| 731 | |
| 732 | // spec §6.3, §6.5: a candidate with no point is logged once per code, and |
| 733 | // the line carries no caller coordinates (there are none to carry). |
| 734 | func TestRegionCandidatesLogAMissingPointOnce(t *testing.T) { |
| 735 | var buf bytes.Buffer |
| 736 | restore := captureLogTo(&buf) |
| 737 | defer restore() |
| 738 | noPointSaid.Delete("odd") // the throttle is process-wide; -count=N reruns this test |
| 739 | odd := edgeJSON("odd-box", "3.3.3.3", "xx-nowhere-1", "odd", eligibleBox) |
| 740 | regionCandidates(views(t, homeBox, odd), "ie", testKeyFP) |
| 741 | regionCandidates(views(t, homeBox, odd), "ie", testKeyFP) |
| 742 | if n := strings.Count(buf.String(), `region "odd"`); n != 1 { |
| 743 | t.Fatalf("missing-point line logged %d times, want once: %s", n, buf.String()) |
| 744 | } |
| 745 | } |
| 746 | |
| 747 | func TestReadEdgesSnapshotReportsListFailure(t *testing.T) { |
| 748 | if _, err := readEdgesSnapshot(context.Background(), failingList{}); err == nil { |
| 749 | t.Fatal("a failed listing must be an error, not an empty answer (codex R2-2)") |
| 750 | } |
| 751 | snap, err := readEdgesSnapshot(context.Background(), release.NewDirStore(t.TempDir())) |
| 752 | if err != nil || len(snap.Files) != 0 || !snap.Complete { |
| 753 | t.Fatalf("an empty prefix = %+v %v, want complete and empty", snap, err) |
| 754 | } |
| 755 | } |
| 756 | |
| 757 | func TestAppPolicyStates(t *testing.T) { |
| 758 | fx, _ := instanceFixture(t, "code-policy") |
| 759 | acct := &Account{ID: OpsAccountID, Sqid: "bab"} |
| 760 | ctx := context.Background() |
| 761 | if p := fx.srv.appPolicy(ctx, acct, "titogo"); p.State != policyAbsent || !p.allows("uk") { |
| 762 | t.Fatalf("no manifest = %+v", p) |
| 763 | } |
| 764 | m := titoManifest() |
| 765 | m.Pool, m.Regions = "", []string{"uk"} |
| 766 | enableInstances(t, fx, "titogo", m) |
| 767 | if p := fx.srv.appPolicy(ctx, acct, "titogo"); p.State != policyKnown || p.allows("") || !p.allows("uk") || p.pooled() { |
| 768 | t.Fatalf("known = %+v", p) |
| 769 | } |
| 770 | m.Pool = "tito" |
| 771 | enableInstances(t, fx, "titogo", m) |
| 772 | if !fx.srv.appPolicy(ctx, acct, "titogo").pooled() { |
| 773 | t.Fatal("a pooled manifest must read as pooled") |
| 774 | } |
| 775 | if err := fx.st.SetAppPlacement(OpsAccountID, "titogo", "fleet/acme"); err != nil { |
| 776 | t.Fatal(err) |
| 777 | } |
| 778 | if p := fx.srv.appPolicy(ctx, acct, "titogo"); p.Placed != "fleet/acme" { |
| 779 | t.Fatalf("placed = %q", p.Placed) |
| 780 | } |
| 781 | _, etag, err := fx.rel.Get(ctx, provision.ManifestKey("bab", "titogo")) |
| 782 | if err != nil { |
| 783 | t.Fatal(err) |
| 784 | } |
| 785 | if _, err := fx.rel.Put(ctx, provision.ManifestKey("bab", "titogo"), []byte("{not json"), etag); err != nil { |
| 786 | t.Fatal(err) |
| 787 | } |
| 788 | if p := fx.srv.appPolicy(ctx, acct, "titogo"); p.State != policyUnreadable || p.allows("") { |
| 789 | t.Fatalf("unreadable = %+v, want unreadable and nothing allowed", p) |
| 790 | } |
| 791 | fx.srv.cfg.Releases = nil |
| 792 | if p := fx.srv.appPolicy(ctx, acct, "titogo"); p.State != policyUnreadable || p.allows("uk") { |
| 793 | t.Fatalf("no bucket = %+v, want unreadable and nothing allowed", p) |
| 794 | } |
| 795 | } |
| 796 | |
| 797 | // splitCapability pairs two boxes in one region that between them publish |
| 798 | // every eligibility fact while neither publishes them all (codex |
| 799 | // plan-R3 4). Task 11 reuses it for the guess. |
| 800 | var splitCapability = map[string][2]string{ |
| 801 | "keyless ordinary box beside a keyed named-pool-only box": { |
| 802 | edgeJSON("uk-a", "1.2.3.4", "eu-west-2", "uk", edgeOpts{hib: bp(true), shared: bp(true), inbound: bp(true)}), |
| 803 | edgeJSON("uk-b", "1.2.3.5", "eu-west-2", "uk", edgeOpts{hib: bp(true), shared: bp(false), inbound: bp(true), fp: testKeyFP}), |
| 804 | }, |
| 805 | "keyed no-inbound box beside a keyless inbound box": { |
| 806 | edgeJSON("uk-a", "1.2.3.4", "eu-west-2", "uk", edgeOpts{hib: bp(true), shared: bp(true), inbound: bp(false), fp: testKeyFP}), |
| 807 | edgeJSON("uk-b", "1.2.3.5", "eu-west-2", "uk", edgeOpts{hib: bp(true), shared: bp(true), inbound: bp(true)}), |
| 808 | }, |
| 809 | "keyed non-hosting box beside a keyless hosting box": { |
| 810 | edgeJSON("uk-a", "1.2.3.4", "eu-west-2", "uk", edgeOpts{hib: bp(false), shared: bp(true), inbound: bp(true), fp: testKeyFP}), |
| 811 | edgeJSON("uk-b", "1.2.3.5", "eu-west-2", "uk", edgeOpts{hib: bp(true), shared: bp(true), inbound: bp(true)}), |
| 812 | }, |
| 813 | } |
| 814 | |
| 815 | // Eligibility is ONE box's facts, never the region's union of them. |
| 816 | func TestEligibilityNeverCombinesBoxes(t *testing.T) { |
| 817 | for name, boxes := range splitCapability { |
| 818 | cands := regionCandidates(views(t, homeBox, boxes[0], boxes[1]), "ie", testKeyFP) |
| 819 | if c, _ := findCandidate(cands, "uk"); c.Eligible || c.unavailableReason() == "" { |
| 820 | t.Errorf("%s: uk = %+v, want ineligible", name, c) |
| 821 | } |
| 822 | if n := eligibleCount(cands); n != 1 { |
| 823 | t.Errorf("%s: %d eligible candidates, want the home alone", name, n) |
| 824 | } |
| 825 | } |
| 826 | } |
| 827 | |
| 828 | // candsOrHome answers from this console's home and secrets key, and falls |
| 829 | // back to the home alone when the listing fails or there is no bucket. |
| 830 | func TestCandsOrHomeUsesTheServerAndFallsBackToTheHome(t *testing.T) { |
| 831 | fx := newConsoleFixture(t) |
| 832 | ctx := context.Background() |
| 833 | fx.srv.cfg.HomeRegion, fx.srv.cfg.SecretsPub = "ie", testKeyPub |
| 834 | setEdges(t, fx, map[string]string{"ie-box": homeBox, "uk-box": ukEligible, "ie2": edgeJSON("ie2", "5.6.7.9", "eu-west-1", "ie", eligibleBox)}) |
| 835 | cands := fx.srv.candsOrHome(ctx) |
| 836 | if len(cands) != 2 || eligibleCount(cands) != 2 { |
| 837 | t.Fatalf("candidates = %+v, want the home (ie folded in) and uk, both eligible", cands) |
| 838 | } |
| 839 | fx.srv.cfg.SecretsPub = "" |
| 840 | if c, _ := findCandidate(fx.srv.candsOrHome(ctx), "uk"); c.Eligible || c.unavailableReason() != "no-secrets-key" { |
| 841 | t.Fatalf("no console key: uk = %+v, want no-secrets-key", c) |
| 842 | } |
| 843 | for name, store := range map[string]release.Store{"failed listing": failingList{Store: fx.rel}, "no bucket": nil} { |
| 844 | fx.srv.cfg.Releases = store |
| 845 | if cands := fx.srv.candsOrHome(ctx); len(cands) != 1 || !cands[0].Home || !cands[0].Eligible { |
| 846 | t.Errorf("%s: candidates = %+v, want the home alone", name, cands) |
| 847 | } |
| 848 | } |
| 849 | } |
| 850 | |
| 851 | // O6, O7 and O13 to O15 are this file's diagnostics, taken verbatim from |
| 852 | // the approved copy record and compared as quoted literals. |
| 853 | func TestRegionCandidatesCopyMatchesTheRecord(t *testing.T) { |
| 854 | raw, err := os.ReadFile("../../docs/superpowers/plans/2026-10-02-nearest-region-copy.json") |
| 855 | if err != nil { |
| 856 | t.Fatal(err) |
| 857 | } |
| 858 | var rec struct { |
| 859 | Entries []struct { |
| 860 | ID, File, Text string |
| 861 | } `json:"entries"` |
| 862 | } |
| 863 | if err := json.Unmarshal(raw, &rec); err != nil { |
| 864 | t.Fatal(err) |
| 865 | } |
| 866 | src, err := os.ReadFile("regioncandidates.go") |
| 867 | if err != nil { |
| 868 | t.Fatal(err) |
| 869 | } |
| 870 | var ids []string |
| 871 | for _, e := range rec.Entries { |
| 872 | if e.File != "internal/console/regioncandidates.go" { |
| 873 | continue |
| 874 | } |
| 875 | ids = append(ids, e.ID) |
| 876 | if !strings.Contains(string(src), strconv.Quote(e.Text)) { |
| 877 | t.Errorf("%s: regioncandidates.go has no literal %s", e.ID, strconv.Quote(e.Text)) |
| 878 | } |
| 879 | } |
| 880 | if got := strings.Join(ids, ","); got != "O6,O7,O13,O14,O15" { |
| 881 | t.Errorf("the record maps %s to regioncandidates.go, want O6,O7,O13,O14,O15", got) |
| 882 | } |
| 883 | } |
| 884 | |
| 885 | func TestEdgesViewParsesBoxEvidenceAndToleratesItsAbsence(t *testing.T) { |
| 886 | ctx, s := context.Background(), release.NewDirStore(t.TempDir()) |
| 887 | with := `{"v":1,"box":"i-0abc","eip":"1.2.3.4","region":"eu-west-2","placement":"uk","hosts":{},"box_id":"i-0abc","capabilities":["region-ha-1"],"draining":true,"headroom":{"mem_free_mib":2210,"disk_free_mib":31200,"awake":14}}` |
| 888 | without := `{"v":1,"box":"old-box","eip":"5.6.7.8","region":"eu-west-1","hosts":{}}` |
| 889 | for k, b := range map[string]string{"i-0abc": with, "old-box": without} { |
| 890 | if _, err := s.Put(ctx, "control/edges/"+k+".json", []byte(b), ""); err != nil { |
| 891 | t.Fatal(err) |
| 892 | } |
| 893 | } |
| 894 | snap, err := readEdgesSnapshot(ctx, s) |
| 895 | if err != nil || !snap.Complete { |
| 896 | t.Fatalf("snap complete=%v err=%v", snap.Complete, err) |
| 897 | } |
| 898 | by := map[string]edgesFilePlacementView{} |
| 899 | for _, f := range snap.Files { |
| 900 | by[f.Box] = f |
| 901 | } |
| 902 | n := by["i-0abc"] |
| 903 | if n.BoxID != "i-0abc" || len(n.Capabilities) != 1 || !n.Draining || n.Headroom == nil || n.Headroom.Awake != 14 { |
| 904 | t.Fatalf("new box parsed as %+v", n) |
| 905 | } |
| 906 | o := by["old-box"] |
| 907 | if o.BoxID != "" || o.Capabilities != nil || o.Draining || o.Headroom != nil { |
| 908 | t.Fatalf("old box parsed as %+v", o) |
| 909 | } |
| 910 | } |
| 911 | |