package main // `carlos instances set` and `carlos instances update` (instance update // settings spec §5): thin fronts on the console's per-instance update doors. // set sends the settings to PUT …/instances/{host}/updates and a channel to // the existing channel door; update raises "Update now" on …/updates/install. import ( "context" "encoding/json" "errors" "flag" "fmt" "log" "net/http" "net/url" "strconv" "strings" "github.com/carlosframework/platform/internal/updatewords" ) // apiUpdatesSettings is one set of the four settings as the console writes // them. Major is a pointer because null is "no hold". type apiUpdatesSettings struct { Updates string `json:"updates"` Channel string `json:"channel"` Major *int `json:"major"` Pin string `json:"pin"` } // apiUpdatesView is what both settings doors answer with. Settings are the // effective values (the app's policy filling any gap), Explicit the // record's own, and Enforced whether the fleet applies them. type apiUpdatesView struct { Host string `json:"host"` Settings apiUpdatesSettings `json:"settings"` Explicit apiUpdatesSettings `json:"explicit"` Enforced bool `json:"enforced"` } // sameSettings compares by value: Major is a pointer, so == would compare // addresses. func sameSettings(a, b apiUpdatesSettings) bool { am, bm := -1, -1 if a.Major != nil { am = *a.Major } if b.Major != nil { bm = *b.Major } return a.Updates == b.Updates && a.Channel == b.Channel && a.Pin == b.Pin && am == bm } // instancesSetRequest builds the settings door's body from the flags given: a // flag not given is not sent, and an empty value clears. A bad --major costs // no round trip. --channel is not part of it: the settings door refuses a // channel, and the channel door applies the pipeline's rules. func instancesSetRequest(fs *flag.FlagSet, updates, major, pin string) (map[string]any, error) { req := map[string]any{} if flagWasSet(fs, "updates") { req["updates"] = strings.TrimSpace(updates) } if flagWasSet(fs, "major") { if m := strings.TrimSpace(major); m == "" { req["major"] = nil } else { n, err := strconv.Atoi(m) if err != nil || n < 0 { return nil, errors.New(updatewords.Text("settings.cli.bad-major-flag")) } req["major"] = n } } if flagWasSet(fs, "pin") { req["pin"] = strings.TrimSpace(pin) } return req, nil } // instancesDirectMode resolves the signed-in session for the two update // verbs, refusing direct bucket mode in the words of settings.cli.direct-mode. func instancesDirectMode(verb, consoleFlag string) *apiSession { sess, err := resolveMode(consoleFlag) if err != nil { log.Fatal(err) } if sess == nil { log.Fatalf(updatewords.Text("settings.cli.direct-mode"), verb) } return sess } // runInstancesSet is `carlos instances set [--updates …] [--channel …] // [--major …] [--pin …]`. The host may come first or after the flags. func runInstancesSet(args []string) { host, args := leadingArg(args) fs := flag.NewFlagSet("instances set", flag.ExitOnError) app := appFlag(fs, "app the instance belongs to") updates := fs.String("updates", "", updatewords.Text("settings.cli.set-flag-updates")) channel := channelFlag(fs, updatewords.Text("settings.cli.set-flag-channel")) major := fs.String("major", "", updatewords.Text("settings.cli.set-flag-major")) pin := fs.String("pin", "", updatewords.Text("settings.cli.set-flag-pin")) consoleFlag := consoleOption(fs, "console to act through (default: the only logged-in one)") 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", "", updatewords.Text("settings.cli.flag-account-id")) usage := updatewords.Text("settings.cli.set-usage") fs.Usage = flagUsage(fs, usage) fs.Parse(args) switch { case host == "" && fs.NArg() == 1: host = strings.TrimSpace(fs.Arg(0)) case fs.NArg() != 0: log.Fatal(usage) } if host == "" { log.Fatal(usage) } // Records and routes are keyed lowercase (#264). host = strings.ToLower(host) req, err := instancesSetRequest(fs, *updates, *major, *pin) if err != nil { log.Fatal(err) } var channelTo *string if flagWasSet(fs, "channel") { // A channel cannot be cleared: an empty one is not a move. c := strings.TrimSpace(*channel) if c == "" { log.Fatal(usage) } channelTo = &c } if len(req) == 0 && channelTo == nil { log.Fatal(usage) } sess := instancesDirectMode("set", *consoleFlag) printUsing(stdout, sess) acct := resolvedAccount(sess, acctRaw, acctIDRaw) app2 := resolvedApp(sess, acct, *app, usage) if err := instancesSetRun(context.Background(), sess, acct, app2, host, req, channelTo); err != nil { log.Fatal(err) } } // instancesSetRun is `carlos instances set` once its flags are checked and // its session resolved: the settings door first, when settings were given, // then the channel door. The first refusal stops it, so a channel is never // tried after refused settings. A gated channel move is not a refusal: it // leaves the settings applied and the run successful. func instancesSetRun(ctx context.Context, sess *apiSession, acct apiAcct, app, host string, req map[string]any, channel *string) error { if len(req) > 0 { before, err := sess.getUpdates(ctx, acct, app, host) if err != nil { return fmt.Errorf("instances set: %v", err) } view, err := sess.putUpdates(ctx, acct, app, host, req) if err != nil { return fmt.Errorf("instances set: %v", err) } if sameSettings(view.Explicit, before.Explicit) { fmt.Fprintf(stdout, updatewords.Text("settings.cli.set-unchanged")+"\n", host) } else { printUpdatesSettings(host, view.Settings) } if !view.Enforced && needsEnforcing(view.Settings) { mutedf(stdout, updatewords.Text("settings.cli.set-not-enforced"), host) } } if channel != nil { return setInstanceChannel(ctx, sess, acct, app, host, *channel) } return nil } // needsEnforcing reports whether settings ask for something a box that does // not apply them would not do anyway: its default is automatic updates on // the channel, with no major hold and no pin. func needsEnforcing(s apiUpdatesSettings) bool { return s.Updates == "manual" || s.Major != nil || s.Pin != "" } func printUpdatesSettings(host string, s apiUpdatesSettings) { none := updatewords.Text("settings.cli.none") or := func(v string) string { if v == "" { return none } return v } major := none if s.Major != nil { major = strconv.Itoa(*s.Major) } fmt.Fprintf(stdout, updatewords.Text("settings.cli.set-done")+"\n", host, or(s.Updates), or(s.Channel), major, or(s.Pin)) } // runInstancesUpdate is `carlos instances update [--fast-track]`: // "Update now". The console raises a request id; the instance's box picks // the build. func runInstancesUpdate(args []string) { host, args := leadingArg(args) fs := flag.NewFlagSet("instances update", flag.ExitOnError) app := appFlag(fs, "app the instance belongs to") fast := fs.Bool("fast-track", false, updatewords.Text("settings.cli.update-flag-fast-track")) consoleFlag := consoleOption(fs, "console to act through (default: the only logged-in one)") 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", "", updatewords.Text("settings.cli.flag-account-id")) usage := updatewords.Text("settings.cli.update-usage") fs.Usage = flagUsage(fs, usage) fs.Parse(args) switch { case host == "" && fs.NArg() == 1: host = strings.TrimSpace(fs.Arg(0)) case fs.NArg() != 0: log.Fatal(usage) } if host == "" { log.Fatal(usage) } host = strings.ToLower(host) sess := instancesDirectMode("update", *consoleFlag) printUsing(stdout, sess) acct := resolvedAccount(sess, acctRaw, acctIDRaw) app2 := resolvedApp(sess, acct, *app, usage) if err := instancesUpdateRun(context.Background(), sess, acct, app2, host, *fast); err != nil { log.Fatal(err) } } // instancesUpdateRun asks for the update and prints the request it raised. // The console's refusals (a pinned instance, a fast track without ownership) // come back as the error, in its own words. func instancesUpdateRun(ctx context.Context, sess *apiSession, acct apiAcct, app, host string, fast bool) error { id, err := sess.installUpdate(ctx, acct, app, host, fast) if err != nil { return fmt.Errorf("instances update: %v", err) } fmt.Fprintf(stdout, updatewords.Text("settings.cli.update-done")+"\n", host, id) return nil } func updatesPath(a apiAcct, app, host, tail string) string { return apiPath(a, app, "/instances/"+url.PathEscape(host)+tail) } func (s *apiSession) getUpdates(ctx context.Context, a apiAcct, app, host string) (apiUpdatesView, error) { status, body, err := s.getJSON(ctx, updatesPath(a, app, host, "/updates")) return decodeUpdates(status, body, err) } func (s *apiSession) putUpdates(ctx context.Context, a apiAcct, app, host string, req map[string]any) (apiUpdatesView, error) { status, body, err := s.putJSON(ctx, updatesPath(a, app, host, "/updates"), req) return decodeUpdates(status, body, err) } func decodeUpdates(status int, body []byte, err error) (apiUpdatesView, error) { if err != nil { return apiUpdatesView{}, err } if status != http.StatusOK { return apiUpdatesView{}, apiErrorFromBody(status, body) } var v apiUpdatesView if err := json.Unmarshal(body, &v); err != nil { return apiUpdatesView{}, fmt.Errorf("unreadable response: %w", err) } return v, nil } func (s *apiSession) installUpdate(ctx context.Context, a apiAcct, app, host string, fast bool) (uint64, error) { status, body, err := s.postJSON(ctx, updatesPath(a, app, host, "/updates/install"), map[string]bool{"fast_track": fast}) if err != nil { return 0, err } if status != http.StatusOK { return 0, apiErrorFromBody(status, body) } var out struct { Request uint64 `json:"request"` } if err := json.Unmarshal(body, &out); err != nil { return 0, fmt.Errorf("unreadable response: %w", err) } return out.Request, nil }