| 1 | package edgesfile |
| 2 | |
| 3 | import ( |
| 4 | "bytes" |
| 5 | "context" |
| 6 | "errors" |
| 7 | "log" |
| 8 | "os" |
| 9 | "strings" |
| 10 | "testing" |
| 11 | "time" |
| 12 | |
| 13 | "github.com/carlosframework/platform/internal/release" |
| 14 | "github.com/carlosframework/platform/internal/status" |
| 15 | ) |
| 16 | |
| 17 | // Stage B Task 10: the console's automatic-eligibility read and the box |
| 18 | // agent's relocation chooser read the same live snapshot. Every listed |
| 19 | // edges file is read, each box's liveness is its status snapshot's |
| 20 | // freshness, a stale snapshot is a retired box (complete evidence, not |
| 21 | // fresh), and a file with no snapshot at all, or an unreadable file, makes |
| 22 | // the evidence incomplete. BoxChoice carries every field the predicate |
| 23 | // reads. |
| 24 | func TestLiveSnapshotJoinsEdgesFilesWithLiveness(t *testing.T) { |
| 25 | ctx := context.Background() |
| 26 | now := time.Date(2026, 10, 7, 12, 0, 0, 0, time.UTC) |
| 27 | s := release.NewDirStore(t.TempDir()) |
| 28 | put := func(box, body string) { |
| 29 | t.Helper() |
| 30 | if _, err := s.Put(ctx, Key(box), []byte(body), ""); err != nil { |
| 31 | t.Fatal(err) |
| 32 | } |
| 33 | } |
| 34 | snapshot := func(box string, at time.Time) { |
| 35 | t.Helper() |
| 36 | if err := status.WriteBox(ctx, s, status.Box{Label: box, GeneratedAt: at}); err != nil { |
| 37 | t.Fatal(err) |
| 38 | } |
| 39 | } |
| 40 | put("box-a", `{"v":1,"box":"box-a","box_id":"i-a","eip":"203.0.113.1","region":"eu-west-1","placement":"ie","hibernation_configured":true,"secrets_key_fp":"fp","capabilities":["region-ha-1"],"headroom":{"mem_free_mib":512,"disk_free_mib":2048,"awake":3},"hosts":{}}`) |
| 41 | put("box-b", `{"v":1,"box":"box-b","eip":"203.0.113.2","region":"eu-west-1","placement":"ie","hosts":{}}`) |
| 42 | snapshot("box-a", now.Add(-time.Second)) |
| 43 | snapshot("box-b", now.Add(-24*time.Hour)) |
| 44 | |
| 45 | live, err := ReadLiveSnapshot(ctx, s, now) |
| 46 | if err != nil { |
| 47 | t.Fatal(err) |
| 48 | } |
| 49 | if !live.EvidenceComplete() || len(live.Files) != 2 { |
| 50 | t.Fatalf("snapshot %+v", live) |
| 51 | } |
| 52 | fresh := map[string]bool{} |
| 53 | for _, f := range live.Files { |
| 54 | fresh[f.Box] = f.Fresh |
| 55 | } |
| 56 | if !fresh["box-a"] || fresh["box-b"] { |
| 57 | t.Fatalf("liveness %v: box-a fresh, box-b retired", fresh) |
| 58 | } |
| 59 | choice, err := LiveSnapshot(ctx, s, now) |
| 60 | if err != nil || !choice.Complete || len(choice.Boxes) != 2 { |
| 61 | t.Fatalf("boxchoice %+v %v", choice, err) |
| 62 | } |
| 63 | for _, b := range choice.Boxes { |
| 64 | if b.Box == "box-a" && (b.BoxID != "i-a" || b.SecretsKeyFP != "fp" || !b.Fresh || b.Headroom == nil || b.Headroom.Awake != 3 || len(b.Capabilities) != 1 || b.HibernationConfigured == nil || !*b.HibernationConfigured) { |
| 65 | t.Fatalf("box-a lost a field: %+v", b) |
| 66 | } |
| 67 | } |
| 68 | |
| 69 | // A box with an edges file and no status snapshot: unknown liveness. |
| 70 | put("box-c", `{"v":1,"box":"box-c","region":"eu-west-1","placement":"ie","hosts":{}}`) |
| 71 | if live, err = ReadLiveSnapshot(ctx, s, now); err != nil || live.EvidenceComplete() { |
| 72 | t.Fatalf("a file with no snapshot left the evidence complete: %+v %v", live, err) |
| 73 | } |
| 74 | // An unreadable file: incomplete, the others still read. |
| 75 | put("box-d", `not json`) |
| 76 | plain, err := ReadSnapshot(ctx, s) |
| 77 | if err != nil || plain.Complete || len(plain.Files) != 3 { |
| 78 | t.Fatalf("an unreadable file: %+v %v", plain, err) |
| 79 | } |
| 80 | } |
| 81 | |
| 82 | // Task 10 review M5: no edges file at all is not complete evidence by |
| 83 | // itself. A fresh box with no edges file makes the evidence incomplete |
| 84 | // (ruling (A)), exactly as it does beside other files; with no file and |
| 85 | // no fresh box the evidence is complete and empty. |
| 86 | func TestLiveSnapshotWithNoEdgesFilesStillJoinsLiveness(t *testing.T) { |
| 87 | ctx := context.Background() |
| 88 | now := time.Date(2026, 10, 7, 12, 0, 0, 0, time.UTC) |
| 89 | s := release.NewDirStore(t.TempDir()) |
| 90 | live, err := ReadLiveSnapshot(ctx, s, now) |
| 91 | if err != nil || !live.EvidenceComplete() || len(live.Files) != 0 { |
| 92 | t.Fatalf("an empty deployment: %+v %v", live, err) |
| 93 | } |
| 94 | if err := status.WriteBox(ctx, s, status.Box{Label: "box-a", GeneratedAt: now.Add(-time.Second)}); err != nil { |
| 95 | t.Fatal(err) |
| 96 | } |
| 97 | if live, err = ReadLiveSnapshot(ctx, s, now); err != nil || live.EvidenceComplete() { |
| 98 | t.Fatalf("a fresh box with no edges file left the evidence complete: %+v %v", live, err) |
| 99 | } |
| 100 | if choice, err := LiveSnapshot(ctx, s, now); err != nil || choice.Complete { |
| 101 | t.Fatalf("boxchoice complete over a fresh box with no edges file: %+v %v", choice, err) |
| 102 | } |
| 103 | } |
| 104 | |
| 105 | // The load failure is said once per process: the throttle the console |
| 106 | // and the pass share lives here (Task 10 review M10). |
| 107 | func TestReadLiveSnapshotSaysALoadFailureOnce(t *testing.T) { |
| 108 | ctx := context.Background() |
| 109 | s := release.NewDirStore(t.TempDir()) |
| 110 | if _, err := s.Put(ctx, Key("box-a"), []byte(`{"v":1,"box":"box-a","region":"eu-west-1","hosts":{}}`), ""); err != nil { |
| 111 | t.Fatal(err) |
| 112 | } |
| 113 | var buf bytes.Buffer |
| 114 | log.SetOutput(&buf) |
| 115 | t.Cleanup(func() { log.SetOutput(os.Stderr) }) |
| 116 | livenessSaid.Delete("load") |
| 117 | for range 2 { |
| 118 | live, err := ReadLiveSnapshot(ctx, failingStatusList{s}, time.Now()) |
| 119 | if err != nil || live.EvidenceComplete() { |
| 120 | t.Fatalf("snapshots unloadable: %+v %v", live, err) |
| 121 | } |
| 122 | } |
| 123 | if n := strings.Count(buf.String(), "could not load box snapshots"); n != 1 { |
| 124 | t.Fatalf("said %d times, want once:\n%s", n, buf.String()) |
| 125 | } |
| 126 | } |
| 127 | |
| 128 | // failingStatusList fails the status half of the join and serves the rest. |
| 129 | type failingStatusList struct{ release.Store } |
| 130 | |
| 131 | func (f failingStatusList) List(ctx context.Context, prefix string) ([]string, error) { |
| 132 | if strings.HasPrefix(prefix, "status/") { |
| 133 | return nil, errors.New("status bucket unreachable") |
| 134 | } |
| 135 | return f.Store.List(ctx, prefix) |
| 136 | } |
| 137 | |