| 1 | package 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 | |
| 8 | import ( |
| 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". |
| 25 | type 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. |
| 35 | type 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. |
| 44 | func 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. |
| 59 | func 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. |
| 83 | func 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. |
| 96 | func 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. |
| 151 | func 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. |
| 179 | func needsEnforcing(s apiUpdatesSettings) bool { |
| 180 | return s.Updates == "manual" || s.Major != nil || s.Pin != "" |
| 181 | } |
| 182 | |
| 183 | func 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. |
| 201 | func 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. |
| 234 | func 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 | |
| 243 | func updatesPath(a apiAcct, app, host, tail string) string { |
| 244 | return apiPath(a, app, "/instances/"+url.PathEscape(host)+tail) |
| 245 | } |
| 246 | |
| 247 | func (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 | |
| 252 | func (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 | |
| 257 | func 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 | |
| 271 | func (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 | |