| 1 | package main |
| 2 | |
| 3 | import ( |
| 4 | "bytes" |
| 5 | "strings" |
| 6 | "testing" |
| 7 | "time" |
| 8 | |
| 9 | "github.com/carlosframework/platform/internal/status" |
| 10 | ) |
| 11 | |
| 12 | func f64(v float64) *float64 { return &v } |
| 13 | func i64(v int64) *int64 { return &v } |
| 14 | |
| 15 | // TestPrintLiveBoxesSuppressesStaleMetrics is the CLI half of the rule the |
| 16 | // console page enforces: a host that stopped reporting prints as UNREACHABLE |
| 17 | // with how long it has been dark, and NONE of its last readings. The operator |
| 18 | // running this has bucket credentials, so the numbers are theirs to see — the |
| 19 | // point is that a dead box's numbers are not a current answer to anything. |
| 20 | func TestPrintLiveBoxesSuppressesStaleMetrics(t *testing.T) { |
| 21 | now := time.Now() |
| 22 | boxes := []status.Box{ |
| 23 | { |
| 24 | GeneratedAt: now, Label: "ada-stargazing", Region: "eu-west-1", |
| 25 | Metrics: &status.BoxMetrics{ |
| 26 | MemTotalBytes: i64(2 << 30), MemAvailBytes: i64(1 << 30), |
| 27 | Load1: f64(0.42), DiskFreeBytes: i64(5 << 30), |
| 28 | Release: "abc1234", Tunnel: "connected", |
| 29 | }, |
| 30 | }, |
| 31 | { |
| 32 | GeneratedAt: now.Add(-90 * time.Minute), Label: "grace-kayaking", Region: "eu-west-2", |
| 33 | Metrics: &status.BoxMetrics{ |
| 34 | MemTotalBytes: i64(9 << 30), MemAvailBytes: i64(7 << 30), |
| 35 | Load1: f64(8.25), DiskFreeBytes: i64(3 << 30), |
| 36 | Release: "beefcaf", Tunnel: "connected", |
| 37 | }, |
| 38 | }, |
| 39 | } |
| 40 | var buf bytes.Buffer |
| 41 | printLiveBoxes(&buf, boxes, now) |
| 42 | out := buf.String() |
| 43 | |
| 44 | for _, want := range []string{ |
| 45 | "ada-stargazing", "eu-west-1", "release=abc1234", "tunnel=connected", |
| 46 | "mem=1.0GB/2.0GB", "load=0.42", "disk-free=5.0GB", |
| 47 | "grace-kayaking", "UNREACHABLE", "1h30m0s", |
| 48 | } { |
| 49 | if !strings.Contains(out, want) { |
| 50 | t.Fatalf("missing %q in:\n%s", want, out) |
| 51 | } |
| 52 | } |
| 53 | for _, leaked := range []string{"beefcaf", "8.25", "7.0GB", "9.0GB", "3.0GB"} { |
| 54 | if strings.Contains(out, leaked) { |
| 55 | t.Fatalf("stale host's reading %q printed:\n%s", leaked, out) |
| 56 | } |
| 57 | } |
| 58 | } |
| 59 | |
| 60 | // TestPrintLiveBoxesPreUpgradeHost: a box running code older than the metrics |
| 61 | // block is fresh and fine — it says so, in the same columns, rather than |
| 62 | // printing a row of fabricated zeros or dropping out of the listing. |
| 63 | func TestPrintLiveBoxesPreUpgradeHost(t *testing.T) { |
| 64 | now := time.Now() |
| 65 | var buf bytes.Buffer |
| 66 | printLiveBoxes(&buf, []status.Box{ |
| 67 | {GeneratedAt: now, Label: "alan-foraging"}, // no metrics, no region |
| 68 | {GeneratedAt: now, Label: "iris-sailing", Metrics: &status.BoxMetrics{Tunnel: "reconnecting"}}, |
| 69 | }, now) |
| 70 | out := buf.String() |
| 71 | if !strings.Contains(out, "alan-foraging") || !strings.Contains(out, "no metrics block") { |
| 72 | t.Fatalf("pre-upgrade host row wrong:\n%s", out) |
| 73 | } |
| 74 | // A metrics block with only some fields known dashes the rest — never a |
| 75 | // zero, the same "unknown is not a number" rule the status package holds. |
| 76 | if !strings.Contains(out, "tunnel=reconnecting") || !strings.Contains(out, "mem=-/-") || |
| 77 | !strings.Contains(out, "load=-") || !strings.Contains(out, "disk-free=-") || |
| 78 | !strings.Contains(out, "release=-") { |
| 79 | t.Fatalf("partial metrics row wrong:\n%s", out) |
| 80 | } |
| 81 | for _, line := range strings.Split(strings.TrimRight(out, "\n"), "\n") { |
| 82 | if !strings.HasPrefix(line, "alan-foraging") && !strings.HasPrefix(line, "iris-sailing") { |
| 83 | t.Fatalf("row does not start with its label (alignment broken):\n%s", out) |
| 84 | } |
| 85 | } |
| 86 | } |
| 87 | |
| 88 | // TestPrintLiveBoxesSortsByLabel: the listing is stable across runs, so a |
| 89 | // diff between two invocations is a change in the fleet, not in list order. |
| 90 | func TestPrintLiveBoxesSortsByLabel(t *testing.T) { |
| 91 | now := time.Now() |
| 92 | var buf bytes.Buffer |
| 93 | printLiveBoxes(&buf, []status.Box{ |
| 94 | {GeneratedAt: now, Label: "zoe-welding"}, |
| 95 | {GeneratedAt: now, Label: "ada-stargazing"}, |
| 96 | }, now) |
| 97 | if a, z := strings.Index(buf.String(), "ada-"), strings.Index(buf.String(), "zoe-"); a > z { |
| 98 | t.Fatalf("rows are not label-sorted:\n%s", buf.String()) |
| 99 | } |
| 100 | } |
| 101 | |
| 102 | // TestPrintLiveBoxesSaysWhetherAlertsAreDelivered is discussion #192's |
| 103 | // visibility ask: a box with no alert sink must read as not delivered in the |
| 104 | // operator's own listing, not as silence. A box that predates the field |
| 105 | // prints a dash, since it did not say. A sink whose client could not be |
| 106 | // built reads as unavailable, never as a working sink. |
| 107 | func TestPrintLiveBoxesSaysWhetherAlertsAreDelivered(t *testing.T) { |
| 108 | now := time.Now() |
| 109 | var buf bytes.Buffer |
| 110 | printLiveBoxes(&buf, []status.Box{ |
| 111 | {GeneratedAt: now, Label: "a-none", Metrics: &status.BoxMetrics{Alerts: &status.AlertDelivery{}}}, |
| 112 | {GeneratedAt: now, Label: "b-sns", Metrics: &status.BoxMetrics{Alerts: &status.AlertDelivery{SNS: true}}}, |
| 113 | {GeneratedAt: now, Label: "c-both", Metrics: &status.BoxMetrics{Alerts: &status.AlertDelivery{Webhook: true, SNS: true, SNSFailures: 3}}}, |
| 114 | {GeneratedAt: now, Label: "d-old", Metrics: &status.BoxMetrics{Tunnel: "connected"}}, |
| 115 | {GeneratedAt: now, Label: "e-unavail", Metrics: &status.BoxMetrics{Alerts: &status.AlertDelivery{SNSUnavailable: true, SNSFailures: 1}}}, |
| 116 | {GeneratedAt: now, Label: "f-hook-unavail", Metrics: &status.BoxMetrics{Alerts: &status.AlertDelivery{Webhook: true, SNSUnavailable: true}}}, |
| 117 | }, now) |
| 118 | want := map[string]string{ |
| 119 | "a-none": "alerts=not-delivered", |
| 120 | "b-sns": "alerts=sns\n", |
| 121 | "c-both": "alerts=webhook+sns (sns failed 3)", |
| 122 | "d-old": "alerts=-", |
| 123 | "e-unavail": "alerts=sns-unavailable (sns failed 1)", |
| 124 | "f-hook-unavail": "alerts=webhook (sns-unavailable)", |
| 125 | } |
| 126 | lines := strings.Split(strings.TrimRight(buf.String(), "\n"), "\n") |
| 127 | if len(lines) != len(want) { |
| 128 | t.Fatalf("got %d rows, want %d:\n%s", len(lines), len(want), buf.String()) |
| 129 | } |
| 130 | for label, frag := range want { |
| 131 | var rows []string |
| 132 | for _, line := range lines { |
| 133 | if strings.HasPrefix(line, label+" ") { |
| 134 | rows = append(rows, line) |
| 135 | } |
| 136 | } |
| 137 | if len(rows) != 1 { |
| 138 | t.Errorf("%s: appears on %d rows, want exactly 1:\n%s", label, len(rows), buf.String()) |
| 139 | continue |
| 140 | } |
| 141 | if !strings.Contains(rows[0]+"\n", frag) { |
| 142 | t.Errorf("%s: want %q in %q", label, frag, rows[0]) |
| 143 | } |
| 144 | } |
| 145 | } |
| 146 | |
| 147 | // TestPrintLiveBoxesShowsReleaseRefusals: a build the box rolled back (D1) |
| 148 | // reads as its own line under the box, naming the host, the version, since |
| 149 | // when and why, so the operator does not have to read the journal. |
| 150 | func TestPrintLiveBoxesShowsReleaseRefusals(t *testing.T) { |
| 151 | now := time.Date(2026, 10, 6, 10, 0, 0, 0, time.UTC) |
| 152 | var buf bytes.Buffer |
| 153 | printLiveBoxes(&buf, []status.Box{ |
| 154 | {GeneratedAt: now, Label: "ada-stargazing", Metrics: &status.BoxMetrics{ |
| 155 | Tunnel: "connected", |
| 156 | ReleaseRefusals: []status.ReleaseRefusal{{ |
| 157 | Host: "console.example.com", Version: "v2", |
| 158 | RefusedAt: time.Date(2026, 10, 6, 9, 30, 40, 0, time.UTC), Reason: "exited: status=2", |
| 159 | }}, |
| 160 | }}, |
| 161 | }, now) |
| 162 | want := "refused console.example.com v2 since 2026-10-06T09:30:40Z: exited: status=2\n" |
| 163 | if !strings.Contains(buf.String(), want) { |
| 164 | t.Fatalf("missing %q in:\n%s", want, buf.String()) |
| 165 | } |
| 166 | } |
| 167 | |
| 168 | // Final review F6: a rollback that did not come up says so on the same line. |
| 169 | func TestPrintLiveBoxesShowsAFailedRollback(t *testing.T) { |
| 170 | now := time.Date(2026, 10, 6, 10, 0, 0, 0, time.UTC) |
| 171 | var buf bytes.Buffer |
| 172 | printLiveBoxes(&buf, []status.Box{ |
| 173 | {GeneratedAt: now, Label: "ada-stargazing", Metrics: &status.BoxMetrics{ |
| 174 | Tunnel: "connected", |
| 175 | ReleaseRefusals: []status.ReleaseRefusal{{ |
| 176 | Host: "console.example.com", Version: "v2", |
| 177 | RefusedAt: time.Date(2026, 10, 6, 9, 30, 40, 0, time.UTC), Reason: "exited: status=2", |
| 178 | RecoveryError: "start console.service: exit status 1", |
| 179 | }}, |
| 180 | }}, |
| 181 | }, now) |
| 182 | want := "refused console.example.com v2 since 2026-10-06T09:30:40Z: exited: status=2 (rollback failed: start console.service: exit status 1)" |
| 183 | if !strings.Contains(buf.String(), want) { |
| 184 | t.Fatalf("missing %q in:\n%s", want, buf.String()) |
| 185 | } |
| 186 | } |
| 187 | |
| 188 | // A wrong-program refusal (D2) is not a failed start. |
| 189 | func TestPrintLiveBoxesShowsAWrongProgramRefusal(t *testing.T) { |
| 190 | now := time.Date(2026, 10, 6, 10, 0, 0, 0, time.UTC) |
| 191 | var buf bytes.Buffer |
| 192 | printLiveBoxes(&buf, []status.Box{ |
| 193 | {GeneratedAt: now, Label: "ada-stargazing", Metrics: &status.BoxMetrics{ |
| 194 | Tunnel: "connected", |
| 195 | ReleaseRefusals: []status.ReleaseRefusal{{ |
| 196 | Host: "console.example.com", Version: "v2", Kind: "wrong-program", |
| 197 | RefusedAt: time.Date(2026, 10, 6, 9, 30, 40, 0, time.UTC), Reason: "wrong program: example.com/carlos", |
| 198 | }}, |
| 199 | }}, |
| 200 | }, now) |
| 201 | want := "refused console.example.com v2 since 2026-10-06T09:30:40Z: wrong program (example.com/carlos)\n" |
| 202 | if !strings.Contains(buf.String(), want) { |
| 203 | t.Fatalf("missing %q in:\n%s", want, buf.String()) |
| 204 | } |
| 205 | } |
| 206 | |
| 207 | // A not-proven refusal (D3, plan 3 ruling 3) is neither a failed start nor a |
| 208 | // rollback: the staged bytes did not match dev's proof. |
| 209 | func TestPrintLiveBoxesShowsANotProvenRefusal(t *testing.T) { |
| 210 | now := time.Date(2026, 10, 6, 10, 0, 0, 0, time.UTC) |
| 211 | var buf bytes.Buffer |
| 212 | printLiveBoxes(&buf, []status.Box{ |
| 213 | {GeneratedAt: now, Label: "ada-stargazing", Metrics: &status.BoxMetrics{ |
| 214 | Tunnel: "connected", |
| 215 | ReleaseRefusals: []status.ReleaseRefusal{{ |
| 216 | Host: "console.example.com", Version: "v2", Kind: "not-proven", |
| 217 | RefusedAt: time.Date(2026, 10, 6, 9, 30, 40, 0, time.UTC), Reason: "not proven: staged sha256 aaaa, dev proved bbbb", |
| 218 | }}, |
| 219 | }}, |
| 220 | }, now) |
| 221 | want := "refused console.example.com v2 since 2026-10-06T09:30:40Z: staged bytes do not match dev's proof (staged sha256 aaaa, dev proved bbbb)\n" |
| 222 | if !strings.Contains(buf.String(), want) { |
| 223 | t.Fatalf("missing %q in:\n%s", want, buf.String()) |
| 224 | } |
| 225 | if strings.Contains(buf.String(), "rollback") { |
| 226 | t.Fatalf("a not-proven row mentions a rollback:\n%s", buf.String()) |
| 227 | } |
| 228 | } |
| 229 | |
| 230 | // D3: a box holding a host on its current build for a dev proof says so, with |
| 231 | // the short sha and when the wait began. |
| 232 | func TestPrintLiveBoxesShowsAProofWait(t *testing.T) { |
| 233 | now := time.Date(2026, 10, 6, 10, 0, 0, 0, time.UTC) |
| 234 | var buf bytes.Buffer |
| 235 | printLiveBoxes(&buf, []status.Box{{GeneratedAt: now, Label: "ada-stargazing", Metrics: &status.BoxMetrics{ |
| 236 | ProofWaits: []status.ProofWait{{Host: "console.example.com", Version: "v2", |
| 237 | SHA256: "e47fcc1e0123456789abcdef", Prover: "dev", Since: now.Add(-time.Hour)}}, |
| 238 | }}}, now) |
| 239 | want := " waiting for a dev proof: console.example.com v2 (sha256 e47fcc1e0123) since 2026-10-06T09:00:00Z" |
| 240 | if !strings.Contains(buf.String(), want) { |
| 241 | t.Fatalf("missing %q in:\n%s", want, buf.String()) |
| 242 | } |
| 243 | } |
| 244 | |
| 245 | // Ruling 7: with no prover named the wait is a policy problem. The line says |
| 246 | // so and prints the policy's own error exactly as given, never "a proof". |
| 247 | func TestPrintLiveBoxesShowsAPolicyProofWait(t *testing.T) { |
| 248 | now := time.Date(2026, 10, 6, 10, 0, 0, 0, time.UTC) |
| 249 | var buf bytes.Buffer |
| 250 | printLiveBoxes(&buf, []status.Box{{GeneratedAt: now, Label: "ada-stargazing", Metrics: &status.BoxMetrics{ |
| 251 | ProofWaits: []status.ProofWait{{Host: "console.example.com", Version: "v2", |
| 252 | Reason: "proof policy: line 3: unknown prover", Since: now.Add(-time.Hour)}}, |
| 253 | }}}, now) |
| 254 | out := buf.String() |
| 255 | want := " waiting for a proof (policy: proof policy: line 3: unknown prover): console.example.com v2 (sha256 -) since 2026-10-06T09:00:00Z" |
| 256 | if !strings.Contains(out, want) { |
| 257 | t.Fatalf("missing %q in:\n%s", want, out) |
| 258 | } |
| 259 | if strings.Contains(out, "a proof") || strings.Contains(out, "a - proof") { |
| 260 | t.Fatalf("an empty prover printed as a blank or dash:\n%s", out) |
| 261 | } |
| 262 | } |
| 263 | |
| 264 | // A named prover's wait carries the gate's reason through unchanged. |
| 265 | func TestPrintLiveBoxesShowsAProofWaitReason(t *testing.T) { |
| 266 | now := time.Date(2026, 10, 6, 10, 0, 0, 0, time.UTC) |
| 267 | var buf bytes.Buffer |
| 268 | printLiveBoxes(&buf, []status.Box{{GeneratedAt: now, Label: "ada-stargazing", Metrics: &status.BoxMetrics{ |
| 269 | ProofWaits: []status.ProofWait{{Host: "console.example.com", Version: "v2", SHA256: "abc", |
| 270 | Prover: "dev", Reason: "proof from dev: signature does not verify", Since: now}}, |
| 271 | }}}, now) |
| 272 | want := "since 2026-10-06T10:00:00Z: proof from dev: signature does not verify\n" |
| 273 | if !strings.Contains(buf.String(), want) { |
| 274 | t.Fatalf("missing %q in:\n%s", want, buf.String()) |
| 275 | } |
| 276 | } |
| 277 | |
| 278 | func TestHumanBytesAndFloat(t *testing.T) { |
| 279 | for _, c := range []struct { |
| 280 | in *int64 |
| 281 | want string |
| 282 | }{ |
| 283 | {nil, "-"}, |
| 284 | {i64(0), "0B"}, |
| 285 | {i64(512), "512B"}, |
| 286 | {i64(1536), "1.5KB"}, |
| 287 | {i64(3 << 20), "3.0MB"}, |
| 288 | {i64(2 << 30), "2.0GB"}, |
| 289 | } { |
| 290 | if got := humanBytes(c.in); got != c.want { |
| 291 | t.Errorf("humanBytes(%v) = %q, want %q", c.in, got, c.want) |
| 292 | } |
| 293 | } |
| 294 | if got := humanFloat(nil); got != "-" { |
| 295 | t.Errorf("humanFloat(nil) = %q, want -", got) |
| 296 | } |
| 297 | if got := humanFloat(f64(1.5)); got != "1.50" { |
| 298 | t.Errorf("humanFloat(1.5) = %q, want 1.50", got) |
| 299 | } |
| 300 | if got := orDash(""); got != "-" { |
| 301 | t.Errorf("orDash(\"\") = %q, want -", got) |
| 302 | } |
| 303 | } |
| 304 | |