carlos / platform Public

settings-5 task 3: carlos instances set and carlos instances update

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev 5b9c8837ce961b049abb8f7e515c5cec278741b7 parent 614ea70
4 files changed, +737 −12
  • cli.go +6 −0
  • instances_cli.go +24 −12
  • instances_updates.go +286 −0
  • instances_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
+}