| 1 | package main |
| 2 | |
| 3 | import ( |
| 4 | "bytes" |
| 5 | "context" |
| 6 | "fmt" |
| 7 | "net/http" |
| 8 | "net/http/httptest" |
| 9 | "strings" |
| 10 | "testing" |
| 11 | ) |
| 12 | |
| 13 | // TestPrintStatusInstancesLabelsPlatformAndStatesItsLimits is the CLI half of |
| 14 | // what the console door promises. Two things have to survive rendering. |
| 15 | // |
| 16 | // A row that matched no route is the box's own overhead — the console, the |
| 17 | // agent, a system unit — and it prints under its unit name and the word |
| 18 | // (platform) rather than as two blank cells a reader would take for missing |
| 19 | // data. |
| 20 | // |
| 21 | // And the table is followed, every time, by the two ways it misleads: a |
| 22 | // hibernating instance has no process and so no reading, and the readings are |
| 23 | // fleet-wide while the account each is attributed to comes from the console's |
| 24 | // own box's registry. Neither caveat is conditional, because both are ways a |
| 25 | // SHORT list reads as a complete one, and short is exactly when nobody thinks |
| 26 | // to ask. |
| 27 | func TestPrintStatusInstancesLabelsPlatformAndStatesItsLimits(t *testing.T) { |
| 28 | rows := []statusInstanceRow{ |
| 29 | {Box: "box-a", Host: "app.example.com", Account: "cdef", App: "widgets", |
| 30 | CPU: f64(0.25), Mem: i64(1 << 20)}, |
| 31 | {Box: "box-a", Unit: "console.service", Account: "bab", Platform: true}, |
| 32 | } |
| 33 | var buf bytes.Buffer |
| 34 | printStatusInstances(&buf, rows, "") |
| 35 | out := buf.String() |
| 36 | |
| 37 | for _, want := range []string{ |
| 38 | "app.example.com", "cdef", "widgets", "0.250", "1MB", |
| 39 | "console.service", "(platform)", |
| 40 | "awake instances only", |
| 41 | "console's own box's route registry", |
| 42 | } { |
| 43 | if !strings.Contains(out, want) { |
| 44 | t.Errorf("missing %q in:\n%s", want, out) |
| 45 | } |
| 46 | } |
| 47 | } |
| 48 | |
| 49 | // TestPrintStatusLayoutRendersSortedAndMarksStale pins the CLI table for |
| 50 | // `carlos status --layout` (R21): the approved heading, byte for byte; rows |
| 51 | // sorted by box then host regardless of the order the door answered in; and |
| 52 | // a stale box's name suffixed with the approved stale marker. |
| 53 | func TestPrintStatusLayoutRendersSortedAndMarksStale(t *testing.T) { |
| 54 | boxes := []statusLayoutBox{ |
| 55 | {Box: "zeta", Mode: "flat", Stale: false, Rows: []statusLayoutRow{ |
| 56 | {Host: "z.example.com", Layout: "flat"}, |
| 57 | }}, |
| 58 | {Box: "alpha", Mode: "nested", Stale: true, Rows: []statusLayoutRow{ |
| 59 | {Host: "b.example.com", Layout: "foreign", Reason: "db and socket are neither default"}, |
| 60 | {Host: "a.example.com", Layout: "nested"}, |
| 61 | }}, |
| 62 | } |
| 63 | var buf bytes.Buffer |
| 64 | printStatusLayout(&buf, boxes) |
| 65 | out := buf.String() |
| 66 | |
| 67 | if !strings.Contains(out, "BOX HOST LAYOUT REASON") { |
| 68 | t.Fatalf("missing the approved heading:\n%s", out) |
| 69 | } |
| 70 | if !strings.Contains(out, "alpha (stale)") { |
| 71 | t.Errorf("stale box not marked:\n%s", out) |
| 72 | } |
| 73 | if strings.Contains(out, "zeta (stale)") { |
| 74 | t.Errorf("fresh box marked stale:\n%s", out) |
| 75 | } |
| 76 | |
| 77 | // Sorted by box then host: alpha's two rows (host-sorted) come before |
| 78 | // zeta's, regardless of the door's own order. |
| 79 | aIdx := strings.Index(out, "a.example.com") |
| 80 | bIdx := strings.Index(out, "b.example.com") |
| 81 | zIdx := strings.Index(out, "z.example.com") |
| 82 | if aIdx < 0 || bIdx < 0 || zIdx < 0 || !(aIdx < bIdx && bIdx < zIdx) { |
| 83 | t.Fatalf("rows not sorted box-then-host:\n%s", out) |
| 84 | } |
| 85 | if !strings.Contains(out, "db and socket are neither default") { |
| 86 | t.Errorf("missing the foreign row's reason:\n%s", out) |
| 87 | } |
| 88 | if strings.Contains(out, "WARNING") { |
| 89 | t.Errorf("no box is mixed, yet a warning printed:\n%s", out) |
| 90 | } |
| 91 | } |
| 92 | |
| 93 | // A box the door marks mixed (nested rows beside flat ones) gets a warning |
| 94 | // after the table naming it: on that box the nested rows are not isolated |
| 95 | // from the flat tenant that still shares the agent's uid. |
| 96 | func TestPrintStatusLayoutWarnsOnAMixedBox(t *testing.T) { |
| 97 | boxes := []statusLayoutBox{ |
| 98 | {Box: "kass-1", Mode: "nested", Mixed: true, Rows: []statusLayoutRow{ |
| 99 | {Host: "a.example.com", Layout: "nested"}, |
| 100 | {Host: "b.example.com", Layout: "flat", Reason: "awake (stopped for the move in 40m0s unless it sleeps first)"}, |
| 101 | }}, |
| 102 | {Box: "kass-2", Mode: "nested", Rows: []statusLayoutRow{{Host: "c.example.com", Layout: "nested"}}}, |
| 103 | } |
| 104 | var buf bytes.Buffer |
| 105 | printStatusLayout(&buf, boxes) |
| 106 | out := buf.String() |
| 107 | if !strings.Contains(out, "WARNING: kass-1 runs nested instances beside flat, foreign or external ones") { |
| 108 | t.Fatalf("mixed box not warned about:\n%s", out) |
| 109 | } |
| 110 | if strings.Contains(out, "WARNING: kass-2") { |
| 111 | t.Errorf("an all-nested box warned about:\n%s", out) |
| 112 | } |
| 113 | } |
| 114 | |
| 115 | // TestStatusLayoutListTranslatesNotFound pins the approved 404 message |
| 116 | // (cli.status.layout.notfound), byte for byte — the same "not found" this |
| 117 | // console gives a non-operator or predates the door with, which is why the |
| 118 | // message covers both readings. |
| 119 | func TestStatusLayoutListTranslatesNotFound(t *testing.T) { |
| 120 | ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| 121 | http.NotFound(w, r) |
| 122 | })) |
| 123 | defer ts.Close() |
| 124 | |
| 125 | _, err := newTestSession(ts.URL).statusLayoutList(context.Background()) |
| 126 | if err == nil { |
| 127 | t.Fatal("want an error on 404") |
| 128 | } |
| 129 | want := "not found. this needs the fleet-operator bit on your account, or this console predates `carlos status --layout` (deploy a console built from current main)" |
| 130 | if err.Error() != want { |
| 131 | t.Fatalf("error = %q, want %q", err.Error(), want) |
| 132 | } |
| 133 | } |
| 134 | |
| 135 | // TestStatusLayoutListDecodesBoxes pins the fetch half: the door's |
| 136 | // {"boxes":[...]} shape decodes into statusLayoutBox/statusLayoutRow. |
| 137 | func TestStatusLayoutListDecodesBoxes(t *testing.T) { |
| 138 | ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| 139 | if r.URL.Path != "/api/cli/status/layout" { |
| 140 | t.Errorf("path = %s", r.URL.Path) |
| 141 | } |
| 142 | w.Header().Set("Content-Type", "application/json") |
| 143 | fmt.Fprint(w, `{"boxes":[{"box":"ie","mode":"nested","stale":false,"rows":[{"host":"h.example.com","layout":"nested"}]}]}`) |
| 144 | })) |
| 145 | defer ts.Close() |
| 146 | |
| 147 | boxes, err := newTestSession(ts.URL).statusLayoutList(context.Background()) |
| 148 | if err != nil { |
| 149 | t.Fatal(err) |
| 150 | } |
| 151 | if len(boxes) != 1 || boxes[0].Box != "ie" || boxes[0].Mode != "nested" || boxes[0].Stale || |
| 152 | len(boxes[0].Rows) != 1 || boxes[0].Rows[0].Host != "h.example.com" || boxes[0].Rows[0].Layout != "nested" { |
| 153 | t.Fatalf("boxes = %+v", boxes) |
| 154 | } |
| 155 | } |
| 156 | |
| 157 | // Boxes that disagree about their mode get one warning naming both |
| 158 | // sides; a stale box is left out of it, and agreement gets none. |
| 159 | func TestPrintStatusLayoutWarnsWhenBoxesDisagreeAboutTheMode(t *testing.T) { |
| 160 | boxes := []statusLayoutBox{ |
| 161 | {Box: "kass-1", Mode: "nested", Rows: []statusLayoutRow{{Host: "a.example.com", Layout: "nested"}}}, |
| 162 | {Box: "kass-2", Mode: "none", Rows: []statusLayoutRow{{Host: "b.example.com", Layout: "flat"}}}, |
| 163 | {Box: "kass-3", Mode: "flat", Rows: []statusLayoutRow{{Host: "c.example.com", Layout: "flat"}}}, |
| 164 | {Box: "kass-4", Mode: "none", Stale: true, Rows: []statusLayoutRow{{Host: "d.example.com", Layout: "flat"}}}, |
| 165 | } |
| 166 | var buf bytes.Buffer |
| 167 | printStatusLayout(&buf, boxes) |
| 168 | out := buf.String() |
| 169 | want := "WARNING: kass-1 is nested while kass-2 and kass-3 are not: an instance placed or moved onto a box that is not nested starts flat there." |
| 170 | if !strings.Contains(out, want) { |
| 171 | t.Fatalf("disagreeing boxes not warned about:\n%s", out) |
| 172 | } |
| 173 | if strings.Contains(out, "kass-4") && strings.Contains(out[strings.Index(out, "WARNING"):], "kass-4") { |
| 174 | t.Errorf("a stale box was counted:\n%s", out) |
| 175 | } |
| 176 | buf.Reset() |
| 177 | printStatusLayout(&buf, boxes[:1]) |
| 178 | if strings.Contains(buf.String(), "WARNING") { |
| 179 | t.Errorf("an agreeing fleet warned:\n%s", buf.String()) |
| 180 | } |
| 181 | } |
| 182 | |
| 183 | // A box nested by default is marked on its name; a box the default has not |
| 184 | // reached says why under the table; a stale box's reason is not repeated. |
| 185 | func TestPrintStatusLayoutSaysNestedByDefaultAndWhyNot(t *testing.T) { |
| 186 | boxes := []statusLayoutBox{ |
| 187 | {Box: "kass-1", Mode: "nested", Default: true, Rows: []statusLayoutRow{{Host: "a.example.com", Layout: "nested"}}}, |
| 188 | {Box: "kass-2", Mode: "none", DefaultBlocked: "isolation not ready: probe: pid1 fail", Rows: []statusLayoutRow{{Host: "b.example.com", Layout: "flat"}}}, |
| 189 | {Box: "kass-3", Mode: "none", Stale: true, DefaultBlocked: "no activator is armed on this box, so nothing could wake nested", Rows: []statusLayoutRow{{Host: "c.example.com", Layout: "flat"}}}, |
| 190 | } |
| 191 | var buf bytes.Buffer |
| 192 | printStatusLayout(&buf, boxes) |
| 193 | out := buf.String() |
| 194 | if !strings.Contains(out, "kass-1 (nested by default) a.example.com nested") { |
| 195 | t.Errorf("nested by default not marked on the box:\n%s", out) |
| 196 | } |
| 197 | if !strings.Contains(out, "kass-2 is not nested by default: isolation not ready: probe: pid1 fail") { |
| 198 | t.Errorf("blocked reason missing:\n%s", out) |
| 199 | } |
| 200 | if strings.Contains(out, "kass-3 is not nested by default") { |
| 201 | t.Errorf("a stale box's reason was repeated:\n%s", out) |
| 202 | } |
| 203 | } |
| 204 | |