carlos / platform Public

Clone
git clone https://amadan.net/carlos/platform

Plain git — no account needed to clone.

Download

Download this file

1package console
2
3import (
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
19const heldHost = "web.kass.carlosapp.dev"
20
21func 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
30func 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
49func 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.
60func 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
87func 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.
112func 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
124func 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.
140func 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
174const 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).
179func 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
196func 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.
210func 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.
254func 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.
268func 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.
284func 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.
301func 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