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 instances set` and `carlos instances update` (instance update
4// settings spec §5): thin fronts on the console's per-instance update doors.
5// set sends the settings to PUT …/instances/{host}/updates and a channel to
6// the existing channel door; update raises "Update now" on …/updates/install.
7
8import (
9 "context"
10 "encoding/json"
11 "errors"
12 "flag"
13 "fmt"
14 "log"
15 "net/http"
16 "net/url"
17 "strconv"
18 "strings"
19
20 "github.com/carlosframework/platform/internal/updatewords"
21)
22
23// apiUpdatesSettings is one set of the four settings as the console writes
24// them. Major is a pointer because null is "no hold".
25type apiUpdatesSettings struct {
26 Updates string `json:"updates"`
27 Channel string `json:"channel"`
28 Major *int `json:"major"`
29 Pin string `json:"pin"`
30}
31
32// apiUpdatesView is what both settings doors answer with. Settings are the
33// effective values (the app's policy filling any gap), Explicit the
34// record's own, and Enforced whether the fleet applies them.
35type apiUpdatesView struct {
36 Host string `json:"host"`
37 Settings apiUpdatesSettings `json:"settings"`
38 Explicit apiUpdatesSettings `json:"explicit"`
39 Enforced bool `json:"enforced"`
40}
41
42// sameSettings compares by value: Major is a pointer, so == would compare
43// addresses.
44func sameSettings(a, b apiUpdatesSettings) bool {
45 am, bm := -1, -1
46 if a.Major != nil {
47 am = *a.Major
48 }
49 if b.Major != nil {
50 bm = *b.Major
51 }
52 return a.Updates == b.Updates && a.Channel == b.Channel && a.Pin == b.Pin && am == bm
53}
54
55// instancesSetRequest builds the settings door's body from the flags given: a
56// flag not given is not sent, and an empty value clears. A bad --major costs
57// no round trip. --channel is not part of it: the settings door refuses a
58// channel, and the channel door applies the pipeline's rules.
59func instancesSetRequest(fs *flag.FlagSet, updates, major, pin string) (map[string]any, error) {
60 req := map[string]any{}
61 if flagWasSet(fs, "updates") {
62 req["updates"] = strings.TrimSpace(updates)
63 }
64 if flagWasSet(fs, "major") {
65 if m := strings.TrimSpace(major); m == "" {
66 req["major"] = nil
67 } else {
68 n, err := strconv.Atoi(m)
69 if err != nil || n < 0 {
70 return nil, errors.New(updatewords.Text("settings.cli.bad-major-flag"))
71 }
72 req["major"] = n
73 }
74 }
75 if flagWasSet(fs, "pin") {
76 req["pin"] = strings.TrimSpace(pin)
77 }
78 return req, nil
79}
80
81// instancesDirectMode resolves the signed-in session for the two update
82// verbs, refusing direct bucket mode in the words of settings.cli.direct-mode.
83func instancesDirectMode(verb, consoleFlag string) *apiSession {
84 sess, err := resolveMode(consoleFlag)
85 if err != nil {
86 log.Fatal(err)
87 }
88 if sess == nil {
89 log.Fatalf(updatewords.Text("settings.cli.direct-mode"), verb)
90 }
91 return sess
92}
93
94// runInstancesSet is `carlos instances set <host> [--updates …] [--channel …]
95// [--major …] [--pin …]`. The host may come first or after the flags.
96func runInstancesSet(args []string) {
97 host, args := leadingArg(args)
98 fs := flag.NewFlagSet("instances set", flag.ExitOnError)
99 app := appFlag(fs, "app the instance belongs to")
100 updates := fs.String("updates", "", updatewords.Text("settings.cli.set-flag-updates"))
101 channel := channelFlag(fs, updatewords.Text("settings.cli.set-flag-channel"))
102 major := fs.String("major", "", updatewords.Text("settings.cli.set-flag-major"))
103 pin := fs.String("pin", "", updatewords.Text("settings.cli.set-flag-pin"))
104 consoleFlag := consoleOption(fs, "console to act through (default: the only logged-in one)")
105 acctRaw := fs.String("account", "", "account (sqid or name) the app belongs to (default: your only account, or the configured/chosen one)")
106 acctIDRaw := fs.String("account-id", "", updatewords.Text("settings.cli.flag-account-id"))
107 usage := updatewords.Text("settings.cli.set-usage")
108 fs.Usage = flagUsage(fs, usage)
109 fs.Parse(args)
110 switch {
111 case host == "" && fs.NArg() == 1:
112 host = strings.TrimSpace(fs.Arg(0))
113 case fs.NArg() != 0:
114 log.Fatal(usage)
115 }
116 if host == "" {
117 log.Fatal(usage)
118 }
119 // Records and routes are keyed lowercase (#264).
120 host = strings.ToLower(host)
121 req, err := instancesSetRequest(fs, *updates, *major, *pin)
122 if err != nil {
123 log.Fatal(err)
124 }
125 var channelTo *string
126 if flagWasSet(fs, "channel") {
127 // A channel cannot be cleared: an empty one is not a move.
128 c := strings.TrimSpace(*channel)
129 if c == "" {
130 log.Fatal(usage)
131 }
132 channelTo = &c
133 }
134 if len(req) == 0 && channelTo == nil {
135 log.Fatal(usage)
136 }
137 sess := instancesDirectMode("set", *consoleFlag)
138 printUsing(stdout, sess)
139 acct := resolvedAccount(sess, acctRaw, acctIDRaw)
140 app2 := resolvedApp(sess, acct, *app, usage)
141 if err := instancesSetRun(context.Background(), sess, acct, app2, host, req, channelTo); err != nil {
142 log.Fatal(err)
143 }
144}
145
146// instancesSetRun is `carlos instances set` once its flags are checked and
147// its session resolved: the settings door first, when settings were given,
148// then the channel door. The first refusal stops it, so a channel is never
149// tried after refused settings. A gated channel move is not a refusal: it
150// leaves the settings applied and the run successful.
151func instancesSetRun(ctx context.Context, sess *apiSession, acct apiAcct, app, host string, req map[string]any, channel *string) error {
152 if len(req) > 0 {
153 before, err := sess.getUpdates(ctx, acct, app, host)
154 if err != nil {
155 return fmt.Errorf("instances set: %v", err)
156 }
157 view, err := sess.putUpdates(ctx, acct, app, host, req)
158 if err != nil {
159 return fmt.Errorf("instances set: %v", err)
160 }
161 if sameSettings(view.Explicit, before.Explicit) {
162 fmt.Fprintf(stdout, updatewords.Text("settings.cli.set-unchanged")+"\n", host)
163 } else {
164 printUpdatesSettings(host, view.Settings)
165 }
166 if !view.Enforced && needsEnforcing(view.Settings) {
167 mutedf(stdout, updatewords.Text("settings.cli.set-not-enforced"), host)
168 }
169 }
170 if channel != nil {
171 return setInstanceChannel(ctx, sess, acct, app, host, *channel)
172 }
173 return nil
174}
175
176// needsEnforcing reports whether settings ask for something a box that does
177// not apply them would not do anyway: its default is automatic updates on
178// the channel, with no major hold and no pin.
179func needsEnforcing(s apiUpdatesSettings) bool {
180 return s.Updates == "manual" || s.Major != nil || s.Pin != ""
181}
182
183func printUpdatesSettings(host string, s apiUpdatesSettings) {
184 none := updatewords.Text("settings.cli.none")
185 or := func(v string) string {
186 if v == "" {
187 return none
188 }
189 return v
190 }
191 major := none
192 if s.Major != nil {
193 major = strconv.Itoa(*s.Major)
194 }
195 fmt.Fprintf(stdout, updatewords.Text("settings.cli.set-done")+"\n", host, or(s.Updates), or(s.Channel), major, or(s.Pin))
196}
197
198// runInstancesUpdate is `carlos instances update <host> [--fast-track]`:
199// "Update now". The console raises a request id; the instance's box picks
200// the build.
201func runInstancesUpdate(args []string) {
202 host, args := leadingArg(args)
203 fs := flag.NewFlagSet("instances update", flag.ExitOnError)
204 app := appFlag(fs, "app the instance belongs to")
205 fast := fs.Bool("fast-track", false, updatewords.Text("settings.cli.update-flag-fast-track"))
206 consoleFlag := consoleOption(fs, "console to act through (default: the only logged-in one)")
207 acctRaw := fs.String("account", "", "account (sqid or name) the app belongs to (default: your only account, or the configured/chosen one)")
208 acctIDRaw := fs.String("account-id", "", updatewords.Text("settings.cli.flag-account-id"))
209 usage := updatewords.Text("settings.cli.update-usage")
210 fs.Usage = flagUsage(fs, usage)
211 fs.Parse(args)
212 switch {
213 case host == "" && fs.NArg() == 1:
214 host = strings.TrimSpace(fs.Arg(0))
215 case fs.NArg() != 0:
216 log.Fatal(usage)
217 }
218 if host == "" {
219 log.Fatal(usage)
220 }
221 host = strings.ToLower(host)
222 sess := instancesDirectMode("update", *consoleFlag)
223 printUsing(stdout, sess)
224 acct := resolvedAccount(sess, acctRaw, acctIDRaw)
225 app2 := resolvedApp(sess, acct, *app, usage)
226 if err := instancesUpdateRun(context.Background(), sess, acct, app2, host, *fast); err != nil {
227 log.Fatal(err)
228 }
229}
230
231// instancesUpdateRun asks for the update and prints the request it raised.
232// The console's refusals (a pinned instance, a fast track without ownership)
233// come back as the error, in its own words.
234func instancesUpdateRun(ctx context.Context, sess *apiSession, acct apiAcct, app, host string, fast bool) error {
235 id, err := sess.installUpdate(ctx, acct, app, host, fast)
236 if err != nil {
237 return fmt.Errorf("instances update: %v", err)
238 }
239 fmt.Fprintf(stdout, updatewords.Text("settings.cli.update-done")+"\n", host, id)
240 return nil
241}
242
243func updatesPath(a apiAcct, app, host, tail string) string {
244 return apiPath(a, app, "/instances/"+url.PathEscape(host)+tail)
245}
246
247func (s *apiSession) getUpdates(ctx context.Context, a apiAcct, app, host string) (apiUpdatesView, error) {
248 status, body, err := s.getJSON(ctx, updatesPath(a, app, host, "/updates"))
249 return decodeUpdates(status, body, err)
250}
251
252func (s *apiSession) putUpdates(ctx context.Context, a apiAcct, app, host string, req map[string]any) (apiUpdatesView, error) {
253 status, body, err := s.putJSON(ctx, updatesPath(a, app, host, "/updates"), req)
254 return decodeUpdates(status, body, err)
255}
256
257func decodeUpdates(status int, body []byte, err error) (apiUpdatesView, error) {
258 if err != nil {
259 return apiUpdatesView{}, err
260 }
261 if status != http.StatusOK {
262 return apiUpdatesView{}, apiErrorFromBody(status, body)
263 }
264 var v apiUpdatesView
265 if err := json.Unmarshal(body, &v); err != nil {
266 return apiUpdatesView{}, fmt.Errorf("unreadable response: %w", err)
267 }
268 return v, nil
269}
270
271func (s *apiSession) installUpdate(ctx context.Context, a apiAcct, app, host string, fast bool) (uint64, error) {
272 status, body, err := s.postJSON(ctx, updatesPath(a, app, host, "/updates/install"), map[string]bool{"fast_track": fast})
273 if err != nil {
274 return 0, err
275 }
276 if status != http.StatusOK {
277 return 0, apiErrorFromBody(status, body)
278 }
279 var out struct {
280 Request uint64 `json:"request"`
281 }
282 if err := json.Unmarshal(body, &out); err != nil {
283 return 0, fmt.Errorf("unreadable response: %w", err)
284 }
285 return out.Request, nil
286}
287