package edgesfile import ( "bytes" "context" "errors" "log" "os" "strings" "testing" "time" "github.com/carlosframework/platform/internal/release" "github.com/carlosframework/platform/internal/status" ) // Stage B Task 10: the console's automatic-eligibility read and the box // agent's relocation chooser read the same live snapshot. Every listed // edges file is read, each box's liveness is its status snapshot's // freshness, a stale snapshot is a retired box (complete evidence, not // fresh), and a file with no snapshot at all, or an unreadable file, makes // the evidence incomplete. BoxChoice carries every field the predicate // reads. func TestLiveSnapshotJoinsEdgesFilesWithLiveness(t *testing.T) { ctx := context.Background() now := time.Date(2026, 10, 7, 12, 0, 0, 0, time.UTC) s := release.NewDirStore(t.TempDir()) put := func(box, body string) { t.Helper() if _, err := s.Put(ctx, Key(box), []byte(body), ""); err != nil { t.Fatal(err) } } snapshot := func(box string, at time.Time) { t.Helper() if err := status.WriteBox(ctx, s, status.Box{Label: box, GeneratedAt: at}); err != nil { t.Fatal(err) } } 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":{}}`) put("box-b", `{"v":1,"box":"box-b","eip":"203.0.113.2","region":"eu-west-1","placement":"ie","hosts":{}}`) snapshot("box-a", now.Add(-time.Second)) snapshot("box-b", now.Add(-24*time.Hour)) live, err := ReadLiveSnapshot(ctx, s, now) if err != nil { t.Fatal(err) } if !live.EvidenceComplete() || len(live.Files) != 2 { t.Fatalf("snapshot %+v", live) } fresh := map[string]bool{} for _, f := range live.Files { fresh[f.Box] = f.Fresh } if !fresh["box-a"] || fresh["box-b"] { t.Fatalf("liveness %v: box-a fresh, box-b retired", fresh) } choice, err := LiveSnapshot(ctx, s, now) if err != nil || !choice.Complete || len(choice.Boxes) != 2 { t.Fatalf("boxchoice %+v %v", choice, err) } for _, b := range choice.Boxes { 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) { t.Fatalf("box-a lost a field: %+v", b) } } // A box with an edges file and no status snapshot: unknown liveness. put("box-c", `{"v":1,"box":"box-c","region":"eu-west-1","placement":"ie","hosts":{}}`) if live, err = ReadLiveSnapshot(ctx, s, now); err != nil || live.EvidenceComplete() { t.Fatalf("a file with no snapshot left the evidence complete: %+v %v", live, err) } // An unreadable file: incomplete, the others still read. put("box-d", `not json`) plain, err := ReadSnapshot(ctx, s) if err != nil || plain.Complete || len(plain.Files) != 3 { t.Fatalf("an unreadable file: %+v %v", plain, err) } } // Task 10 review M5: no edges file at all is not complete evidence by // itself. A fresh box with no edges file makes the evidence incomplete // (ruling (A)), exactly as it does beside other files; with no file and // no fresh box the evidence is complete and empty. func TestLiveSnapshotWithNoEdgesFilesStillJoinsLiveness(t *testing.T) { ctx := context.Background() now := time.Date(2026, 10, 7, 12, 0, 0, 0, time.UTC) s := release.NewDirStore(t.TempDir()) live, err := ReadLiveSnapshot(ctx, s, now) if err != nil || !live.EvidenceComplete() || len(live.Files) != 0 { t.Fatalf("an empty deployment: %+v %v", live, err) } if err := status.WriteBox(ctx, s, status.Box{Label: "box-a", GeneratedAt: now.Add(-time.Second)}); err != nil { t.Fatal(err) } if live, err = ReadLiveSnapshot(ctx, s, now); err != nil || live.EvidenceComplete() { t.Fatalf("a fresh box with no edges file left the evidence complete: %+v %v", live, err) } if choice, err := LiveSnapshot(ctx, s, now); err != nil || choice.Complete { t.Fatalf("boxchoice complete over a fresh box with no edges file: %+v %v", choice, err) } } // The load failure is said once per process: the throttle the console // and the pass share lives here (Task 10 review M10). func TestReadLiveSnapshotSaysALoadFailureOnce(t *testing.T) { ctx := context.Background() s := release.NewDirStore(t.TempDir()) if _, err := s.Put(ctx, Key("box-a"), []byte(`{"v":1,"box":"box-a","region":"eu-west-1","hosts":{}}`), ""); err != nil { t.Fatal(err) } var buf bytes.Buffer log.SetOutput(&buf) t.Cleanup(func() { log.SetOutput(os.Stderr) }) livenessSaid.Delete("load") for range 2 { live, err := ReadLiveSnapshot(ctx, failingStatusList{s}, time.Now()) if err != nil || live.EvidenceComplete() { t.Fatalf("snapshots unloadable: %+v %v", live, err) } } if n := strings.Count(buf.String(), "could not load box snapshots"); n != 1 { t.Fatalf("said %d times, want once:\n%s", n, buf.String()) } } // failingStatusList fails the status half of the join and serves the rest. type failingStatusList struct{ release.Store } func (f failingStatusList) List(ctx context.Context, prefix string) ([]string, error) { if strings.HasPrefix(prefix, "status/") { return nil, errors.New("status bucket unreachable") } return f.Store.List(ctx, prefix) }