carlos / platform Public

settings-5 task 2: carlos apps set sets the instance update policy

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev 614ea70d0fc2a7602ee44631b2242837fc0ad4b0 parent 0b7355a
3 files changed, +402 −13
  • apps_swap.go +20 −13
  • apps_updates.go +129 −0
  • apps_updates_test.go +253 −0
diff --git a/apps_swap.go b/apps_swap.go
index aa2dfe84b..d2126754c 100644
--- a/apps_swap.go
+++ b/apps_swap.go
@@ -13,6 +13,8 @@ import (
"log"
"net/http"
"strings"
+
+ "github.com/carlosframework/platform/internal/updatewords"
)
const appsSetUsage = `usage: carlos apps set <app> [--swap overlap|hold] [--swap-concurrency <n>] [--account <sqid|name>] [--account-id <sqid>] [--console <origin>]
@@ -129,8 +131,12 @@ func runAppsSet(args []string) {
concurrency := fs.Int("swap-concurrency", 0, appsSetFlagConcurrency)
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")
+ control := fs.String("instance-control", "", updatewords.Text("settings.cli.apps-set-flag-control"))
+ defUpdates := fs.String("default-updates", "", updatewords.Text("settings.cli.apps-set-flag-updates"))
+ defChannel := fs.String("default-channel", "", updatewords.Text("settings.cli.apps-set-flag-channel"))
+ defMajor := fs.String("default-major", "", updatewords.Text("settings.cli.apps-set-flag-major"))
consoleFlag := consoleOption(fs, "console to act through (default: the only logged-in one)")
- fs.Usage = flagUsage(fs, appsSetUsage)
+ fs.Usage = flagUsage(fs, appsSetUsage+"\n\n"+updatewords.Text("settings.cli.apps-set-usage"))
fs.Parse(args)
app, err := appsSetApp(lead, *appF, fs.Args())
if err != nil {
@@ -139,10 +145,21 @@ func runAppsSet(args []string) {
if err := validAppName(app); err != nil {
log.Fatalf(appsSetError, err)
}
- req, err := appsSetRequest(*swap, *concurrency, flagWasSet(fs, "swap"), flagWasSet(fs, "swap-concurrency"))
+ var swapReq *swapPolicyReq
+ if flagWasSet(fs, "swap") || flagWasSet(fs, "swap-concurrency") {
+ r, err := appsSetRequest(*swap, *concurrency, flagWasSet(fs, "swap"), flagWasSet(fs, "swap-concurrency"))
+ if err != nil {
+ log.Fatal(err)
+ }
+ swapReq = &r
+ }
+ policy, err := updatePolicyRequest(fs, *control, *defUpdates, *defChannel, *defMajor)
if err != nil {
log.Fatal(err)
}
+ if swapReq == nil && policy == nil {
+ log.Fatal(appsSetNothing)
+ }
sess, err := resolveMode(*consoleFlag)
if err != nil {
log.Fatal(err)
@@ -151,19 +168,9 @@ func runAppsSet(args []string) {
log.Fatal(appsSetDirectMode)
}
acct := resolvedAccount(sess, acctRaw, acctIDRaw)
- got, err := sess.setSwapPolicy(context.Background(), acct, app, req)
- if err != nil {
- log.Fatalf(appsSetError, err)
- }
- if err := appsSetStuck(req, got); err != nil {
+ if err := appsSetRun(context.Background(), sess, acct, app, swapReq, policy); err != nil {
log.Fatal(err)
}
- fmt.Fprintf(stdout, appsSetDone+"\n", app, got.Swap, got.Concurrency)
- if got.Swap == "hold" {
- mutedf(stdout, appsSetHoldNote)
- } else {
- mutedf(stdout, appsSetOverlapNote)
- }
}
// setSwapPolicy is `carlos apps set`'s request. A 404 with no JSON error is
diff --git a/apps_updates.go b/apps_updates.go
new file mode 100644
index 000000000..d69b2a308
--- /dev/null
+++ b/apps_updates.go
@@ -0,0 +1,129 @@
+package main
+
+// `carlos apps set`'s instance update policy flags (instance update settings
+// spec §3.2, §5): --instance-control, --default-updates, --default-channel and
+// --default-major, sent to the console's PUT /api/cli/apps/{account}/{app}/updates
+// door (handleAPIUpdatesPolicy).
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "flag"
+ "fmt"
+ "net/http"
+ "strconv"
+ "strings"
+
+ "github.com/carlosframework/platform/internal/updatewords"
+)
+
+// updatePolicyReq is the door's body: a key not present is left as it is, and
+// a key present with null or "" clears it.
+type updatePolicyReq map[string]any
+
+// apiUpdatePolicy is the door's echo: what the console stored.
+type apiUpdatePolicy struct {
+ InstanceControl []string `json:"instance_control"`
+ DefaultUpdates string `json:"default_updates"`
+ DefaultChannel string `json:"default_channel"`
+ DefaultMajor *int `json:"default_major"`
+}
+
+// updatePolicyRequest builds the door's body from the flags given: a flag not
+// given is not sent, and an empty value clears. It returns nil when none of
+// the four was given. A bad --default-major costs no round trip.
+func updatePolicyRequest(fs *flag.FlagSet, control, updates, channel, major string) (updatePolicyReq, error) {
+ req := updatePolicyReq{}
+ if flagWasSet(fs, "instance-control") {
+ var names []string // nil marshals as null, which the console reads as a clear
+ for _, n := range strings.Split(control, ",") {
+ if n = strings.TrimSpace(n); n != "" {
+ names = append(names, n)
+ }
+ }
+ req["instance_control"] = names
+ }
+ if flagWasSet(fs, "default-updates") {
+ req["default_updates"] = strings.TrimSpace(updates)
+ }
+ if flagWasSet(fs, "default-channel") {
+ req["default_channel"] = strings.TrimSpace(channel)
+ }
+ if flagWasSet(fs, "default-major") {
+ if m := strings.TrimSpace(major); m == "" {
+ req["default_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["default_major"] = n
+ }
+ }
+ if len(req) == 0 {
+ return nil, nil
+ }
+ return req, nil
+}
+
+// setUpdatePolicy is the update policy half of `carlos apps set`. The console
+// refuses with its own words, and they are returned as they came.
+func (s *apiSession) setUpdatePolicy(ctx context.Context, a apiAcct, app string, req updatePolicyReq) (apiUpdatePolicy, error) {
+ status, body, err := s.putJSON(ctx, apiPath(a, app, "/updates"), req)
+ if err != nil {
+ return apiUpdatePolicy{}, err
+ }
+ if status != http.StatusOK {
+ return apiUpdatePolicy{}, apiErrorFromBody(status, body)
+ }
+ var out apiUpdatePolicy
+ if err := json.Unmarshal(body, &out); err != nil {
+ return apiUpdatePolicy{}, fmt.Errorf(appsSetUnreadable, err)
+ }
+ return out, nil
+}
+
+// appsSetRun is `carlos apps set` once its flags are checked and its session
+// resolved: the swap door when a swap flag was given, then the update policy
+// door when a policy flag was, one line for each. The first failure stops it.
+func appsSetRun(ctx context.Context, sess *apiSession, acct apiAcct, app string, swap *swapPolicyReq, policy updatePolicyReq) error {
+ if swap == nil && policy == nil {
+ return errors.New(appsSetNothing)
+ }
+ if swap != nil {
+ got, err := sess.setSwapPolicy(ctx, acct, app, *swap)
+ if err != nil {
+ return fmt.Errorf(appsSetError, err)
+ }
+ if err := appsSetStuck(*swap, got); err != nil {
+ return err
+ }
+ fmt.Fprintf(stdout, appsSetDone+"\n", app, got.Swap, got.Concurrency)
+ if got.Swap == "hold" {
+ mutedf(stdout, appsSetHoldNote)
+ } else {
+ mutedf(stdout, appsSetOverlapNote)
+ }
+ }
+ if policy != nil {
+ got, err := sess.setUpdatePolicy(ctx, acct, app, policy)
+ if err != nil {
+ return fmt.Errorf(appsSetError, err)
+ }
+ none := updatewords.Text("settings.cli.none")
+ or := func(s string) string {
+ if s == "" {
+ return none
+ }
+ return s
+ }
+ major := none
+ if got.DefaultMajor != nil {
+ major = strconv.Itoa(*got.DefaultMajor)
+ }
+ fmt.Fprintf(stdout, updatewords.Text("settings.cli.apps-set-policy-done")+"\n", app,
+ or(strings.Join(got.InstanceControl, ", ")), or(got.DefaultUpdates), or(got.DefaultChannel), major)
+ }
+ return nil
+}
diff --git a/apps_updates_test.go b/apps_updates_test.go
new file mode 100644
index 000000000..5aba6c91d
--- /dev/null
+++ b/apps_updates_test.go
@@ -0,0 +1,253 @@
+package main
+
+import (
+ "context"
+ "errors"
+ "flag"
+ "fmt"
+ "io"
+ "net/http"
+ "net/http/httptest"
+ "reflect"
+ "strings"
+ "sync"
+ "testing"
+
+ "github.com/carlosframework/platform/internal/updatewords"
+)
+
+func parsed(t *testing.T, args ...string) (*flag.FlagSet, *string, *string, *string, *string) {
+ t.Helper()
+ fs := flag.NewFlagSet("t", flag.ContinueOnError)
+ c := fs.String("instance-control", "", "")
+ u := fs.String("default-updates", "", "")
+ ch := fs.String("default-channel", "", "")
+ m := fs.String("default-major", "", "")
+ if err := fs.Parse(args); err != nil {
+ t.Fatal(err)
+ }
+ return fs, c, u, ch, m
+}
+
+func TestUpdatePolicyRequestSendsOnlyWhatWasGiven(t *testing.T) {
+ fs, c, u, ch, m := parsed(t, "--default-updates", "manual")
+ req, err := updatePolicyRequest(fs, *c, *u, *ch, *m)
+ if err != nil || !reflect.DeepEqual(req, updatePolicyReq{"default_updates": "manual"}) {
+ t.Fatalf("req = %v, %v", req, err)
+ }
+ fs, c, u, ch, m = parsed(t, "--instance-control", "updates,pin", "--default-major", "")
+ req, err = updatePolicyRequest(fs, *c, *u, *ch, *m)
+ if err != nil || !reflect.DeepEqual(req, updatePolicyReq{"instance_control": []string{"updates", "pin"}, "default_major": nil}) {
+ t.Fatalf("req = %v, %v", req, err)
+ }
+ fs, c, u, ch, m = parsed(t, "--default-major", "2")
+ req, _ = updatePolicyRequest(fs, *c, *u, *ch, *m)
+ if req["default_major"] != 2 {
+ t.Fatalf("major = %v", req["default_major"])
+ }
+ for _, bad := range []string{"two", "-1", "1.5"} {
+ fs, c, u, ch, m = parsed(t, "--default-major", bad)
+ _, err := updatePolicyRequest(fs, *c, *u, *ch, *m)
+ if err == nil || err.Error() != updatewords.Text("settings.cli.bad-major-flag") {
+ t.Fatalf("--default-major %q: err = %v", bad, err)
+ }
+ }
+ fs, c, u, ch, m = parsed(t)
+ if req, _ := updatePolicyRequest(fs, *c, *u, *ch, *m); req != nil {
+ t.Fatalf("no flags, yet a request: %v", req)
+ }
+}
+
+// An empty --instance-control clears: it goes out as JSON null, which the
+// console reads as "keep them all for the app".
+func TestUpdatePolicyRequestEmptyControlIsNull(t *testing.T) {
+ fs, c, u, ch, m := parsed(t, "--instance-control", " , ", "--default-updates", "", "--default-channel", "beta")
+ req, err := updatePolicyRequest(fs, *c, *u, *ch, *m)
+ if err != nil {
+ t.Fatal(err)
+ }
+ want := updatePolicyReq{"instance_control": []string(nil), "default_updates": "", "default_channel": "beta"}
+ if !reflect.DeepEqual(req, want) {
+ t.Fatalf("req = %#v", req)
+ }
+}
+
+func TestAPISessionSetUpdatePolicyWireShape(t *testing.T) {
+ var method, path, body string
+ ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ b, _ := io.ReadAll(r.Body)
+ method, path, body = r.Method, r.URL.Path, string(b)
+ w.Header().Set("Content-Type", "application/json")
+ io.WriteString(w, `{"instance_control":["updates","pin"],"default_updates":"manual","default_channel":"stable","default_major":2}`)
+ }))
+ defer ts.Close()
+ got, err := newTestSession(ts.URL).setUpdatePolicy(context.Background(), apiAcct{Seg: "bab"}, "shop",
+ updatePolicyReq{"default_updates": "manual", "default_major": 2})
+ two := 2
+ want := apiUpdatePolicy{InstanceControl: []string{"updates", "pin"}, DefaultUpdates: "manual", DefaultChannel: "stable", DefaultMajor: &two}
+ if err != nil || !reflect.DeepEqual(got, want) {
+ t.Fatalf("setUpdatePolicy = %+v, %v", got, err)
+ }
+ if method != http.MethodPut || path != "/api/cli/apps/bab/shop/updates" || body != `{"default_major":2,"default_updates":"manual"}` {
+ t.Fatalf("sent %s %s %s", method, path, body)
+ }
+}
+
+func TestAPISessionSetUpdatePolicyNullsOnTheWire(t *testing.T) {
+ var body string
+ ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ b, _ := io.ReadAll(r.Body)
+ body = string(b)
+ io.WriteString(w, `{"instance_control":[],"default_updates":"","default_channel":"stable","default_major":null}`)
+ }))
+ defer ts.Close()
+ got, err := newTestSession(ts.URL).setUpdatePolicy(context.Background(), apiAcct{Seg: "bab"}, "shop",
+ updatePolicyReq{"instance_control": []string(nil), "default_major": nil})
+ if err != nil || got.DefaultMajor != nil || len(got.InstanceControl) != 0 {
+ t.Fatalf("setUpdatePolicy = %+v, %v", got, err)
+ }
+ if body != `{"default_major":null,"instance_control":null}` {
+ t.Fatalf("sent %s", body)
+ }
+}
+
+func TestAPISessionSetUpdatePolicyErrorVerbatim(t *testing.T) {
+ ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(http.StatusConflict)
+ io.WriteString(w, `{"error":"the console says no, in its own words"}`)
+ }))
+ defer ts.Close()
+ _, err := newTestSession(ts.URL).setUpdatePolicy(context.Background(), apiAcct{Seg: "bab"}, "shop", updatePolicyReq{"default_channel": ""})
+ if err == nil || err.Error() != "the console says no, in its own words" {
+ t.Fatalf("err = %v", err)
+ }
+}
+
+// doorLog is a console that records which doors were called, in order.
+type doorLog struct {
+ mu sync.Mutex
+ calls []string
+}
+
+func (d *doorLog) add(s string) {
+ d.mu.Lock()
+ defer d.mu.Unlock()
+ d.calls = append(d.calls, s)
+}
+
+func (d *doorLog) got() []string {
+ d.mu.Lock()
+ defer d.mu.Unlock()
+ return append([]string(nil), d.calls...)
+}
+
+func appsSetConsole(t *testing.T, d *doorLog) *apiSession {
+ t.Helper()
+ ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ d.add(r.Method + " " + r.URL.Path)
+ w.Header().Set("Content-Type", "application/json")
+ switch {
+ case strings.HasSuffix(r.URL.Path, "/swap"):
+ io.WriteString(w, `{"swap":"hold","swap_concurrency":2}`)
+ case strings.HasSuffix(r.URL.Path, "/updates"):
+ io.WriteString(w, `{"instance_control":["updates","pin"],"default_updates":"manual","default_channel":"stable","default_major":3}`)
+ default:
+ w.WriteHeader(http.StatusNotFound)
+ }
+ }))
+ t.Cleanup(ts.Close)
+ return newTestSession(ts.URL)
+}
+
+func TestAppsSetRunPolicyFlagsOnlyCallOnlyTheUpdatesDoor(t *testing.T) {
+ out := captureStdout(t)
+ var d doorLog
+ policy := updatePolicyReq{"default_updates": "manual"}
+ if err := appsSetRun(context.Background(), appsSetConsole(t, &d), apiAcct{Seg: "bab"}, "shop", nil, policy); err != nil {
+ t.Fatal(err)
+ }
+ if want := []string{"PUT /api/cli/apps/bab/shop/updates"}; !reflect.DeepEqual(d.got(), want) {
+ t.Fatalf("doors = %v", d.got())
+ }
+ want := fmt.Sprintf(updatewords.Text("settings.cli.apps-set-policy-done")+"\n", "shop", "updates, pin", "manual", "stable", "3")
+ if out.String() != want {
+ t.Fatalf("stdout = %q, want %q", out.String(), want)
+ }
+}
+
+func TestAppsSetRunSwapFlagsOnlyCallOnlyTheSwapDoor(t *testing.T) {
+ out := captureStdout(t)
+ var d doorLog
+ hold := "hold"
+ if err := appsSetRun(context.Background(), appsSetConsole(t, &d), apiAcct{Seg: "bab"}, "shop", &swapPolicyReq{Swap: &hold}, nil); err != nil {
+ t.Fatal(err)
+ }
+ if want := []string{"PUT /api/cli/apps/bab/shop/swap"}; !reflect.DeepEqual(d.got(), want) {
+ t.Fatalf("doors = %v", d.got())
+ }
+ if want := fmt.Sprintf(appsSetDone+"\n", "shop", "hold", 2); !strings.HasPrefix(out.String(), want) || strings.Contains(out.String(), "default updates") {
+ t.Fatalf("stdout = %q", out.String())
+ }
+}
+
+func TestAppsSetRunBothCallSwapThenUpdates(t *testing.T) {
+ out := captureStdout(t)
+ var d doorLog
+ hold := "hold"
+ if err := appsSetRun(context.Background(), appsSetConsole(t, &d), apiAcct{Seg: "bab"}, "shop",
+ &swapPolicyReq{Swap: &hold}, updatePolicyReq{"default_major": 3}); err != nil {
+ t.Fatal(err)
+ }
+ if want := []string{"PUT /api/cli/apps/bab/shop/swap", "PUT /api/cli/apps/bab/shop/updates"}; !reflect.DeepEqual(d.got(), want) {
+ t.Fatalf("doors = %v", d.got())
+ }
+ swapLine := fmt.Sprintf(appsSetDone+"\n", "shop", "hold", 2)
+ policyLine := fmt.Sprintf(updatewords.Text("settings.cli.apps-set-policy-done")+"\n", "shop", "updates, pin", "manual", "stable", "3")
+ if !strings.HasPrefix(out.String(), swapLine) || !strings.HasSuffix(out.String(), policyLine) {
+ t.Fatalf("stdout = %q", out.String())
+ }
+}
+
+// A refused swap stops the run: the policy door is not tried after it.
+func TestAppsSetRunSwapFailureSkipsPolicy(t *testing.T) {
+ var d doorLog
+ ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ d.add(r.Method + " " + r.URL.Path)
+ w.WriteHeader(http.StatusConflict)
+ io.WriteString(w, `{"error":"no"}`)
+ }))
+ defer ts.Close()
+ hold := "hold"
+ err := appsSetRun(context.Background(), newTestSession(ts.URL), apiAcct{Seg: "bab"}, "shop",
+ &swapPolicyReq{Swap: &hold}, updatePolicyReq{"default_major": 3})
+ if err == nil || err.Error() != fmt.Sprintf(appsSetError, errors.New("no")) {
+ t.Fatalf("err = %v", err)
+ }
+ if len(d.got()) != 1 {
+ t.Fatalf("doors = %v", d.got())
+ }
+}
+
+func TestAppsSetRunPolicyFailureCarriesTheConsoleWords(t *testing.T) {
+ ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.WriteHeader(http.StatusConflict)
+ io.WriteString(w, `{"error":"the console says no"}`)
+ }))
+ defer ts.Close()
+ err := appsSetRun(context.Background(), newTestSession(ts.URL), apiAcct{Seg: "bab"}, "shop", nil, updatePolicyReq{"default_channel": ""})
+ if err == nil || err.Error() != "apps set: the console says no" {
+ t.Fatalf("err = %v", err)
+ }
+}
+
+func TestAppsSetRunNothingToSet(t *testing.T) {
+ var d doorLog
+ err := appsSetRun(context.Background(), appsSetConsole(t, &d), apiAcct{Seg: "bab"}, "shop", nil, nil)
+ if err == nil || err.Error() != appsSetNothing {
+ t.Fatalf("err = %v", err)
+ }
+ if len(d.got()) != 0 {
+ t.Fatalf("a request went out: %v", d.got())
+ }
+}