| 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 |
| +} |