| 1 | package main |
| 2 | |
| 3 | // `carlos apps set`: the app's swap policy (zero-downtime spec §4.1, §9.1). |
| 4 | // Console-only, like build-probe: the door is person-only (apiswap.go), and |
| 5 | // there is no direct-bucket form. |
| 6 | |
| 7 | import ( |
| 8 | "context" |
| 9 | "encoding/json" |
| 10 | "errors" |
| 11 | "flag" |
| 12 | "fmt" |
| 13 | "log" |
| 14 | "net/http" |
| 15 | "strings" |
| 16 | |
| 17 | "github.com/carlosframework/platform/internal/updatewords" |
| 18 | ) |
| 19 | |
| 20 | const appsSetUsage = `usage: carlos apps set <app> [--swap overlap|hold] [--swap-concurrency <n>] [--account <sqid|name>] [--account-id <sqid>] [--console <origin>] |
| 21 | |
| 22 | --swap overlap|hold how a running instance takes a new build. overlap |
| 23 | starts the new build beside the old one and switches |
| 24 | once it answers its health check, so no request |
| 25 | waits. hold stops the old one first, and requests |
| 26 | wait until the new one is up. Use hold when two |
| 27 | builds cannot share the database. |
| 28 | --swap-concurrency <n> how many instances of the app may swap at once on |
| 29 | one box, after the first has stayed up for ten |
| 30 | seconds. 1 or more; it starts at 1. |
| 31 | |
| 32 | Any member of the app may change these. They reach the app's boxes with its |
| 33 | config, within seconds.` |
| 34 | |
| 35 | const ( |
| 36 | appsSetFlagApp = "app whose swap policy to set" |
| 37 | appsSetFlagSwap = "overlap or hold: how a running instance takes a new build" |
| 38 | appsSetFlagConcurrency = "how many instances of the app may swap at once on one box (1 or more)" |
| 39 | appsSetNothing = "apps set: nothing to set. Pass --swap, --swap-concurrency, or both" |
| 40 | appsSetBadSwap = "apps set: --swap is overlap or hold, not %q" |
| 41 | appsSetBadConcurrency = "apps set: --swap-concurrency must be 1 or more" |
| 42 | appsSetTwoApps = "apps set: name the app once, either first or with --app" |
| 43 | 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>" |
| 44 | appsSetDone = "%s swaps in %s mode, %d at a time on each box" |
| 45 | appsSetOverlapNote = " the old and new builds run side by side for a few seconds and share the database, so keep migrations backward-compatible" |
| 46 | appsSetHoldNote = " each instance stops before its new build starts, and requests wait until it is up" |
| 47 | appsSetMismatch = "apps set: the change did not stick (the console stored %s, %d at a time). Run it again" |
| 48 | appsSetError = "apps set: %v" |
| 49 | appsSetUnreadable = "apps set: unreadable response: %w" |
| 50 | 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)" |
| 51 | ) |
| 52 | |
| 53 | // swapPolicyReq is the door's body: an absent half is left as it is. |
| 54 | type swapPolicyReq struct { |
| 55 | Swap *string `json:"swap,omitempty"` |
| 56 | SwapConcurrency *int `json:"swap_concurrency,omitempty"` |
| 57 | } |
| 58 | |
| 59 | // apiSwapPolicy is the door's echo: what the console stored. |
| 60 | type apiSwapPolicy struct { |
| 61 | Swap string `json:"swap"` |
| 62 | Concurrency int `json:"swap_concurrency"` |
| 63 | } |
| 64 | |
| 65 | // leadingArg lifts a positional word written BEFORE the flags, which Go's |
| 66 | // flag package would otherwise read as the end of the flags: the spec writes |
| 67 | // `carlos apps set <app> --swap hold` and `carlos instances retry <host>`. |
| 68 | func leadingArg(args []string) (string, []string) { |
| 69 | if len(args) > 0 && !strings.HasPrefix(args[0], "-") { |
| 70 | return strings.TrimSpace(args[0]), args[1:] |
| 71 | } |
| 72 | return "", args |
| 73 | } |
| 74 | |
| 75 | // appsSetApp picks the one app named before the flags, with --app, or after |
| 76 | // them. Naming it twice is refused rather than guessed at. |
| 77 | func appsSetApp(lead, flagApp string, rest []string) (string, error) { |
| 78 | var names []string |
| 79 | for _, n := range append([]string{lead, flagApp}, rest...) { |
| 80 | if n = strings.TrimSpace(n); n != "" { |
| 81 | names = append(names, n) |
| 82 | } |
| 83 | } |
| 84 | switch len(names) { |
| 85 | case 0: |
| 86 | return "", errors.New(appsSetUsage) |
| 87 | case 1: |
| 88 | return names[0], nil |
| 89 | } |
| 90 | return "", errors.New(appsSetTwoApps) |
| 91 | } |
| 92 | |
| 93 | // appsSetRequest checks the flags client-side, so a value the door is |
| 94 | // certain to refuse costs no round trip. |
| 95 | func appsSetRequest(swap string, concurrency int, swapGiven, concurrencyGiven bool) (swapPolicyReq, error) { |
| 96 | var req swapPolicyReq |
| 97 | if !swapGiven && !concurrencyGiven { |
| 98 | return req, errors.New(appsSetNothing) |
| 99 | } |
| 100 | if swapGiven { |
| 101 | s := strings.TrimSpace(swap) |
| 102 | if s != "overlap" && s != "hold" { |
| 103 | return req, fmt.Errorf(appsSetBadSwap, swap) |
| 104 | } |
| 105 | req.Swap = &s |
| 106 | } |
| 107 | if concurrencyGiven { |
| 108 | if concurrency < 1 { |
| 109 | return req, errors.New(appsSetBadConcurrency) |
| 110 | } |
| 111 | c := concurrency |
| 112 | req.SwapConcurrency = &c |
| 113 | } |
| 114 | return req, nil |
| 115 | } |
| 116 | |
| 117 | // appsSetStuck compares the echo with what was asked: a write that half |
| 118 | // landed must be loud (build-probe's discipline). |
| 119 | func appsSetStuck(req swapPolicyReq, got apiSwapPolicy) error { |
| 120 | if (req.Swap != nil && *req.Swap != got.Swap) || (req.SwapConcurrency != nil && *req.SwapConcurrency != got.Concurrency) { |
| 121 | return fmt.Errorf(appsSetMismatch, got.Swap, got.Concurrency) |
| 122 | } |
| 123 | return nil |
| 124 | } |
| 125 | |
| 126 | func runAppsSet(args []string) { |
| 127 | lead, args := leadingArg(args) |
| 128 | fs := flag.NewFlagSet("apps set", flag.ExitOnError) |
| 129 | appF := appFlag(fs, appsSetFlagApp) |
| 130 | swap := fs.String("swap", "", appsSetFlagSwap) |
| 131 | concurrency := fs.Int("swap-concurrency", 0, appsSetFlagConcurrency) |
| 132 | acctRaw := fs.String("account", "", "account (sqid or name) the app belongs to (default: your only account, or the configured/chosen one)") |
| 133 | acctIDRaw := fs.String("account-id", "", "account sqid, matched as a sqid ONLY — supersedes --account") |
| 134 | control := fs.String("instance-control", "", updatewords.Text("settings.cli.apps-set-flag-control")) |
| 135 | defUpdates := fs.String("default-updates", "", updatewords.Text("settings.cli.apps-set-flag-updates")) |
| 136 | defChannel := fs.String("default-channel", "", updatewords.Text("settings.cli.apps-set-flag-channel")) |
| 137 | defMajor := fs.String("default-major", "", updatewords.Text("settings.cli.apps-set-flag-major")) |
| 138 | consoleFlag := consoleOption(fs, "console to act through (default: the only logged-in one)") |
| 139 | fs.Usage = flagUsage(fs, appsSetUsage+"\n\n"+updatewords.Text("settings.cli.apps-set-usage")) |
| 140 | fs.Parse(args) |
| 141 | app, err := appsSetApp(lead, *appF, fs.Args()) |
| 142 | if err != nil { |
| 143 | log.Fatal(err) |
| 144 | } |
| 145 | if err := validAppName(app); err != nil { |
| 146 | log.Fatalf(appsSetError, err) |
| 147 | } |
| 148 | var swapReq *swapPolicyReq |
| 149 | if flagWasSet(fs, "swap") || flagWasSet(fs, "swap-concurrency") { |
| 150 | r, err := appsSetRequest(*swap, *concurrency, flagWasSet(fs, "swap"), flagWasSet(fs, "swap-concurrency")) |
| 151 | if err != nil { |
| 152 | log.Fatal(err) |
| 153 | } |
| 154 | swapReq = &r |
| 155 | } |
| 156 | policy, err := updatePolicyRequest(fs, *control, *defUpdates, *defChannel, *defMajor) |
| 157 | if err != nil { |
| 158 | log.Fatal(err) |
| 159 | } |
| 160 | if swapReq == nil && policy == nil { |
| 161 | log.Fatal(appsSetNothing) |
| 162 | } |
| 163 | sess, err := resolveMode(*consoleFlag) |
| 164 | if err != nil { |
| 165 | log.Fatal(err) |
| 166 | } |
| 167 | if sess == nil { |
| 168 | log.Fatal(appsSetDirectMode) |
| 169 | } |
| 170 | acct := resolvedAccount(sess, acctRaw, acctIDRaw) |
| 171 | if err := appsSetRun(context.Background(), sess, acct, app, swapReq, policy); err != nil { |
| 172 | log.Fatal(err) |
| 173 | } |
| 174 | } |
| 175 | |
| 176 | // setSwapPolicy is `carlos apps set`'s request. A 404 with no JSON error is |
| 177 | // both "not your app" and "a console from before this door"; the message |
| 178 | // names both, build-probe's way. |
| 179 | func (s *apiSession) setSwapPolicy(ctx context.Context, a apiAcct, app string, req swapPolicyReq) (apiSwapPolicy, error) { |
| 180 | status, body, err := s.putJSON(ctx, apiPath(a, app, "/swap"), req) |
| 181 | if err != nil { |
| 182 | return apiSwapPolicy{}, err |
| 183 | } |
| 184 | if status != http.StatusOK { |
| 185 | if status == http.StatusNotFound && !hasJSONError(body) { |
| 186 | return apiSwapPolicy{}, fmt.Errorf(appsSetOldConsole, app) |
| 187 | } |
| 188 | return apiSwapPolicy{}, apiErrorFromBody(status, body) |
| 189 | } |
| 190 | var out apiSwapPolicy |
| 191 | if err := json.Unmarshal(body, &out); err != nil { |
| 192 | return apiSwapPolicy{}, fmt.Errorf(appsSetUnreadable, err) |
| 193 | } |
| 194 | return out, nil |
| 195 | } |
| 196 | |