settings-5 task 3: carlos instances set and carlos instances update
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
4 files changed,
+737
−12
cli.go+6 −0instances_cli.go+24 −12instances_updates.go+286 −0instances_updates_test.go+421 −0
diff --git a/cli.go b/cli.go| index 44ed783de..e3138681d 100644 |
| --- a/cli.go |
| +++ b/cli.go |
| @@ -382,6 +382,12 @@ func runInstances(args []string) { |
| case "door-key": |
| runInstancesDoorKey(args[1:]) |
| return |
| + case "set": |
| + runInstancesSet(args[1:]) |
| + return |
| + case "update": |
| + runInstancesUpdate(args[1:]) |
| + return |
| case "set-upstreams": |
| runInstancesSetUpstreams(args[1:]) |
| return |
diff --git a/instances_cli.go b/instances_cli.go| index 6db56d655..ae595db8d 100644 |
| --- a/instances_cli.go |
| +++ b/instances_cli.go |
| @@ -37,6 +37,7 @@ import ( |
| "github.com/carlosframework/platform/internal/provision" |
| "github.com/carlosframework/platform/internal/release" |
| "github.com/carlosframework/platform/internal/rolloutwords" |
| + "github.com/carlosframework/platform/internal/updatewords" |
| ) |
| const instancesEnableUsage = `usage: carlos instances enable --app <a> [flags] |
| @@ -209,7 +210,7 @@ func instancesEnable(ctx context.Context, s release.Store, acct, app string, m p |
| return provision.PutManifest(ctx, s, acct, app, m) |
| } |
| -const instancesRecordsUsage = `usage: carlos instances create --app <a> --host <h> [--region <r>] [--channel <c>] |
| +var instancesRecordsUsage = `usage: carlos instances create --app <a> --host <h> [--region <r>] [--channel <c>] |
| [--kind instance|service|static] [--environment <e>] |
| [--addr <a:p,...>] [--health-path <p>] [--health-status <n>] |
| carlos instances list --app <a> [--match <substr>] [--health <slug>] |
| @@ -219,6 +220,7 @@ const instancesRecordsUsage = `usage: carlos instances create --app <a> --host < |
| carlos instances set-channel --app <a> --host <h> --channel <c> |
| carlos instances set-environment --app <a> --host <h> --environment <e> |
| carlos instances retry --app <a> <host> |
| +` + updatewords.Text("settings.cli.instances-usage-lines") + ` |
| Signed-in only (carlos auth login): instance records are console-authorized |
| writes — the console checks the host against the app's manifest and the |
| @@ -241,6 +243,8 @@ retry clears the failure hold on one instance: the build that failed on its |
| box is tried again there, as a canary. "carlos promote --retry <version> |
| <channel>" does the same on every box. |
| +` + updatewords.Text("settings.cli.instances-usage-para") + ` |
| + |
| --environment binds a route to one of the app's named config bundles |
| ("carlos env environments" lists them; "carlos env --environment <e>" writes |
| them). Omitted, a route serves the app's default bundle — which matters most |
| @@ -746,23 +750,30 @@ func runInstancesSetChannel(args []string) { |
| printUsing(stdout, sess) |
| acct := resolvedAccount(sess, acctRaw, acctIDRaw) |
| app2 := resolvedApp(sess, acct, *app, instancesRecordsUsage) |
| - rec, err := sess.instanceSetChannel(context.Background(), acct, app2, |
| - strings.TrimSpace(*host), strings.TrimSpace(*channel)) |
| + if err := setInstanceChannel(context.Background(), sess, acct, app2, *host, *channel); err != nil { |
| + log.Fatal(err) |
| + } |
| +} |
| + |
| +// setInstanceChannel is the channel move both `carlos instances set-channel` |
| +// and `carlos instances set --channel` make, through the one door. A gated |
| +// move is not a failure: the change is recorded and waiting for people, so it |
| +// returns nil after naming where to finish, the shape `carlos deploy` uses |
| +// for a proposed promote. Any other refusal comes back as the error to print. |
| +func setInstanceChannel(ctx context.Context, sess *apiSession, acct apiAcct, app2, host, channel string) error { |
| + host, channel = strings.TrimSpace(host), strings.TrimSpace(channel) |
| + rec, err := sess.instanceSetChannel(ctx, acct, app2, host, channel) |
| if err != nil { |
| - // A gated move is not a failure: the change is recorded and waiting |
| - // for people, so this exits 0 naming where to finish — the shape |
| - // `carlos deploy` uses for a proposed promote. |
| var prop *apiProposedError |
| if errors.As(err, &prop) { |
| fmt.Fprintf(stdout, "moving %s to %s is proposed and needs %d confirmation(s) at %s\n", |
| - strings.TrimSpace(*host), strings.TrimSpace(*channel), prop.Needed, prop.ConsoleURL) |
| - return |
| + host, channel, prop.Needed, prop.ConsoleURL) |
| + return nil |
| } |
| - ch := strings.TrimSpace(*channel) |
| - err = recordNotFound(context.Background(), sess, acct, app2, strings.TrimSpace(*host), err, func(h string) string { |
| - return fmt.Sprintf("%s is a box route with no instance record, so the console has nothing to move. until it is adopted as an instance, move it on its box: carlos route --host %s --channel %s", h, h, ch) |
| + err = recordNotFound(ctx, sess, acct, app2, host, err, func(h string) string { |
| + return fmt.Sprintf("%s is a box route with no instance record, so the console has nothing to move. until it is adopted as an instance, move it on its box: carlos route --host %s --channel %s", h, h, channel) |
| }) |
| - log.Fatalf("instances set-channel: %v", err) |
| + return fmt.Errorf("instances set-channel: %v", err) |
| } |
| fmt.Fprintf(stdout, "%s %s to %s\n", colorFor(stdout).ok("moved"), rec.Host, rec.Channel) |
| if rec.Kind == "static" { |
| @@ -774,6 +785,7 @@ func runInstancesSetChannel(args []string) { |
| } else { |
| mutedf(stdout, " the change lands on its box within seconds of the next converge tick") |
| } |
| + return nil |
| } |
| // recordNotFound narrows the membership 404 a record write answers when its |
diff --git a/instances_updates.go b/instances_updates.go| new file mode 100644 |
| index 000000000..9dc79ab78 |
| --- /dev/null |
| +++ b/instances_updates.go |
| @@ -0,0 +1,286 @@ |
| +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 <host> [--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", "", "account sqid, matched as a sqid ONLY — supersedes --account") |
| + 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 <host> [--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", "", "account sqid, matched as a sqid ONLY — supersedes --account") |
| + 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 |
| +} |
diff --git a/instances_updates_test.go b/instances_updates_test.go| new file mode 100644 |
| index 000000000..e858d2bb4 |
| --- /dev/null |
| +++ b/instances_updates_test.go |
| @@ -0,0 +1,421 @@ |
| +package main |
| + |
| +import ( |
| + "context" |
| + "flag" |
| + "fmt" |
| + "io" |
| + "net/http" |
| + "net/http/httptest" |
| + "os/exec" |
| + "reflect" |
| + "strings" |
| + "sync" |
| + "testing" |
| + |
| + "github.com/carlosframework/platform/internal/updatewords" |
| +) |
| + |
| +// updatesConsole is a console for the instance update verbs: it answers the |
| +// settings door (GET and PUT), the install door and the channel door, and |
| +// records every request body and the order the doors were called in. |
| +type updatesConsole struct { |
| + mu sync.Mutex |
| + |
| + // getView is what the GET answers; putView is what the PUT answers (the |
| + // GET's, when empty). |
| + getView, putView string |
| + // putStatus/putBody make the PUT refuse; installStatus/installBody the |
| + // install door; channelStatus/channelBody the channel door. |
| + putStatus, installStatus, channelStatus int |
| + putBody, installBody, channelBody string |
| + |
| + puts, installs, channelPosts []string |
| + order []string |
| +} |
| + |
| +func (c *updatesConsole) session(t *testing.T) *apiSession { |
| + t.Helper() |
| + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + c.mu.Lock() |
| + defer c.mu.Unlock() |
| + b, _ := io.ReadAll(r.Body) |
| + w.Header().Set("Content-Type", "application/json") |
| + answer := func(status int, body string) { |
| + if status != 0 { |
| + w.WriteHeader(status) |
| + } |
| + io.WriteString(w, body) |
| + } |
| + switch { |
| + case r.Method == http.MethodGet && r.URL.Path == "/api/cli/apps/bab/shop/instances/a.shop.example.com/updates": |
| + c.order = append(c.order, "get") |
| + answer(0, c.getView) |
| + case r.Method == http.MethodPut && r.URL.Path == "/api/cli/apps/bab/shop/instances/a.shop.example.com/updates": |
| + c.order = append(c.order, "updates") |
| + c.puts = append(c.puts, string(b)) |
| + if c.putStatus != 0 { |
| + answer(c.putStatus, c.putBody) |
| + return |
| + } |
| + view := c.putView |
| + if view == "" { |
| + view = c.getView |
| + } |
| + answer(0, view) |
| + case r.Method == http.MethodPost && r.URL.Path == "/api/cli/apps/bab/shop/instances/a.shop.example.com/updates/install": |
| + c.order = append(c.order, "install") |
| + c.installs = append(c.installs, string(b)) |
| + if c.installStatus != 0 { |
| + answer(c.installStatus, c.installBody) |
| + return |
| + } |
| + answer(0, `{"request":4}`) |
| + case r.Method == http.MethodPost && r.URL.Path == "/api/cli/apps/bab/shop/instances/a.shop.example.com/channel": |
| + c.order = append(c.order, "channel") |
| + c.channelPosts = append(c.channelPosts, string(b)) |
| + if c.channelStatus != 0 { |
| + answer(c.channelStatus, c.channelBody) |
| + return |
| + } |
| + answer(0, `{"host":"a.shop.example.com","channel":"beta","kind":"instance"}`) |
| + default: |
| + c.order = append(c.order, "unexpected "+r.Method+" "+r.URL.Path) |
| + w.WriteHeader(http.StatusNotFound) |
| + } |
| + })) |
| + t.Cleanup(ts.Close) |
| + return newTestSession(ts.URL) |
| +} |
| + |
| +func (c *updatesConsole) orderStr() string { return strings.Join(c.order, ",") } |
| + |
| +const ( |
| + testHost = "a.shop.example.com" |
| + // the record's own values (explicit) differ from the effective ones when |
| + // the app's policy fills a gap. |
| + viewAuto = `{"host":"a.shop.example.com","settings":{"updates":"auto","channel":"stable","major":null,"pin":""},"explicit":{"updates":"","channel":"stable","major":null,"pin":""},"enforced":true}` |
| + viewManual = `{"host":"a.shop.example.com","settings":{"updates":"manual","channel":"stable","major":null,"pin":""},"explicit":{"updates":"manual","channel":"stable","major":null,"pin":""},"enforced":true}` |
| +) |
| + |
| +func setRun(t *testing.T, c *updatesConsole, req map[string]any, channel *string) (string, error) { |
| + t.Helper() |
| + out := captureStdout(t) |
| + err := instancesSetRun(context.Background(), c.session(t), apiAcct{Seg: "bab"}, "shop", testHost, req, channel) |
| + return out.String(), err |
| +} |
| + |
| +func TestInstancesSetSendsOnlyTheFlagsGiven(t *testing.T) { |
| + c := &updatesConsole{getView: viewAuto, putView: viewManual} |
| + out, err := setRun(t, c, map[string]any{"updates": "manual"}, nil) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if len(c.puts) != 1 || c.puts[0] != `{"updates":"manual"}` { |
| + t.Fatalf("PUT bodies = %q", c.puts) |
| + } |
| + if len(c.channelPosts) != 0 { |
| + t.Fatalf("the channel door was called: %q", c.channelPosts) |
| + } |
| + want := fmt.Sprintf(updatewords.Text("settings.cli.set-done")+"\n", testHost, "manual", "stable", "none", "none") |
| + if out != want { |
| + t.Fatalf("output = %q, want %q", out, want) |
| + } |
| +} |
| + |
| +func TestInstancesSetRequestSendsOnlyWhatWasGiven(t *testing.T) { |
| + parse := func(args ...string) (*flag.FlagSet, *string, *string, *string) { |
| + fs := flag.NewFlagSet("t", flag.ContinueOnError) |
| + u := fs.String("updates", "", "") |
| + m := fs.String("major", "", "") |
| + p := fs.String("pin", "", "") |
| + if err := fs.Parse(args); err != nil { |
| + t.Fatal(err) |
| + } |
| + return fs, u, m, p |
| + } |
| + fs, u, m, p := parse("--updates", " manual ", "--major", "2", "--pin", "") |
| + req, err := instancesSetRequest(fs, *u, *m, *p) |
| + if err != nil || !reflect.DeepEqual(req, map[string]any{"updates": "manual", "major": 2, "pin": ""}) { |
| + t.Fatalf("req = %#v, %v", req, err) |
| + } |
| + fs, u, m, p = parse("--major", "") |
| + req, err = instancesSetRequest(fs, *u, *m, *p) |
| + if err != nil || !reflect.DeepEqual(req, map[string]any{"major": nil}) { |
| + t.Fatalf("req = %#v, %v", req, err) |
| + } |
| + fs, u, m, p = parse() |
| + if req, err = instancesSetRequest(fs, *u, *m, *p); err != nil || len(req) != 0 { |
| + t.Fatalf("no flags: req = %#v, %v", req, err) |
| + } |
| + for _, bad := range []string{"two", "-1", "1.5"} { |
| + fs, u, m, p = parse("--major", bad) |
| + if _, err := instancesSetRequest(fs, *u, *m, *p); err == nil || err.Error() != updatewords.Text("settings.cli.bad-major-flag") { |
| + t.Fatalf("--major %q: err = %v", bad, err) |
| + } |
| + } |
| +} |
| + |
| +// Review Focus 1: "nothing changed" is the record's own values not moving, |
| +// not the effective ones. |
| +func TestInstancesSetNoChange(t *testing.T) { |
| + c := &updatesConsole{getView: viewAuto} |
| + out, err := setRun(t, c, map[string]any{"pin": ""}, nil) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if len(c.puts) != 1 || c.puts[0] != `{"pin":""}` { |
| + t.Fatalf("PUT bodies = %q", c.puts) |
| + } |
| + if want := fmt.Sprintf(updatewords.Text("settings.cli.set-unchanged")+"\n", testHost); out != want { |
| + t.Fatalf("output = %q, want %q", out, want) |
| + } |
| +} |
| + |
| +// A change in a field the effective view does not show (a record's updates |
| +// value that the app policy already supplies) is still a change. |
| +func TestInstancesSetExplicitChangeWithSameEffective(t *testing.T) { |
| + same := `{"host":"a.shop.example.com","settings":{"updates":"auto","channel":"stable","major":null,"pin":""},"explicit":{"updates":"auto","channel":"stable","major":null,"pin":""},"enforced":true}` |
| + c := &updatesConsole{getView: viewAuto, putView: same} |
| + out, err := setRun(t, c, map[string]any{"updates": "auto"}, nil) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if strings.Contains(out, "nothing changed") { |
| + t.Fatalf("output = %q", out) |
| + } |
| +} |
| + |
| +func TestInstancesSetMajorComparesByValue(t *testing.T) { |
| + a := `{"host":"a.shop.example.com","settings":{"updates":"auto","channel":"stable","major":3,"pin":""},"explicit":{"updates":"","channel":"stable","major":3,"pin":""},"enforced":true}` |
| + c := &updatesConsole{getView: a} |
| + out, err := setRun(t, c, map[string]any{"major": 3}, nil) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if !strings.Contains(out, "nothing changed") { |
| + t.Fatalf("a major set to what it was reads as a change: %q", out) |
| + } |
| +} |
| + |
| +// Review Focus 2: the settings go first, a gated channel move is not a |
| +// failure, and it does not undo the settings that were applied. |
| +func TestInstancesSetChannelProposedStillAppliesPin(t *testing.T) { |
| + pinned := `{"host":"a.shop.example.com","settings":{"updates":"auto","channel":"stable","major":null,"pin":"v3"},"explicit":{"updates":"","channel":"stable","major":null,"pin":"v3"},"enforced":true}` |
| + c := &updatesConsole{getView: viewAuto, putView: pinned, channelStatus: http.StatusAccepted, |
| + channelBody: `{"proposed":true,"needed":2,"console_url":"https://console.example/p/9"}`} |
| + beta := "beta" |
| + out, err := setRun(t, c, map[string]any{"pin": "v3"}, &beta) |
| + if err != nil { |
| + t.Fatalf("a proposed move is not a failure: %v", err) |
| + } |
| + if len(c.puts) != 1 || len(c.channelPosts) != 1 || c.orderStr() != "get,updates,channel" { |
| + t.Fatalf("puts %q, channel %q, order %s", c.puts, c.channelPosts, c.orderStr()) |
| + } |
| + if c.channelPosts[0] != `{"channel":"beta"}` { |
| + t.Fatalf("channel body = %q", c.channelPosts[0]) |
| + } |
| + if !strings.Contains(out, "is proposed and needs 2 confirmation(s) at https://console.example/p/9") { |
| + t.Fatalf("output:\n%s", out) |
| + } |
| + if !strings.Contains(out, "pin v3") { |
| + t.Fatalf("the settings line is missing:\n%s", out) |
| + } |
| +} |
| + |
| +// --channel alone is the channel door and nothing else. |
| +func TestInstancesSetChannelOnly(t *testing.T) { |
| + c := &updatesConsole{} |
| + beta := "beta" |
| + out, err := setRun(t, c, nil, &beta) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if c.orderStr() != "channel" { |
| + t.Fatalf("order = %s", c.orderStr()) |
| + } |
| + if !strings.Contains(out, "moved a.shop.example.com to beta") { |
| + t.Fatalf("output:\n%s", out) |
| + } |
| +} |
| + |
| +// A refused settings write stops the run: the channel door is not tried. |
| +func TestInstancesSetPrintsTheConsolesRefusal(t *testing.T) { |
| + c := &updatesConsole{getView: viewAuto, putStatus: http.StatusConflict, |
| + putBody: `{"error":"v9 cannot be pinned: no promotion record."}`} |
| + beta := "beta" |
| + _, err := setRun(t, c, map[string]any{"pin": "v9"}, &beta) |
| + if err == nil || !strings.Contains(err.Error(), "v9 cannot be pinned: no promotion record.") { |
| + t.Fatalf("err = %v", err) |
| + } |
| + if len(c.channelPosts) != 0 { |
| + t.Fatalf("the channel door was called after a refused write: %q", c.channelPosts) |
| + } |
| +} |
| + |
| +// A refused channel move is the console's words too, after the settings line. |
| +func TestInstancesSetChannelRefusalAfterSettings(t *testing.T) { |
| + c := &updatesConsole{getView: viewAuto, putView: viewManual, channelStatus: http.StatusConflict, |
| + channelBody: `{"error":"no such channel"}`} |
| + beta := "beta" |
| + out, err := setRun(t, c, map[string]any{"updates": "manual"}, &beta) |
| + if err == nil || !strings.Contains(err.Error(), "no such channel") { |
| + t.Fatalf("err = %v", err) |
| + } |
| + if !strings.Contains(out, "updates manual") { |
| + t.Fatalf("the settings line is missing:\n%s", out) |
| + } |
| +} |
| + |
| +// R8: the "not every box applies" line is for settings that need enforcing. |
| +func TestInstancesSetNotEnforcedOnlyForNonDefault(t *testing.T) { |
| + notEnforced := func(view string) string { |
| + return strings.Replace(view, `"enforced":true`, `"enforced":false`, 1) |
| + } |
| + line := fmt.Sprintf(updatewords.Text("settings.cli.set-not-enforced"), testHost) |
| + cases := []struct { |
| + name string |
| + view string |
| + want bool |
| + }{ |
| + {"manual", notEnforced(viewManual), true}, |
| + {"auto and stable", notEnforced(viewAuto), false}, |
| + {"a major", strings.Replace(notEnforced(viewAuto), `"major":null`, `"major":2`, 1), true}, |
| + {"a pin", strings.Replace(notEnforced(viewAuto), `"pin":""`, `"pin":"v3"`, 1), true}, |
| + {"enforced", viewManual, false}, |
| + } |
| + for _, tc := range cases { |
| + t.Run(tc.name, func(t *testing.T) { |
| + c := &updatesConsole{getView: viewAuto, putView: tc.view} |
| + out, err := setRun(t, c, map[string]any{"updates": "manual"}, nil) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if got := strings.Contains(out, line); got != tc.want { |
| + t.Fatalf("not-enforced line present = %v, want %v:\n%s", got, tc.want, out) |
| + } |
| + }) |
| + } |
| + // It only follows settings that were sent. |
| + c := &updatesConsole{} |
| + beta := "beta" |
| + out, err := setRun(t, c, nil, &beta) |
| + if err != nil || strings.Contains(out, line) { |
| + t.Fatalf("channel only: err %v, output:\n%s", err, out) |
| + } |
| +} |
| + |
| +func TestInstancesUpdatePrintsTheRequest(t *testing.T) { |
| + c := &updatesConsole{} |
| + out := captureStdout(t) |
| + err := instancesUpdateRun(context.Background(), c.session(t), apiAcct{Seg: "bab"}, "shop", testHost, true) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if len(c.installs) != 1 || c.installs[0] != `{"fast_track":true}` { |
| + t.Fatalf("installs %q", c.installs) |
| + } |
| + if want := fmt.Sprintf(updatewords.Text("settings.cli.update-done")+"\n", testHost, 4); out.String() != want { |
| + t.Fatalf("output = %q, want %q", out.String(), want) |
| + } |
| + c2 := &updatesConsole{} |
| + if err := instancesUpdateRun(context.Background(), c2.session(t), apiAcct{Seg: "bab"}, "shop", testHost, false); err != nil { |
| + t.Fatal(err) |
| + } |
| + if c2.installs[0] != `{"fast_track":false}` { |
| + t.Fatalf("installs %q", c2.installs) |
| + } |
| +} |
| + |
| +func TestInstancesUpdateRefusalsAreTheConsolesWords(t *testing.T) { |
| + for _, tc := range []struct { |
| + status int |
| + body string |
| + want string |
| + }{ |
| + {http.StatusConflict, `{"error":"a.shop.example.com is pinned to v3."}`, "a.shop.example.com is pinned to v3."}, |
| + {http.StatusForbidden, `{"error":"only an owner can fast track."}`, "only an owner can fast track."}, |
| + } { |
| + c := &updatesConsole{installStatus: tc.status, installBody: tc.body} |
| + out := captureStdout(t) |
| + err := instancesUpdateRun(context.Background(), c.session(t), apiAcct{Seg: "bab"}, "shop", testHost, false) |
| + if err == nil || !strings.Contains(err.Error(), tc.want) { |
| + t.Fatalf("status %d: err = %v", tc.status, err) |
| + } |
| + if out.Len() != 0 { |
| + t.Fatalf("status %d: printed %q", tc.status, out.String()) |
| + } |
| + } |
| +} |
| + |
| +// The verbs that end in log.Fatal run as the real binary. |
| +func TestInstancesSetAndUpdateRefuseDirectMode(t *testing.T) { |
| + store := t.TempDir() |
| + for _, tc := range []struct { |
| + verb string |
| + args []string |
| + }{ |
| + {"set", []string{"instances", "set", testHost, "--updates", "manual"}}, |
| + {"update", []string{"instances", "update", testHost}}, |
| + } { |
| + bin := buildCarlos(t) |
| + out, err := runCarlosEnv(t, bin, tc.args, "CARLOS_DEPLOYMENT_DIR="+store) |
| + if err == nil { |
| + t.Fatalf("%s exited 0 in direct mode:\n%s", tc.verb, out) |
| + } |
| + want := fmt.Sprintf(updatewords.Text("settings.cli.direct-mode"), tc.verb) |
| + if !strings.Contains(out, want) { |
| + t.Fatalf("%s: output\n%s\nwant %q", tc.verb, out, want) |
| + } |
| + } |
| +} |
| + |
| +func TestInstancesSetRefusesWhatItCannotSendBeforeAnyConsole(t *testing.T) { |
| + bin := buildCarlos(t) |
| + for _, args := range [][]string{ |
| + {"instances", "set", testHost}, // nothing to set |
| + {"instances", "set", testHost, "--channel", ""}, // a channel cannot be cleared |
| + {"instances", "set", "--updates", "manual"}, // no host |
| + {"instances", "update"}, // no host |
| + } { |
| + out, err := runCarlosEnv(t, bin, args) |
| + if err == nil { |
| + t.Fatalf("%v exited 0:\n%s", args, out) |
| + } |
| + usage := updatewords.Text("settings.cli.set-usage") |
| + if args[1] == "update" { |
| + usage = updatewords.Text("settings.cli.update-usage") |
| + } |
| + if !strings.Contains(out, usage) { |
| + t.Fatalf("%v: output\n%s\nwant the usage %q", args, out, usage) |
| + } |
| + } |
| + out, err := runCarlosEnv(t, bin, []string{"instances", "set", testHost, "--major", "two"}) |
| + if err == nil || !strings.Contains(out, updatewords.Text("settings.cli.bad-major-flag")) { |
| + t.Fatalf("bad major: err %v, output\n%s", err, out) |
| + } |
| +} |
| + |
| +func TestInstancesUsageNamesTheNewVerbs(t *testing.T) { |
| + for _, id := range []string{"settings.cli.instances-usage-lines", "settings.cli.instances-usage-para"} { |
| + if !strings.Contains(instancesRecordsUsage, updatewords.Text(id)) { |
| + t.Fatalf("the instances usage lacks %s", id) |
| + } |
| + } |
| + for _, line := range []string{"carlos instances retry --app <a> <host>\n", "does the same on every box.\n"} { |
| + if !strings.Contains(instancesRecordsUsage, line) { |
| + t.Fatalf("the instances usage lost %q", line) |
| + } |
| + } |
| +} |
| + |
| +// runCarlosEnv runs the built binary with the test environment (no console, |
| +// scratch credentials) plus extra variables. |
| +func runCarlosEnv(t *testing.T, bin string, args []string, extra ...string) (string, error) { |
| + t.Helper() |
| + cmd := exec.Command(bin, args...) |
| + cmd.Env = carlosTestEnv(t, extra...) |
| + cmd.Dir = t.TempDir() |
| + out, err := cmd.CombinedOutput() |
| + return string(out), err |
| +} |