carlos / platform Public

zdd-6 task 5: carlos apps set --swap --swap-concurrency

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev a45435832fde9a913a02360393d84da245251270 parent ea2d668
3 files changed, +303 −1
  • apps.go +6 −1
  • apps_swap.go +185 −0
  • apps_swap_test.go +112 −0
diff --git a/apps.go b/apps.go
index bdd530ac3..48246d3e6 100644
--- a/apps.go
+++ b/apps.go
@@ -34,7 +34,7 @@ import (
"github.com/carlosframework/platform/internal/release"
)
-const appsUsage = `usage: carlos apps <create|place|build-probe|delete|restore|ttl|keep|list> [flags] <app>
+const appsUsage = `usage: carlos apps <create|place|build-probe|set|delete|restore|ttl|keep|list> [flags] <app>
create --app <name> [flags] claim a new app name in an account, through
the console API — the same claim the console's New app
@@ -83,6 +83,9 @@ const appsUsage = `usage: carlos apps <create|place|build-probe|delete|restore|t
at claim time until now, which meant the fix was out of
reach of every app that already had the problem.
+ set <app> [--swap overlap|hold] [--swap-concurrency <n>] how the app's
+ running instances take a new build: see carlos apps set -h.
+
delete [flags] <app> move an app to the Trash: every route drops
(custom domains included), its name stays reserved, and it
is restorable for 30 days, or 3 for a temporary app. Refused
@@ -148,6 +151,8 @@ func RunApps(args []string) {
runAppsPlace(args[1:])
case "build-probe":
runAppsBuildProbe(args[1:])
+ case "set":
+ runAppsSet(args[1:])
case "delete":
runAppsDelete(args[1:])
case "restore":
diff --git a/apps_swap.go b/apps_swap.go
new file mode 100644
index 000000000..f000176b3
--- /dev/null
+++ b/apps_swap.go
@@ -0,0 +1,185 @@
+package main
+
+// `carlos apps set`: the app's swap policy (zero-downtime spec §4.1, §9.1).
+// Console-only, like build-probe: the door is person-only (apiswap.go), and
+// there is no direct-bucket form.
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "flag"
+ "fmt"
+ "log"
+ "net/http"
+ "strings"
+)
+
+const appsSetUsage = `usage: carlos apps set <app> [--swap overlap|hold] [--swap-concurrency <n>] [--account <sqid|name>] [--account-id <sqid>] [--console <origin>]
+
+ --swap overlap|hold how a running instance takes a new build. overlap
+ starts the new build 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. Use hold when two
+ builds cannot share the database.
+ --swap-concurrency <n> how many instances of the app may swap at once on
+ one box, after the first has stayed up for ten
+ seconds. 1 or more; it starts at 1.
+
+Any member of the app may change these. They reach the app's boxes with its
+config, within seconds.`
+
+const (
+ appsSetFlagSwap = "overlap or hold: how a running instance takes a new build"
+ appsSetFlagConcurrency = "how many instances of the app may swap at once on one box (1 or more)"
+ appsSetNothing = "apps set: nothing to set. Pass --swap, --swap-concurrency, or both"
+ appsSetBadSwap = "apps set: --swap is overlap or hold, not %q"
+ appsSetBadConcurrency = "apps set: --swap-concurrency must be 1 or more"
+ appsSetTwoApps = "apps set: name the app once, either first or with --app"
+ appsSetDirectMode = "apps set works through a console, and this shell is in direct bucket mode (CARLOS_DEPLOYMENT_BUCKET or CARLOS_DEPLOYMENT_DIR is set). Log in instead: carlos auth login --console <url>"
+ appsSetDone = "%s swaps in %s mode, %d at a time on each box"
+ appsSetOverlapNote = " the old and new builds run side by side for a few seconds and share the database, so keep migrations backward-compatible"
+ appsSetHoldNote = " each instance stops before its new build starts, and requests wait until it is up"
+ appsSetMismatch = "apps set: the change did not stick (the console stored %s, %d at a time). Run it again"
+ appsSetOldConsole = "not found: either %s is not an app in this account, or this console predates carlos apps set (deploy a console built from current main)"
+)
+
+// swapPolicyReq is the door's body: an absent half is left as it is.
+type swapPolicyReq struct {
+ Swap *string `json:"swap,omitempty"`
+ SwapConcurrency *int `json:"swap_concurrency,omitempty"`
+}
+
+// apiSwapPolicy is the door's echo: what the console stored.
+type apiSwapPolicy struct {
+ Swap string `json:"swap"`
+ Concurrency int `json:"swap_concurrency"`
+}
+
+// leadingArg lifts a positional word written BEFORE the flags, which Go's
+// flag package would otherwise read as the end of the flags: the spec writes
+// `carlos apps set <app> --swap hold` and `carlos instances retry <host>`.
+func leadingArg(args []string) (string, []string) {
+ if len(args) > 0 && !strings.HasPrefix(args[0], "-") {
+ return strings.TrimSpace(args[0]), args[1:]
+ }
+ return "", args
+}
+
+// appsSetApp picks the one app named before the flags, with --app, or after
+// them. Naming it twice is refused rather than guessed at.
+func appsSetApp(lead, flagApp string, rest []string) (string, error) {
+ var names []string
+ for _, n := range append([]string{lead, flagApp}, rest...) {
+ if n = strings.TrimSpace(n); n != "" {
+ names = append(names, n)
+ }
+ }
+ switch len(names) {
+ case 0:
+ return "", errors.New(appsSetUsage)
+ case 1:
+ return names[0], nil
+ }
+ return "", errors.New(appsSetTwoApps)
+}
+
+// appsSetRequest checks the flags client-side, so a value the door is
+// certain to refuse costs no round trip.
+func appsSetRequest(swap string, concurrency int, swapGiven, concurrencyGiven bool) (swapPolicyReq, error) {
+ var req swapPolicyReq
+ if !swapGiven && !concurrencyGiven {
+ return req, errors.New(appsSetNothing)
+ }
+ if swapGiven {
+ s := strings.TrimSpace(swap)
+ if s != "overlap" && s != "hold" {
+ return req, fmt.Errorf(appsSetBadSwap, swap)
+ }
+ req.Swap = &s
+ }
+ if concurrencyGiven {
+ if concurrency < 1 {
+ return req, errors.New(appsSetBadConcurrency)
+ }
+ c := concurrency
+ req.SwapConcurrency = &c
+ }
+ return req, nil
+}
+
+// appsSetStuck compares the echo with what was asked: a write that half
+// landed must be loud (build-probe's discipline).
+func appsSetStuck(req swapPolicyReq, got apiSwapPolicy) error {
+ if (req.Swap != nil && *req.Swap != got.Swap) || (req.SwapConcurrency != nil && *req.SwapConcurrency != got.Concurrency) {
+ return fmt.Errorf(appsSetMismatch, got.Swap, got.Concurrency)
+ }
+ return nil
+}
+
+func runAppsSet(args []string) {
+ lead, args := leadingArg(args)
+ fs := flag.NewFlagSet("apps set", flag.ExitOnError)
+ appF := appFlag(fs, "app whose swap policy to set")
+ swap := fs.String("swap", "", appsSetFlagSwap)
+ concurrency := fs.Int("swap-concurrency", 0, appsSetFlagConcurrency)
+ acctRaw := fs.String("account", "", "account (sqid or name) the app belongs to (default: your only account, or the configured/chosen one)")
+ acctIDRaw := fs.String("account-id", "", "account sqid, matched as a sqid ONLY — supersedes --account")
+ consoleFlag := consoleOption(fs, "console to act through (default: the only logged-in one)")
+ fs.Usage = flagUsage(fs, appsSetUsage)
+ fs.Parse(args)
+ app, err := appsSetApp(lead, *appF, fs.Args())
+ if err != nil {
+ log.Fatal(err)
+ }
+ if err := validAppName(app); err != nil {
+ log.Fatalf("apps set: %v", err)
+ }
+ req, err := appsSetRequest(*swap, *concurrency, flagWasSet(fs, "swap"), flagWasSet(fs, "swap-concurrency"))
+ if err != nil {
+ log.Fatal(err)
+ }
+ sess, err := resolveMode(*consoleFlag)
+ if err != nil {
+ log.Fatal(err)
+ }
+ if sess == nil {
+ log.Fatal(appsSetDirectMode)
+ }
+ acct := resolvedAccount(sess, acctRaw, acctIDRaw)
+ got, err := sess.setSwapPolicy(context.Background(), acct, app, req)
+ if err != nil {
+ log.Fatalf("apps set: %v", err)
+ }
+ if err := appsSetStuck(req, got); err != nil {
+ log.Fatal(err)
+ }
+ fmt.Fprintf(stdout, appsSetDone+"\n", app, got.Swap, got.Concurrency)
+ if got.Swap == "hold" {
+ mutedf(stdout, appsSetHoldNote)
+ } else {
+ mutedf(stdout, appsSetOverlapNote)
+ }
+}
+
+// setSwapPolicy is `carlos apps set`'s request. A 404 with no JSON error is
+// both "not your app" and "a console from before this door"; the message
+// names both, build-probe's way.
+func (s *apiSession) setSwapPolicy(ctx context.Context, a apiAcct, app string, req swapPolicyReq) (apiSwapPolicy, error) {
+ status, body, err := s.putJSON(ctx, apiPath(a, app, "/swap"), req)
+ if err != nil {
+ return apiSwapPolicy{}, err
+ }
+ if status != http.StatusOK {
+ if status == http.StatusNotFound && !hasJSONError(body) {
+ return apiSwapPolicy{}, fmt.Errorf(appsSetOldConsole, app)
+ }
+ return apiSwapPolicy{}, apiErrorFromBody(status, body)
+ }
+ var out apiSwapPolicy
+ if err := json.Unmarshal(body, &out); err != nil {
+ return apiSwapPolicy{}, fmt.Errorf("apps set: unreadable response: %w", err)
+ }
+ return out, nil
+}
diff --git a/apps_swap_test.go b/apps_swap_test.go
new file mode 100644
index 000000000..ac0afcbc1
--- /dev/null
+++ b/apps_swap_test.go
@@ -0,0 +1,112 @@
+package main
+
+import (
+ "context"
+ "fmt"
+ "io"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+)
+
+// Review Focus 5: the spec's order, flags first with the app after, and
+// --app all name one app; two names do not.
+func TestAppsSetApp(t *testing.T) {
+ for _, tc := range []struct {
+ lead, flagApp string
+ rest []string
+ want, err string
+ }{
+ {lead: "shop", want: "shop"},
+ {flagApp: "shop", want: "shop"},
+ {rest: []string{"shop"}, want: "shop"},
+ {lead: "shop", flagApp: "other", err: appsSetTwoApps},
+ {lead: "shop", rest: []string{"other"}, err: appsSetTwoApps},
+ {flagApp: "shop", rest: []string{"other"}, err: appsSetTwoApps},
+ {err: appsSetUsage},
+ } {
+ got, err := appsSetApp(tc.lead, tc.flagApp, tc.rest)
+ if tc.err != "" {
+ if err == nil || err.Error() != tc.err {
+ t.Errorf("appsSetApp(%q, %q, %q) = %q, %v; want error %q", tc.lead, tc.flagApp, tc.rest, got, err, tc.err)
+ }
+ continue
+ }
+ if err != nil || got != tc.want {
+ t.Errorf("appsSetApp(%q, %q, %q) = %q, %v; want %q", tc.lead, tc.flagApp, tc.rest, got, err, tc.want)
+ }
+ }
+}
+
+func TestLeadingArg(t *testing.T) {
+ if w, rest := leadingArg([]string{"shop", "--swap", "hold"}); w != "shop" || len(rest) != 2 {
+ t.Errorf("leadingArg = %q, %q", w, rest)
+ }
+ if w, rest := leadingArg([]string{"--swap", "hold", "shop"}); w != "" || len(rest) != 3 {
+ t.Errorf("a flag first leaves everything to the flag parser: %q, %q", w, rest)
+ }
+ if w, rest := leadingArg(nil); w != "" || rest != nil {
+ t.Errorf("leadingArg(nil) = %q, %q", w, rest)
+ }
+}
+
+func TestAppsSetRequest(t *testing.T) {
+ if _, err := appsSetRequest("", 0, false, false); err == nil || err.Error() != appsSetNothing {
+ t.Errorf("no flags: %v", err)
+ }
+ if _, err := appsSetRequest("fast", 0, true, false); err == nil || err.Error() != fmt.Sprintf(appsSetBadSwap, "fast") {
+ t.Errorf("--swap fast: %v", err)
+ }
+ if _, err := appsSetRequest("", 0, false, true); err == nil || err.Error() != appsSetBadConcurrency {
+ t.Errorf("--swap-concurrency 0: %v", err)
+ }
+ req, err := appsSetRequest(" hold ", 3, true, true)
+ if err != nil || req.Swap == nil || *req.Swap != "hold" || req.SwapConcurrency == nil || *req.SwapConcurrency != 3 {
+ t.Errorf("hold/3: %+v, %v", req, err)
+ }
+ if req, err = appsSetRequest("", 2, false, true); err != nil || req.Swap != nil || *req.SwapConcurrency != 2 {
+ t.Errorf("concurrency only: %+v, %v", req, err)
+ }
+}
+
+func TestAppsSetStuck(t *testing.T) {
+ hold, two := "hold", 2
+ if err := appsSetStuck(swapPolicyReq{Swap: &hold, SwapConcurrency: &two}, apiSwapPolicy{Swap: "hold", Concurrency: 2}); err != nil {
+ t.Errorf("a matching echo: %v", err)
+ }
+ if err := appsSetStuck(swapPolicyReq{Swap: &hold}, apiSwapPolicy{Swap: "overlap", Concurrency: 1}); err == nil {
+ t.Error("an echo of overlap for a hold request passed")
+ }
+ if err := appsSetStuck(swapPolicyReq{SwapConcurrency: &two}, apiSwapPolicy{Swap: "overlap", Concurrency: 2}); err != nil {
+ t.Errorf("the unnamed half may be anything: %v", err)
+ }
+}
+
+func TestAPISessionSetSwapPolicyWireShape(t *testing.T) {
+ var method, path, body string
+ ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ b, _ := io.ReadAll(r.Body)
+ method, path, body = r.Method, r.URL.Path, string(b)
+ w.Header().Set("Content-Type", "application/json")
+ io.WriteString(w, `{"swap":"hold","swap_concurrency":1}`)
+ }))
+ defer ts.Close()
+ hold := "hold"
+ got, err := newTestSession(ts.URL).setSwapPolicy(context.Background(), apiAcct{Seg: "bab"}, "shop", swapPolicyReq{Swap: &hold})
+ if err != nil || got != (apiSwapPolicy{Swap: "hold", Concurrency: 1}) {
+ t.Fatalf("setSwapPolicy = %+v, %v", got, err)
+ }
+ if method != http.MethodPut || path != "/api/cli/apps/bab/shop/swap" || body != `{"swap":"hold"}` {
+ t.Fatalf("sent %s %s %s", method, path, body)
+ }
+}
+
+func TestAPISessionSetSwapPolicyOldConsole(t *testing.T) {
+ ts := httptest.NewServer(http.NotFoundHandler())
+ defer ts.Close()
+ hold := "hold"
+ _, err := newTestSession(ts.URL).setSwapPolicy(context.Background(), apiAcct{Seg: "bab"}, "shop", swapPolicyReq{Swap: &hold})
+ if err == nil || err.Error() != fmt.Sprintf(appsSetOldConsole, "shop") {
+ t.Fatalf("err = %v", err)
+ }
+}