carlos / platform Public

Clone
git clone https://amadan.net/carlos/platform

Plain git — no account needed to clone.

Download

Download this file

1package 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
7import (
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
20const 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
32Any member of the app may change these. They reach the app's boxes with its
33config, within seconds.`
34
35const (
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.
54type 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.
60type 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>`.
68func 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.
77func 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.
95func 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).
119func 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
126func 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.
179func (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