carlos / platform Public

cli: apps set --egress-allow/--size, instances set --size, apps show (#328, #329)

The operator policy door from the command line. A write reads the policy
and sends its ETag back (If-Match, or If-None-Match: * when there is none);
a 412 is said plainly and never retried. --egress-allow is normalized
client-side ("" clears it), --size is a size name or default. apps show
prints the allow, the default size and each instance's own size, marking
one whose instance was deleted or recreated.

sendJSON now runs through sendJSONHeader, which adds request headers and
hands back the response's.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev 58361a9f9d67994e00a05887f5facbd76a73e96a parent b681db6
9 files changed, +702 −32
  • apimode.go +19 −6
  • apps.go +5 −1
  • apps_policy.go +304 −0
  • apps_policy_test.go +322 −0
  • apps_swap.go +15 −7
  • apps_updates.go +7 −3
  • apps_updates_test.go +7 −7
  • instances_updates.go +22 −7
  • instances_updates_test.go +1 −1
diff --git a/apimode.go b/apimode.go
index 87244c546..9409fb48b 100644
--- a/apimode.go
+++ b/apimode.go
@@ -864,13 +864,26 @@ func (s *apiSession) getJSONHeader(ctx context.Context, path string) (int, http.
// which is why this returns the raw body rather than pre-deciding what
// counts as an error.
func (s *apiSession) sendJSON(ctx context.Context, method, path string, in any) (int, []byte, error) {
+ status, _, body, err := s.sendJSONHeader(ctx, method, path, in, nil)
+ return status, body, err
+}
+
+// sendJSONHeader is sendJSON with request headers added (hdr, which may be
+// nil) and the response headers handed back, for the door that compares and
+// swaps on an ETag (the app policy door's If-Match).
+func (s *apiSession) sendJSONHeader(ctx context.Context, method, path string, in any, hdr http.Header) (int, http.Header, []byte, error) {
raw, err := json.Marshal(in)
if err != nil {
- return 0, nil, err
+ return 0, nil, nil, err
}
req, err := http.NewRequestWithContext(ctx, method, s.c.base+path, bytes.NewReader(raw))
if err != nil {
- return 0, nil, err
+ return 0, nil, nil, err
+ }
+ for k, vs := range hdr {
+ for _, v := range vs {
+ req.Header.Add(k, v)
+ }
}
req.Header.Set("Content-Type", "application/json")
if s.c.token != "" {
@@ -878,17 +891,17 @@ func (s *apiSession) sendJSON(ctx context.Context, method, path string, in any)
}
res, err := s.c.httpc.Do(req)
if err != nil {
- return 0, nil, err
+ return 0, nil, nil, err
}
defer res.Body.Close()
if merr := consoleMoved(s.c.base, res.StatusCode, res.Header.Get("Location")); merr != nil {
- return res.StatusCode, nil, offerConsoleMoved(merr)
+ return res.StatusCode, res.Header, nil, offerConsoleMoved(merr)
}
body, err := io.ReadAll(io.LimitReader(res.Body, 1<<20))
if err != nil {
- return res.StatusCode, nil, err
+ return res.StatusCode, res.Header, nil, err
}
- return res.StatusCode, body, nil
+ return res.StatusCode, res.Header, body, nil
}
// postJSON is sendJSON pinned to POST — the console door shape a create or
diff --git a/apps.go b/apps.go
index 48246d3e6..22fd90452 100644
--- a/apps.go
+++ b/apps.go
@@ -34,7 +34,7 @@ import (
"github.com/carlosframework/platform/internal/release"
)
-const appsUsage = `usage: carlos apps <create|place|build-probe|set|delete|restore|ttl|keep|list> [flags] <app>
+const appsUsage = `usage: carlos apps <create|place|build-probe|set|show|delete|restore|ttl|keep|list> [flags] <app>
create --app <name> [flags] claim a new app name in an account, through
the console API — the same claim the console's New app
@@ -86,6 +86,8 @@ const appsUsage = `usage: carlos apps <create|place|build-probe|set|delete|resto
set <app> [--swap overlap|hold] [--swap-concurrency <n>] how the app's
running instances take a new build: see carlos apps set -h.
+ show <app> the app's egress allow and sizes. System operators only.
+
delete [flags] <app> move an app to the Trash: every route drops
(custom domains included), its name stays reserved, and it
is restorable for 30 days, or 3 for a temporary app. Refused
@@ -153,6 +155,8 @@ func RunApps(args []string) {
runAppsBuildProbe(args[1:])
case "set":
runAppsSet(args[1:])
+ case "show":
+ runAppsShow(args[1:])
case "delete":
runAppsDelete(args[1:])
case "restore":
diff --git a/apps_policy.go b/apps_policy.go
new file mode 100644
index 000000000..cddd536a4
--- /dev/null
+++ b/apps_policy.go
@@ -0,0 +1,304 @@
+package main
+
+// The app policy from the command line (instance sizes and egress spec §2,
+// #328, #329): `carlos apps set --egress-allow/--size`, `carlos instances set
+// --size` and `carlos apps show`, all through the console's operator door,
+// GET and PUT /api/cli/apps/{account}/{app}/policy (internal/console/
+// apipolicy.go). The door is for system operators only, and console-only:
+// there is no direct-bucket form.
+//
+// A write reads the policy first and sends its ETag back (If-Match, or
+// If-None-Match: * when there is no object), so a change made by someone
+// else in between is refused with 412. That is said, never retried here: the
+// operator decides on the policy as it now stands.
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "flag"
+ "fmt"
+ "log"
+ "net/http"
+ "sort"
+ "strings"
+
+ "github.com/carlosframework/platform/internal/cgroup"
+ "github.com/carlosframework/platform/internal/egressaddr"
+)
+
+// Copy (collected for review in plan Task 15).
+const (
+ appsSetFlagEgress = `private addresses every instance of the app may reach, comma-separated, never a port. "" clears them. System operators only`
+ appsSetFlagSize = "the size the app's instances run at: small, standard, large or xlarge. default goes back to the build's size. System operators only"
+ instancesSetFlagSize = "this instance's size: small, standard, large or xlarge. default follows the app's size. System operators only"
+ appsSetPolicyUsage = `Only system operators may pass --egress-allow or --size. A change reaches
+the app's boxes within seconds once they run a release that applies it.`
+ appsSetBadEgress = "apps set: --egress-allow: %v"
+ appsSetBadSize = "apps set: --size is small, standard, large, xlarge or default, not %q"
+ instancesSetBadSize = "instances set: --size is small, standard, large, xlarge or default, not %q"
+ appsPolicyOldConsole = "not found: either %s is not an app in this account, you are not a system operator, or this console predates app policies (deploy a console built from current main)"
+ appsPolicyChanged = "the policy changed while this ran. Run it again"
+ appsSetEgressDone = "%s instances may reach %s"
+ appsSetEgressCleared = "%s instances have no egress allow"
+ appsSetSizeDone = "%s instances run at %s unless an instance has its own size"
+ appsSetSizeCleared = "%s instances run at the build's size unless an instance has its own size"
+ instancesSetSizeDone = "%s runs at %s"
+ instancesSetSizeDefault = "%s follows the app's size"
+ appsShowUsage = "usage: carlos apps show <app> [--account <sqid|name>] [--account-id <sqid>] [--console <origin>]"
+ appsShowEgress = "egress allow %s"
+ appsShowDefault = "default size %s"
+ appsShowOverride = "size %s %s"
+ appsShowNone = "none"
+ appsShowBuildSize = "the build's size"
+ appsShowStale = " (not in force. The instance was deleted or recreated)"
+ appsShowDirectMode = "apps show works through a console, and this shell is in direct bucket mode (CARLOS_DEPLOYMENT_BUCKET or CARLOS_DEPLOYMENT_DIR is set). Log in instead: carlos auth login --console <url>"
+)
+
+// policySizeDefault is the size value that removes an instance's override
+// (the door's policySizeValueDefault), and the --size word for "no size of
+// its own" on both verbs.
+const policySizeDefault = "default"
+
+// policyChange is the door's PUT body (policyPutReq): an absent field is left
+// as it is; an empty allow clears it; "" clears the app's default size; a
+// size value of "default" removes that host's override.
+type policyChange struct {
+ EgressAllow *[]string `json:"egress_allow,omitempty"`
+ DefaultSize *string `json:"default_size,omitempty"`
+ Size *policySize `json:"size,omitempty"`
+}
+
+type policySize struct {
+ Host string `json:"host"`
+ Value string `json:"value"`
+}
+
+// apiAppPolicy is the door's reply (policyReply): the policy, its ETag (""
+// when there is no object), the egress addresses the door could not check,
+// and the overrides whose instance is gone or was recreated.
+type apiAppPolicy struct {
+ Policy apiPolicyBody `json:"policy"`
+ ETag string `json:"etag"`
+ NotChecked []string `json:"not_checked"`
+ Stale []string `json:"stale_overrides"`
+}
+
+// apiPolicyBody is the part of the policy object (apppolicy.Policy) the CLI
+// shows.
+type apiPolicyBody struct {
+ EgressAllow []string `json:"egress_allow"`
+ DefaultSize string `json:"default_size"`
+ Sizes map[string]apiSizeOverride `json:"sizes"`
+}
+
+type apiSizeOverride struct {
+ Size string `json:"size"`
+}
+
+// appsPolicyChangeFrom builds `carlos apps set`'s policy change from the
+// flags given, checked client-side so an address or size the door is
+// certain to refuse costs no round trip. It returns nil when neither flag
+// was given.
+func appsPolicyChangeFrom(fs *flag.FlagSet, egress, size string) (*policyChange, error) {
+ var c policyChange
+ if flagWasSet(fs, "egress-allow") {
+ norm, err := egressaddr.Normalize(strings.Split(egress, ","))
+ if err != nil {
+ return nil, fmt.Errorf(appsSetBadEgress, err)
+ }
+ if norm == nil {
+ norm = []string{} // [] clears; null would leave the allow as it is
+ }
+ c.EgressAllow = &norm
+ }
+ if flagWasSet(fs, "size") {
+ v := strings.TrimSpace(size)
+ if v == policySizeDefault {
+ v = ""
+ } else if _, ok := cgroup.SizeByName(v); !ok {
+ return nil, fmt.Errorf(appsSetBadSize, size)
+ }
+ c.DefaultSize = &v
+ }
+ if c.EgressAllow == nil && c.DefaultSize == nil {
+ return nil, nil
+ }
+ return &c, nil
+}
+
+// instancesSizeFrom checks `carlos instances set --size` client-side: a
+// size name, or default to follow the app's size.
+func instancesSizeFrom(size string) (string, error) {
+ v := strings.TrimSpace(size)
+ if v == policySizeDefault {
+ return v, nil
+ }
+ if _, ok := cgroup.SizeByName(v); !ok {
+ return "", fmt.Errorf(instancesSetBadSize, size)
+ }
+ return v, nil
+}
+
+// policyReplyFrom reads the door's answer. A 404 with no JSON error is an
+// unknown app, a caller who is not a system operator, or a console from
+// before the door, and the message names all three; a 412 is a change made
+// by someone else since the read.
+func policyReplyFrom(app string, status int, body []byte) (apiAppPolicy, error) {
+ switch {
+ case status == http.StatusNotFound && !hasJSONError(body):
+ return apiAppPolicy{}, fmt.Errorf(appsPolicyOldConsole, app)
+ case status == http.StatusPreconditionFailed:
+ return apiAppPolicy{}, errors.New(appsPolicyChanged)
+ case status != http.StatusOK:
+ return apiAppPolicy{}, apiErrorFromBody(status, body)
+ }
+ var out apiAppPolicy
+ if err := json.Unmarshal(body, &out); err != nil {
+ return apiAppPolicy{}, fmt.Errorf("unreadable policy reply: %w", err)
+ }
+ return out, nil
+}
+
+// getAppPolicy is the door's GET: the policy a write would build on now.
+func (s *apiSession) getAppPolicy(ctx context.Context, a apiAcct, app string) (apiAppPolicy, error) {
+ status, body, err := s.getJSON(ctx, apiPath(a, app, "/policy"))
+ if err != nil {
+ return apiAppPolicy{}, err
+ }
+ return policyReplyFrom(app, status, body)
+}
+
+// changeAppPolicy reads the policy, then writes c on the ETag it read. A
+// 412 comes back as appsPolicyChanged and is not retried.
+func (s *apiSession) changeAppPolicy(ctx context.Context, a apiAcct, app string, c policyChange) (apiAppPolicy, error) {
+ cur, err := s.getAppPolicy(ctx, a, app)
+ if err != nil {
+ return apiAppPolicy{}, err
+ }
+ hdr := http.Header{}
+ if cur.ETag == "" {
+ hdr.Set("If-None-Match", "*")
+ } else {
+ hdr.Set("If-Match", cur.ETag)
+ }
+ status, _, body, err := s.sendJSONHeader(ctx, http.MethodPut, apiPath(a, app, "/policy"), c, hdr)
+ if err != nil {
+ return apiAppPolicy{}, err
+ }
+ return policyReplyFrom(app, status, body)
+}
+
+// appsSetPolicyRun is the policy part of `carlos apps set`: the change, then
+// one line for each field it carried, from what the console stored.
+func appsSetPolicyRun(ctx context.Context, sess *apiSession, acct apiAcct, app string, c policyChange) error {
+ got, err := sess.changeAppPolicy(ctx, acct, app, c)
+ if err != nil {
+ return fmt.Errorf(appsSetError, err)
+ }
+ if c.EgressAllow != nil {
+ if len(got.Policy.EgressAllow) == 0 {
+ fmt.Fprintf(stdout, appsSetEgressCleared+"\n", app)
+ } else {
+ fmt.Fprintf(stdout, appsSetEgressDone+"\n", app, strings.Join(got.Policy.EgressAllow, ", "))
+ }
+ }
+ if c.DefaultSize != nil {
+ if got.Policy.DefaultSize == "" {
+ fmt.Fprintf(stdout, appsSetSizeCleared+"\n", app)
+ } else {
+ fmt.Fprintf(stdout, appsSetSizeDone+"\n", app, got.Policy.DefaultSize)
+ }
+ }
+ return nil
+}
+
+// instancesSetSizeRun is the size part of `carlos instances set`: one host's
+// override through the app's policy, and a line from what the console
+// stored.
+func instancesSetSizeRun(ctx context.Context, sess *apiSession, acct apiAcct, app, host, size string) error {
+ got, err := sess.changeAppPolicy(ctx, acct, app, policyChange{Size: &policySize{Host: host, Value: size}})
+ if err != nil {
+ return fmt.Errorf("instances set: %v", err)
+ }
+ if o, ok := got.Policy.Sizes[host]; ok {
+ fmt.Fprintf(stdout, instancesSetSizeDone+"\n", host, o.Size)
+ } else {
+ fmt.Fprintf(stdout, instancesSetSizeDefault+"\n", host)
+ }
+ return nil
+}
+
+// runAppsShow is `carlos apps show <app>`: the operator's view of the app's
+// policy.
+func runAppsShow(args []string) {
+ lead, args := leadingArg(args)
+ fs := flag.NewFlagSet("apps show", flag.ExitOnError)
+ 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")
+ consoleFlag := consoleOption(fs, "console to act through (default: the only logged-in one)")
+ fs.Usage = flagUsage(fs, appsShowUsage)
+ fs.Parse(args)
+ app := lead
+ switch {
+ case app == "" && fs.NArg() == 1:
+ app = strings.TrimSpace(fs.Arg(0))
+ case fs.NArg() != 0:
+ log.Fatal(appsShowUsage)
+ }
+ if app == "" {
+ log.Fatal(appsShowUsage)
+ }
+ if err := validAppName(app); err != nil {
+ log.Fatal(err)
+ }
+ sess, err := resolveMode(*consoleFlag)
+ if err != nil {
+ log.Fatal(err)
+ }
+ if sess == nil {
+ log.Fatal(appsShowDirectMode)
+ }
+ acct := resolvedAccount(sess, acctRaw, acctIDRaw)
+ if err := appsShowRun(context.Background(), sess, acct, app); err != nil {
+ log.Fatal(err)
+ }
+}
+
+// appsShowRun prints the policy: the egress allow, the app's default size,
+// and each instance's own size, sorted by host, marking an override whose
+// instance is gone or was recreated.
+func appsShowRun(ctx context.Context, sess *apiSession, acct apiAcct, app string) error {
+ got, err := sess.getAppPolicy(ctx, acct, app)
+ if err != nil {
+ return err
+ }
+ p := got.Policy
+ egress := appsShowNone
+ if len(p.EgressAllow) > 0 {
+ egress = strings.Join(p.EgressAllow, ", ")
+ }
+ fmt.Fprintf(stdout, appsShowEgress+"\n", egress)
+ def := appsShowBuildSize
+ if p.DefaultSize != "" {
+ def = p.DefaultSize
+ }
+ fmt.Fprintf(stdout, appsShowDefault+"\n", def)
+ stale := map[string]bool{}
+ for _, h := range got.Stale {
+ stale[h] = true
+ }
+ hosts := make([]string, 0, len(p.Sizes))
+ for h := range p.Sizes {
+ hosts = append(hosts, h)
+ }
+ sort.Strings(hosts)
+ for _, h := range hosts {
+ line := fmt.Sprintf(appsShowOverride, h, p.Sizes[h].Size)
+ if stale[h] {
+ line += appsShowStale
+ }
+ fmt.Fprintln(stdout, line)
+ }
+ return nil
+}
diff --git a/apps_policy_test.go b/apps_policy_test.go
new file mode 100644
index 000000000..9a7bf0062
--- /dev/null
+++ b/apps_policy_test.go
@@ -0,0 +1,322 @@
+package main
+
+import (
+ "context"
+ "flag"
+ "fmt"
+ "io"
+ "net/http"
+ "net/http/httptest"
+ "reflect"
+ "strings"
+ "sync"
+ "testing"
+)
+
+// policyChangeFor parses --egress-allow and --size as `carlos apps set` does
+// and builds the change from them.
+func policyChangeFor(t *testing.T, args ...string) (*policyChange, error) {
+ t.Helper()
+ fs := flag.NewFlagSet("t", flag.ContinueOnError)
+ egress := fs.String("egress-allow", "", "")
+ size := fs.String("size", "", "")
+ if err := fs.Parse(args); err != nil {
+ t.Fatal(err)
+ }
+ return appsPolicyChangeFrom(fs, *egress, *size)
+}
+
+// policyConsole is a fake console's policy door. getBody is the GET's
+// answer (getStatus when set); putStatus/putBody the PUT's.
+type policyConsole struct {
+ getStatus int
+ getBody string
+ putStatus int
+ putBody string
+
+ mu sync.Mutex
+ calls []string
+ puts []string
+ ifMatch []string
+ ifNoneMt []string
+}
+
+func (c *policyConsole) session(t *testing.T) *apiSession {
+ t.Helper()
+ ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ b, _ := io.ReadAll(r.Body)
+ c.mu.Lock()
+ c.calls = append(c.calls, r.Method+" "+r.URL.Path)
+ c.mu.Unlock()
+ answer := func(status int, body string) {
+ if status == 0 {
+ status = http.StatusOK
+ }
+ if strings.HasPrefix(body, "{") {
+ w.Header().Set("Content-Type", "application/json")
+ }
+ w.WriteHeader(status)
+ io.WriteString(w, body)
+ }
+ if !strings.HasSuffix(r.URL.Path, "/policy") {
+ w.WriteHeader(http.StatusNotFound)
+ return
+ }
+ switch r.Method {
+ case http.MethodGet:
+ answer(c.getStatus, c.getBody)
+ case http.MethodPut:
+ c.mu.Lock()
+ c.puts = append(c.puts, string(b))
+ c.ifMatch = append(c.ifMatch, r.Header.Get("If-Match"))
+ c.ifNoneMt = append(c.ifNoneMt, r.Header.Get("If-None-Match"))
+ c.mu.Unlock()
+ answer(c.putStatus, c.putBody)
+ default:
+ w.WriteHeader(http.StatusMethodNotAllowed)
+ }
+ }))
+ t.Cleanup(ts.Close)
+ return newTestSession(ts.URL)
+}
+
+func TestAppsSetPolicyRefusesClientSide(t *testing.T) {
+ cases := []struct {
+ args []string
+ want string
+ }{
+ {[]string{"--egress-allow", "10.0.5.20:5432"}, "has a port"},
+ {[]string{"--egress-allow", "10.0.5.0/24"}, "is a range"},
+ {[]string{"--egress-allow", "8.8.8.8"}, "already reachable"},
+ {[]string{"--egress-allow", "10.0.5.20,8.8.8.8"}, "already reachable"},
+ }
+ for _, c := range cases {
+ got, err := policyChangeFor(t, c.args...)
+ if err == nil || !strings.Contains(err.Error(), c.want) || !strings.HasPrefix(err.Error(), "apps set: --egress-allow: ") || got != nil {
+ t.Errorf("%v: change = %+v, err = %v; want an error containing %q", c.args, got, err, c.want)
+ }
+ }
+ for _, bad := range []string{"huge", "", "Large"} {
+ got, err := policyChangeFor(t, "--size", bad)
+ if err == nil || err.Error() != fmt.Sprintf(appsSetBadSize, bad) || got != nil {
+ t.Errorf("--size %q: change = %+v, err = %v", bad, got, err)
+ }
+ }
+ if got, err := policyChangeFor(t); got != nil || err != nil {
+ t.Errorf("no policy flag: change = %+v, err = %v; want nil, nil", got, err)
+ }
+}
+
+func TestChangeAppPolicyWireShape(t *testing.T) {
+ ctx := context.Background()
+ acct := apiAcct{Seg: "bab"}
+ reply := `{"policy":{"v":1,"egress_allow":["10.0.5.20","fd00:5::20"]},"etag":"e2","not_checked":[]}`
+
+ // No object yet: If-None-Match: *, and only the field given.
+ c := &policyConsole{getBody: `{"policy":{"v":1},"etag":"","not_checked":[]}`, putBody: reply}
+ change, err := policyChangeFor(t, "--egress-allow", "fd00:5::20, 10.0.5.20,10.0.5.20")
+ if err != nil {
+ t.Fatal(err)
+ }
+ got, err := c.session(t).changeAppPolicy(ctx, acct, "shop", *change)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if want := []string{"GET /api/cli/apps/bab/shop/policy", "PUT /api/cli/apps/bab/shop/policy"}; !reflect.DeepEqual(c.calls, want) {
+ t.Fatalf("requests = %v", c.calls)
+ }
+ if c.puts[0] != `{"egress_allow":["10.0.5.20","fd00:5::20"]}` || c.ifNoneMt[0] != "*" || c.ifMatch[0] != "" {
+ t.Fatalf("PUT %s If-Match %q If-None-Match %q", c.puts[0], c.ifMatch[0], c.ifNoneMt[0])
+ }
+ if got.ETag != "e2" || !reflect.DeepEqual(got.Policy.EgressAllow, []string{"10.0.5.20", "fd00:5::20"}) {
+ t.Fatalf("reply = %+v", got)
+ }
+
+ // An object: If-Match its etag.
+ c = &policyConsole{getBody: `{"policy":{"v":1,"revision":3},"etag":"\"e1\"","not_checked":[]}`, putBody: reply}
+ if _, err := c.session(t).changeAppPolicy(ctx, acct, "shop", *change); err != nil {
+ t.Fatal(err)
+ }
+ if c.ifMatch[0] != `"e1"` || c.ifNoneMt[0] != "" {
+ t.Fatalf("If-Match %q If-None-Match %q", c.ifMatch[0], c.ifNoneMt[0])
+ }
+
+ // Clearing: an empty allow is [], --size default is "".
+ for _, tc := range []struct {
+ args []string
+ body string
+ }{
+ {[]string{"--egress-allow", ""}, `{"egress_allow":[]}`},
+ {[]string{"--size", "default"}, `{"default_size":""}`},
+ {[]string{"--size", "large", "--egress-allow", "10.0.5.20"}, `{"egress_allow":["10.0.5.20"],"default_size":"large"}`},
+ } {
+ c = &policyConsole{getBody: `{"policy":{"v":1},"etag":"e1"}`, putBody: `{"policy":{"v":1},"etag":"e2"}`}
+ change, err := policyChangeFor(t, tc.args...)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, err := c.session(t).changeAppPolicy(ctx, acct, "shop", *change); err != nil {
+ t.Fatal(err)
+ }
+ if c.puts[0] != tc.body {
+ t.Errorf("%v: PUT %s, want %s", tc.args, c.puts[0], tc.body)
+ }
+ }
+
+ // A 412 is said plainly and not retried.
+ c = &policyConsole{getBody: `{"policy":{"v":1},"etag":"e1"}`, putStatus: http.StatusPreconditionFailed,
+ putBody: `{"error":"the policy changed since it was read. Read it again and retry"}`}
+ if _, err := c.session(t).changeAppPolicy(ctx, acct, "shop", *change); err == nil || err.Error() != appsPolicyChanged {
+ t.Fatalf("412: err = %v", err)
+ }
+ if len(c.puts) != 1 {
+ t.Fatalf("412: %d PUTs, want 1 (no silent retry)", len(c.puts))
+ }
+
+ // A bare 404 is an unknown app, a non-operator, or an older console.
+ c = &policyConsole{getStatus: http.StatusNotFound, getBody: "404 page not found\n"}
+ if _, err := c.session(t).changeAppPolicy(ctx, acct, "shop", *change); err == nil || err.Error() != fmt.Sprintf(appsPolicyOldConsole, "shop") {
+ t.Fatalf("404: err = %v", err)
+ }
+ if len(c.puts) != 0 {
+ t.Fatalf("404: a PUT went out: %v", c.puts)
+ }
+
+ // The door's own words come through as they are.
+ c = &policyConsole{getBody: `{"policy":{"v":1},"etag":""}`, putStatus: http.StatusNotFound,
+ putBody: `{"error":"no instance shop-a.example.com in this app"}`}
+ if _, err := c.session(t).changeAppPolicy(ctx, acct, "shop", *change); err == nil || err.Error() != "no instance shop-a.example.com in this app" {
+ t.Fatalf("door refusal: err = %v", err)
+ }
+}
+
+func TestInstancesSetSizeUsesThePolicyDoor(t *testing.T) {
+ out := captureStdout(t)
+ c := &policyConsole{getBody: `{"policy":{"v":1},"etag":"e1"}`,
+ putBody: `{"policy":{"v":1,"sizes":{"shop-a.example.com":{"size":"xlarge","created_at":"2026-10-21T00:00:00Z"}}},"etag":"e2"}`}
+ xl := "xlarge"
+ if err := instancesSetRun(context.Background(), c.session(t), apiAcct{Seg: "bab"}, "shop", "shop-a.example.com", nil, nil, &xl); err != nil {
+ t.Fatal(err)
+ }
+ if want := []string{"GET /api/cli/apps/bab/shop/policy", "PUT /api/cli/apps/bab/shop/policy"}; !reflect.DeepEqual(c.calls, want) {
+ t.Fatalf("requests = %v (want no updates-door request)", c.calls)
+ }
+ if c.puts[0] != `{"size":{"host":"shop-a.example.com","value":"xlarge"}}` || c.ifMatch[0] != "e1" {
+ t.Fatalf("PUT %s If-Match %q", c.puts[0], c.ifMatch[0])
+ }
+ if want := fmt.Sprintf(instancesSetSizeDone+"\n", "shop-a.example.com", "xlarge"); out.String() != want {
+ t.Fatalf("stdout = %q, want %q", out.String(), want)
+ }
+
+ out.Reset()
+ c = &policyConsole{getBody: `{"policy":{"v":1},"etag":"e1"}`, putBody: `{"policy":{"v":1},"etag":"e2"}`}
+ def := "default"
+ if err := instancesSetRun(context.Background(), c.session(t), apiAcct{Seg: "bab"}, "shop", "shop-a.example.com", nil, nil, &def); err != nil {
+ t.Fatal(err)
+ }
+ if c.puts[0] != `{"size":{"host":"shop-a.example.com","value":"default"}}` {
+ t.Fatalf("PUT %s", c.puts[0])
+ }
+ if want := fmt.Sprintf(instancesSetSizeDefault+"\n", "shop-a.example.com"); out.String() != want {
+ t.Fatalf("stdout = %q, want %q", out.String(), want)
+ }
+
+ // A refused size stops the run: the channel door is not tried after it.
+ c = &policyConsole{getBody: `{"policy":{"v":1},"etag":"e1"}`, putStatus: http.StatusPreconditionFailed, putBody: `{"error":"x"}`}
+ beta := "beta"
+ err := instancesSetRun(context.Background(), c.session(t), apiAcct{Seg: "bab"}, "shop", "shop-a.example.com", nil, &beta, &xl)
+ if err == nil || err.Error() != "instances set: "+appsPolicyChanged {
+ t.Fatalf("err = %v", err)
+ }
+ if len(c.calls) != 2 {
+ t.Fatalf("requests = %v", c.calls)
+ }
+}
+
+func TestAppsShowPrintsThePolicy(t *testing.T) {
+ out := captureStdout(t)
+ c := &policyConsole{getBody: `{"policy":{"v":1,"revision":4,"egress_allow":["10.0.5.20"],"default_size":"large",` +
+ `"sizes":{"shop-b.example.com":{"size":"small","created_at":"2026-10-20T00:00:00Z"},` +
+ `"shop-a.example.com":{"size":"xlarge","created_at":"2026-10-21T00:00:00Z"}}},` +
+ `"etag":"e1","not_checked":[],"stale_overrides":["shop-b.example.com"]}`}
+ if err := appsShowRun(context.Background(), c.session(t), apiAcct{Seg: "bab"}, "shop"); err != nil {
+ t.Fatal(err)
+ }
+ want := "egress allow 10.0.5.20\n" +
+ "default size large\n" +
+ "size shop-a.example.com xlarge\n" +
+ "size shop-b.example.com small" + appsShowStale + "\n"
+ if out.String() != want {
+ t.Fatalf("stdout =\n%s\nwant\n%s", out.String(), want)
+ }
+ if want := []string{"GET /api/cli/apps/bab/shop/policy"}; !reflect.DeepEqual(c.calls, want) {
+ t.Fatalf("requests = %v", c.calls)
+ }
+
+ out.Reset()
+ c = &policyConsole{getBody: `{"policy":{"v":1},"etag":"","not_checked":[]}`}
+ if err := appsShowRun(context.Background(), c.session(t), apiAcct{Seg: "bab"}, "shop"); err != nil {
+ t.Fatal(err)
+ }
+ if want := "egress allow none\ndefault size the build's size\n"; out.String() != want {
+ t.Fatalf("stdout = %q, want %q", out.String(), want)
+ }
+
+ c = &policyConsole{getStatus: http.StatusNotFound, getBody: "404 page not found\n"}
+ if err := appsShowRun(context.Background(), c.session(t), apiAcct{Seg: "bab"}, "shop"); err == nil || err.Error() != fmt.Sprintf(appsPolicyOldConsole, "shop") {
+ t.Fatalf("404: err = %v", err)
+ }
+}
+
+func TestAppsSetWithoutPolicyFlagsSendsNoPolicyRequest(t *testing.T) {
+ captureStdout(t)
+ var d doorLog
+ hold := "hold"
+ if err := appsSetRun(context.Background(), appsSetConsole(t, &d), apiAcct{Seg: "bab"}, "shop", &swapPolicyReq{Swap: &hold}, nil, 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())
+ }
+}
+
+// The policy change comes after the swap and update policy changes, one line
+// for each field it carried, from what the console stored.
+func TestAppsSetRunPolicyChangePrintsWhatTheConsoleStored(t *testing.T) {
+ out := captureStdout(t)
+ c := &policyConsole{getBody: `{"policy":{"v":1},"etag":""}`,
+ putBody: `{"policy":{"v":1,"egress_allow":["10.0.5.20","fd00:5::20"],"default_size":"large"},"etag":"e1"}`}
+ change, err := policyChangeFor(t, "--egress-allow", "10.0.5.20,fd00:5::20", "--size", "large")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := appsSetRun(context.Background(), c.session(t), apiAcct{Seg: "bab"}, "shop", nil, nil, change); err != nil {
+ t.Fatal(err)
+ }
+ want := fmt.Sprintf(appsSetEgressDone+"\n", "shop", "10.0.5.20, fd00:5::20") + fmt.Sprintf(appsSetSizeDone+"\n", "shop", "large")
+ if out.String() != want {
+ t.Fatalf("stdout = %q, want %q", out.String(), want)
+ }
+
+ out.Reset()
+ c = &policyConsole{getBody: `{"policy":{"v":1,"egress_allow":["10.0.5.20"],"default_size":"large"},"etag":"e1"}`,
+ putBody: `{"policy":{"v":1},"etag":"e2"}`}
+ change, err = policyChangeFor(t, "--egress-allow", "", "--size", "default")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := appsSetRun(context.Background(), c.session(t), apiAcct{Seg: "bab"}, "shop", nil, nil, change); err != nil {
+ t.Fatal(err)
+ }
+ want = fmt.Sprintf(appsSetEgressCleared+"\n", "shop") + fmt.Sprintf(appsSetSizeCleared+"\n", "shop")
+ if out.String() != want {
+ t.Fatalf("stdout = %q, want %q", out.String(), want)
+ }
+
+ // A refused change is the console's words under the verb.
+ c = &policyConsole{getBody: `{"policy":{"v":1},"etag":"e1"}`, putStatus: http.StatusPreconditionFailed, putBody: `{"error":"x"}`}
+ err = appsSetRun(context.Background(), c.session(t), apiAcct{Seg: "bab"}, "shop", nil, nil, change)
+ if err == nil || err.Error() != fmt.Sprintf(appsSetError, appsPolicyChanged) {
+ t.Fatalf("err = %v", err)
+ }
+}
diff --git a/apps_swap.go b/apps_swap.go
index d2126754c..2ae2cd223 100644
--- a/apps_swap.go
+++ b/apps_swap.go
@@ -1,8 +1,10 @@
package main
-// `carlos apps set`: the app's swap policy (zero-downtime spec §4.1, §9.1).
-// Console-only, like build-probe: the door is person-only (apiswap.go), and
-// there is no direct-bucket form.
+// `carlos apps set`: the app's swap policy (zero-downtime spec §4.1, §9.1),
+// its update policy (apps_updates.go) and, for system operators, its egress
+// allow and default size (apps_policy.go). Console-only, like build-probe:
+// the doors are person-only (apiswap.go, apipolicy.go), and there is no
+// direct-bucket form.
import (
"context"
@@ -36,7 +38,7 @@ const (
appsSetFlagApp = "app whose swap policy to set"
appsSetFlagSwap = "overlap or hold: how a running instance takes a new build"
appsSetFlagConcurrency = "how many instances of the app may swap at once on one box (1 or more)"
- appsSetNothing = "apps set: nothing to set. Pass --swap, --swap-concurrency, or both"
+ appsSetNothing = "apps set: nothing to set. Pass --swap, --swap-concurrency, --egress-allow or --size"
appsSetBadSwap = "apps set: --swap is overlap or hold, not %q"
appsSetBadConcurrency = "apps set: --swap-concurrency must be 1 or more"
appsSetTwoApps = "apps set: name the app once, either first or with --app"
@@ -135,8 +137,10 @@ func runAppsSet(args []string) {
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"))
+ egress := fs.String("egress-allow", "", appsSetFlagEgress)
+ size := fs.String("size", "", appsSetFlagSize)
consoleFlag := consoleOption(fs, "console to act through (default: the only logged-in one)")
- fs.Usage = flagUsage(fs, appsSetUsage+"\n\n"+updatewords.Text("settings.cli.apps-set-usage"))
+ fs.Usage = flagUsage(fs, appsSetUsage+"\n\n"+updatewords.Text("settings.cli.apps-set-usage")+"\n\n"+appsSetPolicyUsage)
fs.Parse(args)
app, err := appsSetApp(lead, *appF, fs.Args())
if err != nil {
@@ -157,7 +161,11 @@ func runAppsSet(args []string) {
if err != nil {
log.Fatal(err)
}
- if swapReq == nil && policy == nil {
+ change, err := appsPolicyChangeFrom(fs, *egress, *size)
+ if err != nil {
+ log.Fatal(err)
+ }
+ if swapReq == nil && policy == nil && change == nil {
log.Fatal(appsSetNothing)
}
sess, err := resolveMode(*consoleFlag)
@@ -168,7 +176,7 @@ func runAppsSet(args []string) {
log.Fatal(appsSetDirectMode)
}
acct := resolvedAccount(sess, acctRaw, acctIDRaw)
- if err := appsSetRun(context.Background(), sess, acct, app, swapReq, policy); err != nil {
+ if err := appsSetRun(context.Background(), sess, acct, app, swapReq, policy, change); err != nil {
log.Fatal(err)
}
}
diff --git a/apps_updates.go b/apps_updates.go
index cd3415e89..96e9ce572 100644
--- a/apps_updates.go
+++ b/apps_updates.go
@@ -87,9 +87,10 @@ func (s *apiSession) setUpdatePolicy(ctx context.Context, a apiAcct, app string,
// 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 {
+// door when a policy flag was, then the app policy door when --egress-allow
+// or --size was, with lines for each. The first failure stops it.
+func appsSetRun(ctx context.Context, sess *apiSession, acct apiAcct, app string, swap *swapPolicyReq, policy updatePolicyReq, change *policyChange) error {
+ if swap == nil && policy == nil && change == nil {
return errors.New(appsSetNothing)
}
if swap != nil {
@@ -131,5 +132,8 @@ func appsSetRun(ctx context.Context, sess *apiSession, acct apiAcct, app string,
fmt.Fprintf(stdout, updatewords.Text("settings.cli.apps-set-policy-done")+"\n", app,
or(strings.Join(got.InstanceControl, ", ")), updates, or(got.DefaultChannel), major)
}
+ if change != nil {
+ return appsSetPolicyRun(ctx, sess, acct, app, *change)
+ }
return nil
}
diff --git a/apps_updates_test.go b/apps_updates_test.go
index df1a79e84..afa786341 100644
--- a/apps_updates_test.go
+++ b/apps_updates_test.go
@@ -164,7 +164,7 @@ 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 {
+ if err := appsSetRun(context.Background(), appsSetConsole(t, &d), apiAcct{Seg: "bab"}, "shop", nil, policy, nil); err != nil {
t.Fatal(err)
}
if want := []string{"PUT /api/cli/apps/bab/shop/updates"}; !reflect.DeepEqual(d.got(), want) {
@@ -180,7 +180,7 @@ 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 {
+ if err := appsSetRun(context.Background(), appsSetConsole(t, &d), apiAcct{Seg: "bab"}, "shop", &swapPolicyReq{Swap: &hold}, nil, nil); err != nil {
t.Fatal(err)
}
if want := []string{"PUT /api/cli/apps/bab/shop/swap"}; !reflect.DeepEqual(d.got(), want) {
@@ -196,7 +196,7 @@ func TestAppsSetRunBothCallSwapThenUpdates(t *testing.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 {
+ &swapPolicyReq{Swap: &hold}, updatePolicyReq{"default_major": 3}, nil); 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) {
@@ -220,7 +220,7 @@ func TestAppsSetRunSwapFailureSkipsPolicy(t *testing.T) {
defer ts.Close()
hold := "hold"
err := appsSetRun(context.Background(), newTestSession(ts.URL), apiAcct{Seg: "bab"}, "shop",
- &swapPolicyReq{Swap: &hold}, updatePolicyReq{"default_major": 3})
+ &swapPolicyReq{Swap: &hold}, updatePolicyReq{"default_major": 3}, nil)
if err == nil || err.Error() != fmt.Sprintf(appsSetError, errors.New("no")) {
t.Fatalf("err = %v", err)
}
@@ -235,7 +235,7 @@ func TestAppsSetRunPolicyFailureCarriesTheConsoleWords(t *testing.T) {
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": ""})
+ err := appsSetRun(context.Background(), newTestSession(ts.URL), apiAcct{Seg: "bab"}, "shop", nil, updatePolicyReq{"default_channel": ""}, nil)
if err == nil || err.Error() != "apps set: the console says no" {
t.Fatalf("err = %v", err)
}
@@ -243,7 +243,7 @@ func TestAppsSetRunPolicyFailureCarriesTheConsoleWords(t *testing.T) {
func TestAppsSetRunNothingToSet(t *testing.T) {
var d doorLog
- err := appsSetRun(context.Background(), appsSetConsole(t, &d), apiAcct{Seg: "bab"}, "shop", nil, nil)
+ err := appsSetRun(context.Background(), appsSetConsole(t, &d), apiAcct{Seg: "bab"}, "shop", nil, nil, nil)
if err == nil || err.Error() != appsSetNothing {
t.Fatalf("err = %v", err)
}
@@ -262,7 +262,7 @@ func TestAppsSetPolicyDoneSaysAutoForUnsetDefaultUpdates(t *testing.T) {
}))
t.Cleanup(ts.Close)
out := captureStdout(t)
- if err := appsSetRun(context.Background(), newTestSession(ts.URL), apiAcct{Seg: "bab"}, "shop", nil, updatePolicyReq{"default_updates": ""}); err != nil {
+ if err := appsSetRun(context.Background(), newTestSession(ts.URL), apiAcct{Seg: "bab"}, "shop", nil, updatePolicyReq{"default_updates": ""}, nil); err != nil {
t.Fatal(err)
}
none := updatewords.Text("settings.cli.none")
diff --git a/instances_updates.go b/instances_updates.go
index e7055e50d..ff92d21ce 100644
--- a/instances_updates.go
+++ b/instances_updates.go
@@ -92,7 +92,7 @@ func instancesDirectMode(verb, consoleFlag string) *apiSession {
}
// runInstancesSet is `carlos instances set <host> [--updates …] [--channel …]
-// [--major …] [--pin …]`. The host may come first or after the flags.
+// [--major …] [--pin …] [--size …]`. The host may come first or after the flags.
func runInstancesSet(args []string) {
host, args := leadingArg(args)
fs := flag.NewFlagSet("instances set", flag.ExitOnError)
@@ -101,6 +101,7 @@ func runInstancesSet(args []string) {
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"))
+ size := fs.String("size", "", instancesSetFlagSize)
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"))
@@ -131,24 +132,33 @@ func runInstancesSet(args []string) {
}
channelTo = &c
}
- if len(req) == 0 && channelTo == nil {
+ var sizeTo *string
+ if flagWasSet(fs, "size") {
+ v, err := instancesSizeFrom(*size)
+ if err != nil {
+ log.Fatal(err)
+ }
+ sizeTo = &v
+ }
+ if len(req) == 0 && channelTo == nil && sizeTo == 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 {
+ if err := instancesSetRun(context.Background(), sess, acct, app2, host, req, channelTo, sizeTo); 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 {
+// then the app policy door for a size, then the channel door. The first
+// refusal stops it, so a channel is never tried after refused settings or a
+// refused size. 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, size *string) error {
if len(req) > 0 {
before, err := sess.getUpdates(ctx, acct, app, host)
if err != nil {
@@ -167,6 +177,11 @@ func instancesSetRun(ctx context.Context, sess *apiSession, acct apiAcct, app, h
mutedf(stdout, updatewords.Text("settings.cli.set-not-enforced"), host)
}
}
+ if size != nil {
+ if err := instancesSetSizeRun(ctx, sess, acct, app, host, *size); err != nil {
+ return err
+ }
+ }
if channel != nil {
return setInstanceChannel(ctx, sess, acct, app, host, *channel)
}
diff --git a/instances_updates_test.go b/instances_updates_test.go
index 4a6b7e107..2b3548e6f 100644
--- a/instances_updates_test.go
+++ b/instances_updates_test.go
@@ -101,7 +101,7 @@ const (
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)
+ err := instancesSetRun(context.Background(), c.session(t), apiAcct{Seg: "bab"}, "shop", testHost, req, channel, nil)
return out.String(), err
}