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" "github.com/carlosframework/platform/internal/updatewords" ) const appsSetUsage = `usage: carlos apps set [--swap overlap|hold] [--swap-concurrency ] [--account ] [--account-id ] [--console ] --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 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 ( appsSetFlagApp = "app whose swap policy to set" 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 " 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" appsSetError = "apps set: %v" appsSetUnreadable = "apps set: unreadable response: %w" 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 --swap hold` and `carlos instances retry `. 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, appsSetFlagApp) 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") control := fs.String("instance-control", "", updatewords.Text("settings.cli.apps-set-flag-control")) defUpdates := fs.String("default-updates", "", updatewords.Text("settings.cli.apps-set-flag-updates")) defChannel := fs.String("default-channel", "", updatewords.Text("settings.cli.apps-set-flag-channel")) defMajor := fs.String("default-major", "", updatewords.Text("settings.cli.apps-set-flag-major")) consoleFlag := consoleOption(fs, "console to act through (default: the only logged-in one)") fs.Usage = flagUsage(fs, appsSetUsage+"\n\n"+updatewords.Text("settings.cli.apps-set-usage")) fs.Parse(args) app, err := appsSetApp(lead, *appF, fs.Args()) if err != nil { log.Fatal(err) } if err := validAppName(app); err != nil { log.Fatalf(appsSetError, err) } var swapReq *swapPolicyReq if flagWasSet(fs, "swap") || flagWasSet(fs, "swap-concurrency") { r, err := appsSetRequest(*swap, *concurrency, flagWasSet(fs, "swap"), flagWasSet(fs, "swap-concurrency")) if err != nil { log.Fatal(err) } swapReq = &r } policy, err := updatePolicyRequest(fs, *control, *defUpdates, *defChannel, *defMajor) if err != nil { log.Fatal(err) } if swapReq == nil && policy == nil { log.Fatal(appsSetNothing) } sess, err := resolveMode(*consoleFlag) if err != nil { log.Fatal(err) } if sess == nil { log.Fatal(appsSetDirectMode) } acct := resolvedAccount(sess, acctRaw, acctIDRaw) if err := appsSetRun(context.Background(), sess, acct, app, swapReq, policy); err != nil { log.Fatal(err) } } // 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(appsSetUnreadable, err) } return out, nil }