| 1 | package console |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "fmt" |
| 6 | "html" |
| 7 | "net/http" |
| 8 | "net/url" |
| 9 | "strings" |
| 10 | "testing" |
| 11 | "time" |
| 12 | |
| 13 | "github.com/carlosframework/platform/internal/registry" |
| 14 | "github.com/carlosframework/platform/internal/release" |
| 15 | "github.com/carlosframework/platform/internal/rolloutwords" |
| 16 | "github.com/carlosframework/platform/internal/status" |
| 17 | ) |
| 18 | |
| 19 | const heldHost = "web.kass.carlosapp.dev" |
| 20 | |
| 21 | func heldFixture(t *testing.T) *consoleFixture { |
| 22 | t.Helper() |
| 23 | fx := newConsoleFixture(t) |
| 24 | fx.addRoute(t, registry.Route{Host: heldHost, App: "kass", Account: fx.sqid, Kind: "instance", Status: "ready", Version: "v2", Channel: "stable"}) |
| 25 | sibling(t, fx, "kass", heldHost, "stable") |
| 26 | writeHeldBox(t, fx, heldHost, "kass", "v2") |
| 27 | return fx |
| 28 | } |
| 29 | |
| 30 | func TestInstancePageShowsInstalledServingAndTheHold(t *testing.T) { |
| 31 | fx := heldFixture(t) |
| 32 | c := signInPaul(t, fx) |
| 33 | code, _, body := get(t, c, fx.u("/apps/kass/instances/"+heldHost)) |
| 34 | if code != http.StatusOK { |
| 35 | t.Fatalf("page = %d", code) |
| 36 | } |
| 37 | for _, want := range []string{ |
| 38 | swapCopy.Installed, "<code>v2</code>", swapCopy.Serving, "<code>v1</code>", swapCopy.Hold, |
| 39 | fmt.Sprintf(swapCopy.HoldText, "v2", rolloutwords.Reason(status.ReasonHealth), "v1"), |
| 40 | fmt.Sprintf(swapCopy.PromoteRetry, "kass", "v2", "stable"), |
| 41 | `action="/a/` + fx.sqid + `/apps/kass/instances/` + heldHost + `/retry"`, |
| 42 | } { |
| 43 | if !strings.Contains(body, want) { |
| 44 | t.Errorf("page lacks %q", want) |
| 45 | } |
| 46 | } |
| 47 | } |
| 48 | |
| 49 | func TestInstancePageWithoutAReportIsUnchanged(t *testing.T) { |
| 50 | fx := newConsoleFixture(t) |
| 51 | c := signInPaul(t, fx) |
| 52 | _, _, body := get(t, c, fx.u("/apps/kass/instances/kass.carlosapp.dev")) |
| 53 | if !strings.Contains(body, instanceCopy.Observed) || strings.Contains(body, swapCopy.Installed) { |
| 54 | t.Fatal("a host with no rollout report must show the page as before") |
| 55 | } |
| 56 | } |
| 57 | |
| 58 | // A box that could not arm its scheduler reports only the installed build: |
| 59 | // the page keeps Installed, shows no serving build, no hold and no Retry. |
| 60 | func TestInstancePageOfAnUnavailableBoxShowsNoHoldAndNoRetry(t *testing.T) { |
| 61 | fx := newConsoleFixture(t) |
| 62 | fx.addRoute(t, registry.Route{Host: heldHost, App: "kass", Account: fx.sqid, Kind: "instance", Status: "ready", Version: "v2", Channel: "stable"}) |
| 63 | sibling(t, fx, "kass", heldHost, "stable") |
| 64 | box := status.CollectBox(nil, status.Probes{MachineID: "box1"}, time.Now()) |
| 65 | box.Metrics = &status.BoxMetrics{ |
| 66 | RouteVersions: map[string]string{heldHost: "v2"}, |
| 67 | Rollout: &status.RolloutReport{Unavailable: "no-cgroup-delegation"}, |
| 68 | } |
| 69 | if err := status.WriteBox(context.Background(), fx.rel, box); err != nil { |
| 70 | t.Fatal(err) |
| 71 | } |
| 72 | c := signInPaul(t, fx) |
| 73 | code, _, body := get(t, c, fx.u("/apps/kass/instances/"+heldHost)) |
| 74 | if code != http.StatusOK { |
| 75 | t.Fatalf("page = %d", code) |
| 76 | } |
| 77 | if !strings.Contains(body, swapCopy.Installed) || !strings.Contains(body, "<code>v2</code>") { |
| 78 | t.Error("an unavailable box's page must keep the installed build") |
| 79 | } |
| 80 | for _, bad := range []string{swapCopy.Hold, swapCopy.RetryButton, "/retry\"", "carlos promote --app"} { |
| 81 | if strings.Contains(body, bad) { |
| 82 | t.Errorf("an unavailable box's page must not show %q", bad) |
| 83 | } |
| 84 | } |
| 85 | } |
| 86 | |
| 87 | func TestBrowserRetryConfirmsThenWrites(t *testing.T) { |
| 88 | fx := heldFixture(t) |
| 89 | c := signInPaul(t, fx) |
| 90 | csrf := sessionToken(t, fx, c) |
| 91 | endpoint := fx.u("/apps/kass/instances/" + heldHost + "/retry") |
| 92 | code, _, body := postForm(t, c, endpoint, url.Values{"csrf": {csrf}}) |
| 93 | title := strings.NewReplacer("{host}", heldHost, "{build}", "v2").Replace(swapCopy.RetryTitle) |
| 94 | if code != http.StatusOK || !strings.Contains(body, title) { |
| 95 | t.Fatalf("confirm = %d, lacks %q", code, title) |
| 96 | } |
| 97 | code, loc, _ := postGated(t, fx, c, endpoint, url.Values{"csrf": {csrf}, "confirmed": {"1"}}) |
| 98 | if code != http.StatusSeeOther || loc != fx.path("/apps/kass/instances/"+heldHost) { |
| 99 | t.Fatalf("retry = %d -> %q", code, loc) |
| 100 | } |
| 101 | r := lastScoped(t, fx, "kass") |
| 102 | if r.Scope.Kind != release.RestartScopeRetry || r.Scope.Value != heldHost { |
| 103 | t.Fatalf("stored = %+v", r) |
| 104 | } |
| 105 | g, err := release.VerifyHostRetry(fx.pub, r.Scope.Signed, fx.sqid, "kass", heldHost, time.Now()) |
| 106 | if err != nil || g.Build != "v2" || g.Epoch != heldEpoch || g.Book != heldBook { |
| 107 | t.Fatalf("signed grant = %+v, %v; want v2 at epoch %d of book %s", g, err, heldEpoch, heldBook) |
| 108 | } |
| 109 | } |
| 110 | |
| 111 | // The browser twin of TestAPIInstanceRetryWithNoKeyWritesNothing. |
| 112 | func TestBrowserRetryWithNoKeyWritesNothing(t *testing.T) { |
| 113 | fx := heldFixture(t) |
| 114 | fx.srv.cfg.Key = nil |
| 115 | c := signInPaul(t, fx) |
| 116 | csrf := sessionToken(t, fx, c) |
| 117 | code, _, body := postGated(t, fx, c, fx.u("/apps/kass/instances/"+heldHost+"/retry"), url.Values{"csrf": {csrf}, "confirmed": {"1"}}) |
| 118 | if code != http.StatusServiceUnavailable || !strings.Contains(body, browserRetryNoKey) { |
| 119 | t.Fatalf("no key = %d, want 503 naming the missing key", code) |
| 120 | } |
| 121 | noRetryWritten(t, fx, "kass") |
| 122 | } |
| 123 | |
| 124 | func TestBrowserRetryRefusals(t *testing.T) { |
| 125 | fx := heldFixture(t) |
| 126 | c := signInPaul(t, fx) |
| 127 | if code, _, _ := postForm(t, c, fx.u("/apps/kass/instances/"+heldHost+"/retry"), url.Values{"csrf": {"wrong"}}); code != http.StatusForbidden { |
| 128 | t.Fatalf("bad csrf = %d", code) |
| 129 | } |
| 130 | csrf := sessionToken(t, fx, c) |
| 131 | code, _, body := postForm(t, c, fx.u("/apps/kass/instances/nope.kass.carlosapp.dev/retry"), url.Values{"csrf": {csrf}, "confirmed": {"1"}}) |
| 132 | if code != http.StatusNotFound || body != "404 page not found\n" { |
| 133 | t.Fatalf("unknown host = %d %q", code, body) |
| 134 | } |
| 135 | } |
| 136 | |
| 137 | // The browser twin of TestAPIInstanceRetryAsksTheBoxsReportFirst: the |
| 138 | // refusal is flashed on the instance page, before any confirmation, and |
| 139 | // nothing is written. |
| 140 | func TestBrowserRetryAsksTheBoxsReportFirst(t *testing.T) { |
| 141 | for _, tc := range []struct { |
| 142 | name string |
| 143 | retryV int |
| 144 | epoch uint64 |
| 145 | book string |
| 146 | want string |
| 147 | }{ |
| 148 | {"an older box", 0, heldEpoch, heldBook, browserRetryUnsupported}, |
| 149 | {"no book", status.HostRetryV, heldEpoch, "", browserRetryUnsupported}, |
| 150 | {"book not saved", status.HostRetryV, 0, heldBook, browserRetryUnsaved}, |
| 151 | } { |
| 152 | t.Run(tc.name, func(t *testing.T) { |
| 153 | fx := newConsoleFixture(t) |
| 154 | fx.addRoute(t, registry.Route{Host: heldHost, App: "kass", Account: fx.sqid, Kind: "instance", Status: "ready", Version: "v2", Channel: "stable"}) |
| 155 | sibling(t, fx, "kass", heldHost, "stable") |
| 156 | writeRetryReport(t, fx, heldHost, "", tc.retryV, tc.epoch, tc.book) |
| 157 | c := signInPaul(t, fx) |
| 158 | csrf := sessionToken(t, fx, c) |
| 159 | endpoint := fx.u("/apps/kass/instances/" + heldHost + "/retry") |
| 160 | for _, form := range []url.Values{{"csrf": {csrf}}, {"csrf": {csrf}, "confirmed": {"1"}}} { |
| 161 | code, loc, body := postGated(t, fx, c, endpoint, form) |
| 162 | if code != http.StatusSeeOther || loc != fx.path("/apps/kass/instances/"+heldHost) { |
| 163 | t.Fatalf("retry = %d -> %q, want the flash's redirect", code, loc) |
| 164 | } |
| 165 | if page := flashAfter(t, fx, c, code, loc, body); !strings.Contains(page, html.EscapeString(tc.want)) { |
| 166 | t.Fatalf("the page after the refusal lacks %q", tc.want) |
| 167 | } |
| 168 | } |
| 169 | noRetryWritten(t, fx, "kass") |
| 170 | }) |
| 171 | } |
| 172 | } |
| 173 | |
| 174 | const downHost = "api.kass.carlosapp.dev" |
| 175 | |
| 176 | // writeDeclinedBox publishes a box that declined the flag but honours its |
| 177 | // failure book: heldHost failed, downHost was left down, both held by v2 at |
| 178 | // the given epoch. Reason is empty, as on the real box (recon D §1). |
| 179 | func writeDeclinedBox(t *testing.T, fx *consoleFixture, code string, epoch uint64) { |
| 180 | t.Helper() |
| 181 | hr := func(attempt string) status.HostRollout { |
| 182 | return status.HostRollout{Account: fx.sqid, App: "kass", Installed: "v2", Good: "v1", Attempt: attempt, AttemptBuild: "v2", |
| 183 | HeldBy: "v2", HeldEpoch: epoch, HeldBook: heldBook, RetryV: status.HostRetryV} |
| 184 | } |
| 185 | box := status.CollectBox(nil, status.Probes{MachineID: "box1"}, time.Now()) |
| 186 | box.Metrics = &status.BoxMetrics{ |
| 187 | RouteVersions: map[string]string{heldHost: "v2", downHost: "v2"}, |
| 188 | Rollout: &status.RolloutReport{Unavailable: code, FailureBook: "honoured", Hosts: map[string]status.HostRollout{ |
| 189 | heldHost: hr(status.AttemptFailed), downHost: hr(status.AttemptDown)}}, |
| 190 | } |
| 191 | if err := status.WriteBox(context.Background(), fx.rel, box); err != nil { |
| 192 | t.Fatal(err) |
| 193 | } |
| 194 | } |
| 195 | |
| 196 | func declinedFixture(t *testing.T, code string, epoch uint64) *consoleFixture { |
| 197 | t.Helper() |
| 198 | fx := newConsoleFixture(t) |
| 199 | for _, h := range []string{heldHost, downHost} { |
| 200 | fx.addRoute(t, registry.Route{Host: h, App: "kass", Account: fx.sqid, Kind: "instance", Status: "ready", Version: "v2", Channel: "stable"}) |
| 201 | sibling(t, fx, "kass", h, "stable") |
| 202 | } |
| 203 | writeDeclinedBox(t, fx, code, epoch) |
| 204 | return fx |
| 205 | } |
| 206 | |
| 207 | // A box that declined the flag still holds a failed build; its page says so |
| 208 | // in words that are true for it (it will not start the build, not "stays |
| 209 | // on"), and offers Retry, whose confirmation says no canary runs there. |
| 210 | func TestInstancePageOfADeclinedBoxShowsTheHoldAndRetry(t *testing.T) { |
| 211 | fx := declinedFixture(t, "host-claims-on", heldEpoch) |
| 212 | c := signInPaul(t, fx) |
| 213 | retry := func(h string) string { return `action="/a/` + fx.sqid + `/apps/kass/instances/` + h + `/retry"` } |
| 214 | for _, tc := range []struct { |
| 215 | host, hold string |
| 216 | }{ |
| 217 | {heldHost, fmt.Sprintf(swapCopy.HoldDeclinedText, "v2")}, |
| 218 | {downHost, fmt.Sprintf(swapCopy.HoldDeclinedDownText, "v2")}, |
| 219 | } { |
| 220 | code, _, body := get(t, c, fx.u("/apps/kass/instances/"+tc.host)) |
| 221 | if code != http.StatusOK { |
| 222 | t.Fatalf("%s: page = %d", tc.host, code) |
| 223 | } |
| 224 | for _, want := range []string{swapCopy.Hold, html.EscapeString(tc.hold), fmt.Sprintf(swapCopy.PromoteRetry, "kass", "v2", "stable"), retry(tc.host)} { |
| 225 | if !strings.Contains(body, want) { |
| 226 | t.Errorf("%s: page lacks %q", tc.host, want) |
| 227 | } |
| 228 | } |
| 229 | for _, bad := range []string{"it failed ()", "stays on", "stays on until", "hold mode"} { |
| 230 | if strings.Contains(body, bad) { |
| 231 | t.Errorf("%s: a declined box's page must not say %q", tc.host, bad) |
| 232 | } |
| 233 | } |
| 234 | } |
| 235 | // The confirmation line is the declined one: no canary runs there. |
| 236 | csrf := sessionToken(t, fx, c) |
| 237 | code, _, body := postForm(t, c, fx.u("/apps/kass/instances/"+heldHost+"/retry"), url.Values{"csrf": {csrf}}) |
| 238 | line := strings.NewReplacer("{host}", heldHost, "{build}", "v2").Replace(swapCopy.RetryLineDeclined) |
| 239 | canary := strings.NewReplacer("{host}", heldHost, "{build}", "v2").Replace(swapCopy.RetryLine) |
| 240 | if code != http.StatusOK || !strings.Contains(body, html.EscapeString(line)) || strings.Contains(body, html.EscapeString(canary)) { |
| 241 | t.Fatalf("confirm = %d; want the declined line %q and not the canary line", code, line) |
| 242 | } |
| 243 | code, _, _ = postGated(t, fx, c, fx.u("/apps/kass/instances/"+heldHost+"/retry"), url.Values{"csrf": {csrf}, "confirmed": {"1"}}) |
| 244 | if code != http.StatusSeeOther { |
| 245 | t.Fatalf("retry = %d", code) |
| 246 | } |
| 247 | if r := lastScoped(t, fx, "kass"); r.Scope.Kind != release.RestartScopeRetry || r.Scope.Value != heldHost { |
| 248 | t.Fatalf("stored = %+v", r) |
| 249 | } |
| 250 | } |
| 251 | |
| 252 | // A declined host whose book is unsaved (epoch 0) shows the hold, which is |
| 253 | // true, and no Retry form, which would only be refused. |
| 254 | func TestInstancePageOfADeclinedHostAtEpochZeroShowsTheHoldAndNoForm(t *testing.T) { |
| 255 | fx := declinedFixture(t, "host-claims-on", 0) |
| 256 | c := signInPaul(t, fx) |
| 257 | _, _, body := get(t, c, fx.u("/apps/kass/instances/"+heldHost)) |
| 258 | if !strings.Contains(body, html.EscapeString(fmt.Sprintf(swapCopy.HoldDeclinedText, "v2"))) { |
| 259 | t.Error("the hold row must show") |
| 260 | } |
| 261 | if strings.Contains(body, "/retry\"") || strings.Contains(body, ">"+swapCopy.RetryButton+"<") { |
| 262 | t.Error("a host at epoch 0 must not offer Retry") |
| 263 | } |
| 264 | } |
| 265 | |
| 266 | // A refusing code reports no per-host state worth acting on, and an unknown |
| 267 | // code is never retried: with a held host, still no hold row and no button. |
| 268 | func TestInstancePageOfARefusingBoxWithAHeldHostShowsNoHoldAndNoRetry(t *testing.T) { |
| 269 | for _, code := range []string{"no-deploy-store", "a-newer-code"} { |
| 270 | t.Run(code, func(t *testing.T) { |
| 271 | fx := declinedFixture(t, code, heldEpoch) |
| 272 | c := signInPaul(t, fx) |
| 273 | _, _, body := get(t, c, fx.u("/apps/kass/instances/"+heldHost)) |
| 274 | for _, bad := range []string{swapCopy.Hold, "/retry\"", "carlos promote --app", html.EscapeString(fmt.Sprintf(swapCopy.HoldDeclinedText, "v2"))} { |
| 275 | if strings.Contains(body, bad) { |
| 276 | t.Errorf("a %s box's page must not show %q", code, bad) |
| 277 | } |
| 278 | } |
| 279 | }) |
| 280 | } |
| 281 | } |
| 282 | |
| 283 | // A retry unsupported by an older box hides the form but keeps the hold. |
| 284 | func TestInstancePageOfAnOlderBoxKeepsTheHoldAndHidesRetry(t *testing.T) { |
| 285 | fx := newConsoleFixture(t) |
| 286 | fx.addRoute(t, registry.Route{Host: heldHost, App: "kass", Account: fx.sqid, Kind: "instance", Status: "ready", Version: "v2", Channel: "stable"}) |
| 287 | sibling(t, fx, "kass", heldHost, "stable") |
| 288 | writeRetryReport(t, fx, heldHost, "", 0, heldEpoch, heldBook) |
| 289 | c := signInPaul(t, fx) |
| 290 | _, _, body := get(t, c, fx.u("/apps/kass/instances/"+heldHost)) |
| 291 | if !strings.Contains(body, swapCopy.Hold) || !strings.Contains(body, "carlos promote --app") { |
| 292 | t.Error("the hold row and the promote line must stay") |
| 293 | } |
| 294 | if strings.Contains(body, "/retry\"") { |
| 295 | t.Error("a box that cannot take a retry must not be offered one") |
| 296 | } |
| 297 | } |
| 298 | |
| 299 | // A report with no book identity alone (retry_v and the epoch present) hides |
| 300 | // the form, since the door would refuse it, but keeps the hold row. |
| 301 | func TestInstancePageWithNoHeldBookHidesRetryAndKeepsTheHold(t *testing.T) { |
| 302 | fx := newConsoleFixture(t) |
| 303 | fx.addRoute(t, registry.Route{Host: heldHost, App: "kass", Account: fx.sqid, Kind: "instance", Status: "ready", Version: "v2", Channel: "stable"}) |
| 304 | sibling(t, fx, "kass", heldHost, "stable") |
| 305 | writeRetryReport(t, fx, heldHost, "", status.HostRetryV, heldEpoch, "") |
| 306 | c := signInPaul(t, fx) |
| 307 | _, _, body := get(t, c, fx.u("/apps/kass/instances/"+heldHost)) |
| 308 | if !strings.Contains(body, swapCopy.Hold) || !strings.Contains(body, "carlos promote --app") { |
| 309 | t.Error("the hold row and the promote line must stay") |
| 310 | } |
| 311 | if strings.Contains(body, "/retry\"") { |
| 312 | t.Error("a report with no book identity must not be offered Retry") |
| 313 | } |
| 314 | } |
| 315 | |