zdd-6 task 6: the Deploys card on the app settings page
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
8 files changed,
+299
−1
internal/console/appswap.go+83 −0internal/console/appswap_test.go+83 −0internal/console/claim_invariant_fixtures_test.go+4 −0internal/console/server.go+9 −0internal/console/swapcopy.go+97 −0internal/console/templates/app_settings.html+21 −0internal/console/testdata/claim-routes.txt+1 −0internal/provision/plan9_b8_census_test.go+1 −1
diff --git a/internal/console/appswap.go b/internal/console/appswap.go| new file mode 100644 |
| index 000000000..c215111b5 |
| --- /dev/null |
| +++ b/internal/console/appswap.go |
| @@ -0,0 +1,83 @@ |
| +package console |
| + |
| +import ( |
| + "context" |
| + "crypto/subtle" |
| + "fmt" |
| + "log" |
| + "net/http" |
| + "strconv" |
| + "strings" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/appconfig" |
| +) |
| + |
| +// handleAppSwap is the Deploys card's form (spec §4.1, §9.2): any member of |
| +// the app, gated in AGENTS.md's order (session, CSRF, then the byte-identical |
| +// 404 for an app that is not the caller's), as the restart action is. |
| +func (s *Server) handleAppSwap(w http.ResponseWriter, r *http.Request) { |
| + addr, raw, acct, _, ok := s.account(w, r) |
| + if !ok { |
| + return |
| + } |
| + if subtle.ConstantTimeCompare([]byte(r.FormValue("csrf")), []byte(raw)) != 1 { |
| + s.renderMessage(w, r, http.StatusForbidden, "Refused", "Cross-site request blocked.") |
| + return |
| + } |
| + app := r.PathValue("app") |
| + has, err := s.store.HasAppIn(acct.ID, app) |
| + if err != nil { |
| + s.renderMessage(w, r, http.StatusInternalServerError, "Error", "Could not read your apps. Please try again.") |
| + return |
| + } |
| + if !has { |
| + http.NotFound(w, r) |
| + return |
| + } |
| + back := func(kind, msg string) { |
| + s.setFlash(w, kind, msg) |
| + http.Redirect(w, r, appSettingsURL(acct.Sqid, app, "general"), http.StatusSeeOther) |
| + } |
| + if s.cfg.Releases == nil { |
| + back("err", swapCopy.NoBucket) |
| + return |
| + } |
| + swap := strings.TrimSpace(r.FormValue("swap")) |
| + n, err := strconv.Atoi(strings.TrimSpace(r.FormValue("swap_concurrency"))) |
| + if (swap != appconfig.SwapOverlap && swap != appconfig.SwapHold) || err != nil || n < 1 { |
| + back("err", swapCopy.Invalid) |
| + return |
| + } |
| + if !s.allowWrite(Principal{Kind: PrincipalUser, Address: addr}, time.Now()) { |
| + back("err", swapCopy.RateLimited) |
| + return |
| + } |
| + ctx, cancel := context.WithTimeout(r.Context(), configStoreTimeout) |
| + defer cancel() |
| + got, err := appconfig.SetSwapPolicy(ctx, s.cfg.Releases, acct.Sqid, app, swap, n) |
| + if err != nil { |
| + log.Printf("console: swap policy %s/%s: %v", acct.Sqid, app, err) |
| + back("err", swapCopy.StoreFailed) |
| + return |
| + } |
| + s.audit(acct, app, addr, "swap-policy", fmt.Sprintf(apiSwapAudit, app, got.Swap, got.Concurrency, "console")) |
| + s.bumpSerial(ctx) |
| + back("ok", fmt.Sprintf(swapCopy.Saved, got.Swap, got.Concurrency)) |
| +} |
| + |
| +// loadSwapPolicy fills the Deploys card. A failed read says so rather than |
| +// showing the defaults as if they were the setting (ErrLevelUnread's rule). |
| +func (s *Server) loadSwapPolicy(ctx context.Context, p *page, acct *Account) { |
| + if s.cfg.Releases == nil { |
| + p.SwapUnread = true |
| + return |
| + } |
| + sp, err := appconfig.GetSwapPolicy(ctx, s.cfg.Releases, acct.Sqid, p.AppName) |
| + if err != nil { |
| + log.Printf("console: swap policy %s/%s: %v", acct.Sqid, p.AppName, err) |
| + p.SwapUnread = true |
| + return |
| + } |
| + p.SwapPolicy = sp |
| +} |
diff --git a/internal/console/appswap_test.go b/internal/console/appswap_test.go| new file mode 100644 |
| index 000000000..890a3b4ca |
| --- /dev/null |
| +++ b/internal/console/appswap_test.go |
| @@ -0,0 +1,83 @@ |
| +package console |
| + |
| +import ( |
| + "context" |
| + "fmt" |
| + "net/http" |
| + "net/url" |
| + "strings" |
| + "testing" |
| + |
| + "github.com/carlosframework/platform/internal/appconfig" |
| +) |
| + |
| +func TestAppSettingsShowsTheSwapCard(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + if _, err := appconfig.SetSwapPolicy(context.Background(), fx.rel, fx.sqid, "kass", "hold", 3); err != nil { |
| + t.Fatal(err) |
| + } |
| + c := signInPaul(t, fx) |
| + code, _, body := get(t, c, fx.u("/apps/kass/settings")) |
| + if code != http.StatusOK { |
| + t.Fatalf("settings = %d", code) |
| + } |
| + for _, want := range []string{swapCopy.Heading, `action="/a/` + fx.sqid + `/apps/kass/swap"`, |
| + `<option value="hold" selected>`, `name="swap_concurrency"`, `value="3"`} { |
| + if !strings.Contains(body, want) { |
| + t.Errorf("settings page lacks %q", want) |
| + } |
| + } |
| +} |
| + |
| +func TestAppSwapFormSaves(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + c := signInPaul(t, fx) |
| + csrf := sessionToken(t, fx, c) |
| + code, loc, _ := postForm(t, c, fx.u("/apps/kass/swap"), |
| + url.Values{"csrf": {csrf}, "swap": {"hold"}, "swap_concurrency": {"2"}}) |
| + if code != http.StatusSeeOther || loc != fx.path("/apps/kass/settings") { |
| + t.Fatalf("save = %d -> %q", code, loc) |
| + } |
| + got, err := appconfig.GetSwapPolicy(context.Background(), fx.rel, fx.sqid, "kass") |
| + if err != nil || got != (appconfig.SwapPolicy{Swap: "hold", Concurrency: 2}) { |
| + t.Fatalf("stored = %+v, %v", got, err) |
| + } |
| + if n := serial(t, fx); n != 1 { |
| + t.Fatalf("serial = %d, want 1", n) |
| + } |
| + if _, _, body := get(t, c, fx.u("/apps/kass/settings")); !strings.Contains(body, fmt.Sprintf(swapCopy.Saved, "hold", 2)) { |
| + t.Errorf("no saved notice after the redirect") |
| + } |
| +} |
| + |
| +func TestAppSwapFormRefusesBadInput(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + c := signInPaul(t, fx) |
| + csrf := sessionToken(t, fx, c) |
| + for _, form := range []url.Values{ |
| + {"csrf": {csrf}, "swap": {"fast"}, "swap_concurrency": {"1"}}, |
| + {"csrf": {csrf}, "swap": {"hold"}, "swap_concurrency": {"0"}}, |
| + {"csrf": {csrf}, "swap": {"hold"}, "swap_concurrency": {"two"}}, |
| + {"csrf": {csrf}, "swap": {"hold"}}, |
| + } { |
| + if code, _, _ := postForm(t, c, fx.u("/apps/kass/swap"), form); code != http.StatusSeeOther { |
| + t.Errorf("%v = %d, want a redirect with a notice", form, code) |
| + } |
| + } |
| + if got, _ := appconfig.GetSwapPolicy(context.Background(), fx.rel, fx.sqid, "kass"); got != (appconfig.SwapPolicy{Swap: "overlap", Concurrency: 1}) { |
| + t.Fatalf("a refused form stored %+v", got) |
| + } |
| +} |
| + |
| +func TestAppSwapFormNeedsCSRFAndMembership(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + c := signInPaul(t, fx) |
| + if code, _, _ := postForm(t, c, fx.u("/apps/kass/swap"), url.Values{"csrf": {"wrong"}, "swap": {"hold"}, "swap_concurrency": {"1"}}); code != http.StatusForbidden { |
| + t.Fatalf("bad csrf = %d", code) |
| + } |
| + csrf := sessionToken(t, fx, c) |
| + code, _, body := postForm(t, c, fx.u("/apps/no-such-app/swap"), url.Values{"csrf": {csrf}, "swap": {"hold"}, "swap_concurrency": {"1"}}) |
| + if code != http.StatusNotFound || body != "404 page not found\n" { |
| + t.Fatalf("unknown app = %d %q", code, body) |
| + } |
| +} |
diff --git a/internal/console/claim_invariant_fixtures_test.go b/internal/console/claim_invariant_fixtures_test.go| index 90e4f9b80..7b413dd46 100644 |
| --- a/internal/console/claim_invariant_fixtures_test.go |
| +++ b/internal/console/claim_invariant_fixtures_test.go |
| @@ -967,6 +967,10 @@ var claimFixtures = map[string]claimFixture{ |
| }, |
| do: claimForm("/a/bab/apps/titogo/store/destroy", "confirm", "<bucket>"), |
| }}, |
| + "route POST /a/{sqid}/apps/{app}/swap": {door: &claimDoor{ |
| + apps: claimRefs("bab/kass"), |
| + do: claimForm("/a/bab/apps/kass/swap", "swap", "hold", "swap_concurrency", "2"), |
| + }}, |
| // ---- POST: unauthenticated, signed ---- |
| // SES delivery events count against the sending app (mail_sender_counts). |
diff --git a/internal/console/server.go b/internal/console/server.go| index a0827041b..d9221584b 100644 |
| --- a/internal/console/server.go |
| +++ b/internal/console/server.go |
| @@ -1392,6 +1392,8 @@ func NewServer(cfg Config, st *Store) (*Server, error) { |
| // The password-gate form (appsettings.go's handleAppGate): member-gated, |
| // unlike the owner-only writes above it. |
| s.registerRoute("POST /a/{sqid}/apps/{app}/gate", s.handleAppGate) |
| + // The Deploys card's form (appswap.go): member-gated, like the gate. |
| + s.registerRoute("POST /a/{sqid}/apps/{app}/swap", s.handleAppSwap) |
| s.registerRoute("POST /a/{sqid}/apps/{app}/rename", s.handleAppRename) |
| s.registerRoute("POST /a/{sqid}/apps/{app}/keep", s.handleAppKeep) |
| s.registerRoute("POST /a/{sqid}/apps/{app}/delete", s.handleAppDelete) |
| @@ -1861,6 +1863,12 @@ type page struct { |
| AnalyticsSetting telemetry.Resolved |
| AnalyticsUnread bool |
| + // SwapPolicy is the app's swap policy for the settings page's Deploys |
| + // card (zero-downtime spec §4.1, §9.2). SwapUnread says the read failed, |
| + // and the card then offers no form, for ErrLevelUnread's reason. |
| + SwapPolicy appconfig.SwapPolicy |
| + SwapUnread bool |
| + |
| // App Analytics (analyticsview.go's tab, apianalytics.go's sibling): the |
| // assembled window plus the range the form re-renders with. A zero |
| // AnalyticsWin with AnalyticsErr "" is the normal nothing-counted-yet |
| @@ -3652,6 +3660,7 @@ func (s *Server) populateAppSettings(r *http.Request, p *page, acct *Account) { |
| // The analytics switch is the OTHER half of that same resolved object — |
| // one settings file, one read — under the name its own section uses. |
| p.AnalyticsSetting, p.AnalyticsUnread = p.ErrLevelSetting, p.ErrLevelUnread |
| + s.loadSwapPolicy(r.Context(), p, acct) |
| } |
| func (s *Server) renderAppSettingsForm(w http.ResponseWriter, r *http.Request, status int, section, msg string, drafts map[string]string) { |
diff --git a/internal/console/swapcopy.go b/internal/console/swapcopy.go| new file mode 100644 |
| index 000000000..705cf3959 |
| --- /dev/null |
| +++ b/internal/console/swapcopy.go |
| @@ -0,0 +1,97 @@ |
| +package console |
| + |
| +// swapCopyStrings is the console's copy for the zero-downtime surfaces (spec |
| +// §9.2): the Deploys card on the app's settings page, and the installed and |
| +// serving facts, failure hold and Retry action on an instance page. Every |
| +// string is a draft until the copy review approves it (see the markers). |
| +type swapCopyStrings struct { |
| + Heading string |
| + Intro string |
| + SwapLabel string |
| + OverlapOption string |
| + HoldOption string |
| + ConcurrencyLabel string |
| + MigrationsNote string |
| + Saved string |
| + Invalid string |
| + StoreFailed string |
| + RateLimited string |
| + Unread string |
| + NoBucket string |
| + Installed string |
| + Serving string |
| + Hold string |
| + HoldText string |
| + PromoteRetry string |
| + RetryButton string |
| + Rolling string |
| + Asleep string |
| + RetryTitle string |
| + RetryLine string |
| + RetryDone string |
| + RetryFailed string |
| + RetryLimited string |
| + NotHeld string |
| + Audit string |
| +} |
| + |
| +var swapCopy = swapCopyStrings{ |
| + // TODO-COPY zdd.settings.heading |
| + Heading: "Deploys", |
| + // TODO-COPY zdd.settings.intro |
| + Intro: "When a new build reaches a running instance, overlap starts it beside the old one and switches once it answers its health check, so no request waits. Hold stops the old one first, and requests wait until the new one is up.", |
| + // TODO-COPY zdd.settings.swap-label |
| + SwapLabel: "Swap mode:", |
| + // TODO-COPY zdd.settings.overlap-option |
| + OverlapOption: "overlap (default)", |
| + // TODO-COPY zdd.settings.hold-option |
| + HoldOption: "hold", |
| + // TODO-COPY zdd.settings.concurrency-label |
| + ConcurrencyLabel: "Instances that swap at once on each box:", |
| + // TODO-COPY zdd.settings.migrations-note |
| + MigrationsNote: "With overlap, the old and new builds share the instance's database for a few seconds. Keep migrations backward-compatible: add in one release and remove in a later one. Use hold when a release cannot do that.", |
| + // TODO-COPY zdd.settings.saved |
| + Saved: "Swap policy saved: %s, %d at a time on each box.", |
| + // TODO-COPY zdd.settings.invalid |
| + Invalid: "Choose overlap or hold, and a whole number of 1 or more.", |
| + // TODO-COPY zdd.settings.store-failed |
| + StoreFailed: "Could not save the swap policy. Try again.", |
| + // TODO-COPY zdd.settings.rate-limited |
| + RateLimited: "Too many changes. Try again in a minute.", |
| + // TODO-COPY zdd.settings.unread |
| + Unread: "This app's swap policy could not be read just now, so it cannot be changed here. Reload before concluding anything.", |
| + // TODO-COPY zdd.settings.no-bucket |
| + NoBucket: "No deployment bucket on this host, so the swap policy cannot be changed here.", |
| + // TODO-COPY zdd.instance.installed |
| + Installed: "Installed build", |
| + // TODO-COPY zdd.instance.serving |
| + Serving: "Serving build", |
| + // TODO-COPY zdd.instance.hold |
| + Hold: "Failure hold", |
| + // TODO-COPY zdd.instance.hold-text |
| + HoldText: "Build %s failed on this box because %s. This instance stays on %s until you retry it or ship a newer build.", |
| + // TODO-COPY zdd.instance.promote-retry |
| + PromoteRetry: "To try it again on every box: carlos promote --app %s --retry %s %s", |
| + // TODO-COPY zdd.instance.retry-button |
| + RetryButton: "Retry", |
| + // TODO-COPY zdd.instance.rolling |
| + Rolling: "Build %s is installed and rolling out. Build %s serves until the new one passes its health check.", |
| + // TODO-COPY zdd.instance.asleep |
| + Asleep: "Build %s is installed and starts on the next request.", |
| + // TODO-COPY zdd.instance.retry-title |
| + RetryTitle: "Retry build {build} on {host}?", |
| + // TODO-COPY zdd.instance.retry-line |
| + RetryLine: "This clears the failure hold on {host}. Its next attempt at {build} runs as a canary. If that fails too, {host} stays on its last good build.", |
| + // TODO-COPY zdd.instance.retry-done |
| + RetryDone: "Retry requested for {host}.", |
| + // TODO-COPY zdd.instance.retry-failed |
| + RetryFailed: "The retry request failed. Try again.", |
| + // TODO-COPY zdd.instance.retry-limited |
| + RetryLimited: "Too many retry requests. Try again later.", |
| + // TODO-COPY zdd.instance.not-held |
| + NotHeld: "{host} is no longer held back by a failed build, so there is nothing to retry.", |
| + // TODO-COPY zdd.instance.audit |
| + Audit: "Retry of build {build} requested for {host}.", |
| +} |
| + |
| +func (p page) SwapCopy() swapCopyStrings { return swapCopy } |
diff --git a/internal/console/templates/app_settings.html b/internal/console/templates/app_settings.html| index 2c223d7a3..958e3ca07 100644 |
| --- a/internal/console/templates/app_settings.html |
| +++ b/internal/console/templates/app_settings.html |
| @@ -361,6 +361,27 @@ JavaScript, no cookie, no stored IP. A visitor is a salted hash that changes eve |
| joined across days. Whether that needs consent where your visitors are is your call, which is why it |
| is off until you turn it on.</p> |
| +<h2>{{/* TODO-COPY zdd.settings.heading */}}{{.SwapCopy.Heading}}</h2> |
| +{{if .SwapUnread}} |
| +<p class="small">{{/* TODO-COPY zdd.settings.unread */}}{{.SwapCopy.Unread}}</p> |
| +{{else}} |
| +<p class="small">{{/* TODO-COPY zdd.settings.intro */}}{{.SwapCopy.Intro}}</p> |
| +<form method="post" action="{{.Base}}/apps/{{.Detail.App}}/swap" class="settings-row"> |
| + <input type="hidden" name="csrf" value="{{.CSRF}}"> |
| + <label>{{/* TODO-COPY zdd.settings.swap-label */}}{{.SwapCopy.SwapLabel}} |
| + <select name="swap"> |
| + <option value="overlap"{{if eq .SwapPolicy.Swap "overlap"}} selected{{end}}>{{/* TODO-COPY zdd.settings.overlap-option */}}{{.SwapCopy.OverlapOption}}</option> |
| + <option value="hold"{{if eq .SwapPolicy.Swap "hold"}} selected{{end}}>{{/* TODO-COPY zdd.settings.hold-option */}}{{.SwapCopy.HoldOption}}</option> |
| + </select> |
| + </label> |
| + <label>{{/* TODO-COPY zdd.settings.concurrency-label */}}{{.SwapCopy.ConcurrencyLabel}} |
| + <input type="number" name="swap_concurrency" min="1" step="1" value="{{.SwapPolicy.Concurrency}}" required> |
| + </label> |
| + <button type="submit" class="btn-sm">Save</button> |
| +</form> |
| +<p class="small">{{/* TODO-COPY zdd.settings.migrations-note */}}{{.SwapCopy.MigrationsNote}}</p> |
| +{{end}} |
| + |
| {{end}} |
| {{if eq .SettingsSection "features"}} |
| {{if .Settings.NoBucket}}<p class="banner error">no deployment bucket on this host — this console cannot read features</p>{{end}} |
diff --git a/internal/console/testdata/claim-routes.txt b/internal/console/testdata/claim-routes.txt| index dde451d13..01bf8648d 100644 |
| --- a/internal/console/testdata/claim-routes.txt |
| +++ b/internal/console/testdata/claim-routes.txt |
| @@ -132,6 +132,7 @@ route POST /a/{sqid}/apps/{app}/rename |
| route POST /a/{sqid}/apps/{app}/restart |
| route POST /a/{sqid}/apps/{app}/rollback |
| route POST /a/{sqid}/apps/{app}/store/destroy |
| +route POST /a/{sqid}/apps/{app}/swap |
| route POST /a/{sqid}/delete |
| route POST /a/{sqid}/errors/level |
| route POST /a/{sqid}/fleets/{fleet}/detach |
diff --git a/internal/provision/plan9_b8_census_test.go b/internal/provision/plan9_b8_census_test.go| index d49cda6c8..031e355ed 100644 |
| --- a/internal/provision/plan9_b8_census_test.go |
| +++ b/internal/provision/plan9_b8_census_test.go |
| @@ -596,7 +596,7 @@ var plan9B8CensusInventory = []censusEntry{ |
| {"internal/console/relayops.go", "Server.relayAcquire", "nonrecord", "sel:Put", "3c757e1c92f1c0ce41c3b5f00aa1397ce5a0c4faabc2f176b23355cf5a41f5fb"}, |
| {"internal/console/relayops.go", "Server.relayPublishOnce", "nonrecord", "sel:Put", "783a3c20337914e08f92be783f6b48ab14288437a8043c0d5bf29f281a7a9b1d"}, |
| {"internal/console/relayops.go", "Server.relayRotate", "nonrecord", "sel:Put", "58e0090a4990e2d5e793cec2ec7bcbfb3626b4043307f64484a55766099e1434"}, |
| - {"internal/console/server.go", "NewServer", "key-site", "lit:instances", "4033fcfce048867456db39d4c293afe18b4f13e94ba756466ad786d633c8cf10"}, |
| + {"internal/console/server.go", "NewServer", "key-site", "lit:instances", "78de5c1959d047aa5025fc51a7e37a568d29257384dc022a0ea020448fa6dd9e"}, |
| {"internal/console/server.go", "Server.PublishMovesConsole", "nonrecord", "sel:PutReplace", "b228397d413769f93209645db9ac6bd4a47cc9db31f06c8699cb540cffea9b5a"}, |
| {"internal/console/server.go", "Server.handleAppInstances", "key-site", "lit:instances", "7368e00fa1bf20365b61361815db216441b7fd21b3d330349dc09170045d45b7"}, |
| {"internal/console/server.go", "Server.handleAppActivity", "key-site", "lit:instances", "265425be83a63bf2fbcd88e1b75600933cf1a2e8b7fa9cc90046fdd329db9786"}, |