cli: carlos features set|list, and the reference entry (#347)
API mode only, retention's pattern and reason. set takes key=value words; key= with nothing after it clears the key, since the record never stores an empty string. Keys are shape-checked client-side with the console's own rule so a typo costs no round trip, and a key named twice is refused rather than last-wins.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
5 files changed,
+332
−0
apimode.go+44 −0docs/site/cli.md+18 −0features_cli.go+182 −0features_cli_test.go+84 −0main.go+4 −0
CI passed — run details
diff --git a/apimode.go b/apimode.go| index 5ee7c07b3..118bcc3de 100644 |
| --- a/apimode.go |
| +++ b/apimode.go |
| @@ -1179,6 +1179,50 @@ func (s *apiSession) scheduleRun(ctx context.Context, a apiAcct, app, name strin |
| return res, nil |
| } |
| +// apiFeaturesResult mirrors the console's features answer (apifeatures.go): |
| +// the app's map as the platform now serves it, who last changed it and |
| +// when, and — on a set — whether this call changed anything. |
| +type apiFeaturesResult struct { |
| + Account string `json:"account"` |
| + App string `json:"app"` |
| + Features map[string]string `json:"features"` |
| + SetBy string `json:"set_by"` |
| + SetAt string `json:"set_at"` |
| + Changed bool `json:"changed"` |
| +} |
| + |
| +func (s *apiSession) features(ctx context.Context, a apiAcct, app string) (apiFeaturesResult, error) { |
| + status, body, err := s.getJSON(ctx, apiPath(a, app, "/features")) |
| + return decodeFeatures(status, body, err) |
| +} |
| + |
| +func (s *apiSession) setFeatures(ctx context.Context, a apiAcct, app string, set map[string]string, unset []string) (apiFeaturesResult, error) { |
| + in := struct { |
| + Set map[string]string `json:"set,omitempty"` |
| + Unset []string `json:"unset,omitempty"` |
| + }{set, unset} |
| + status, body, err := s.postJSON(ctx, apiPath(a, app, "/features"), in) |
| + return decodeFeatures(status, body, err) |
| +} |
| + |
| +func decodeFeatures(status int, body []byte, err error) (apiFeaturesResult, error) { |
| + if err != nil { |
| + return apiFeaturesResult{}, err |
| + } |
| + if status != http.StatusOK { |
| + if status == http.StatusNotFound { |
| + return apiFeaturesResult{}, fmt.Errorf("%w — or this console predates `carlos features` "+ |
| + "(deploy a console built from current main)", apiErrorFromBody(status, body)) |
| + } |
| + return apiFeaturesResult{}, apiErrorFromBody(status, body) |
| + } |
| + var res apiFeaturesResult |
| + if err := json.Unmarshal(body, &res); err != nil { |
| + return apiFeaturesResult{}, fmt.Errorf("features: unreadable response: %w", err) |
| + } |
| + return res, nil |
| +} |
| + |
| // apiRetentionResult mirrors the console's retention answer: the policy as |
| // recorded, plus the console's own count of currently-eligible versions — |
| // computed console-side because this terminal has no bucket. Eligible is a |
diff --git a/docs/site/cli.md b/docs/site/cli.md| index 04d332c12..6749533fe 100644 |
| --- a/docs/site/cli.md |
| +++ b/docs/site/cli.md |
| @@ -350,6 +350,24 @@ that route at its next domains pass, certificate and all. The `list` verb |
| joins the claims to the fleet's own readings sweep, so DNS state, certificate |
| expiry and delegation are readable without leaving the terminal. |
| +### carlos features |
| + |
| +Sets or lists an app's own feature flags — switches the app defines, the |
| +platform stores and serves, and nobody but the app interprets. |
| + |
| +```sh |
| +carlos features set --app titogo sandbox=on |
| +carlos features list --app titogo |
| +``` |
| + |
| +Keys and values are the app's vocabulary (`sandbox=on`, `beta-ui=v2`); the |
| +platform never reads them. `key=` with nothing after the equals clears a key. |
| +The app finds the map on the `deployment` block its instance token already |
| +fetches beside `managed_domains`, so a change lands on its next poll — no |
| +restart, no environment edit, no root-owned file on an edge box. Setting is |
| +an admin-role write and every change is audited under the app's Activity; |
| +the app's own instance credential can read the flags but never set them. |
| + |
| ### carlos logs |
| The app's own stdout and stderr, merged with the platform's events about it: |
diff --git a/features_cli.go b/features_cli.go| new file mode 100644 |
| index 000000000..52f6f6b07 |
| --- /dev/null |
| +++ b/features_cli.go |
| @@ -0,0 +1,182 @@ |
| +// The `carlos features` verb family (issue #347): the app-defined switches |
| +// the platform stores and serves but never interprets — `sandbox=on` for |
| +// titogo's router — set here, audited by the console, and read by the app |
| +// off the deployment block it already fetches. API mode only, like |
| +// retention and for its reason: a switch is a console write with an audit |
| +// row, and there is no store-mode equivalent. |
| +package main |
| + |
| +import ( |
| + "context" |
| + "flag" |
| + "fmt" |
| + "log" |
| + "sort" |
| + "strings" |
| + |
| + "github.com/carlosframework/platform/internal/release" |
| +) |
| + |
| +const featuresUsage = `usage: carlos features <set|list> [flags] |
| + |
| + set -app <a> [flags] key=value [key=value ...] |
| + set the app's feature flags — keys and values are the |
| + app's own vocabulary; an empty value (key=) clears the key |
| + list -app <a> [flags] |
| + the app's feature flags as the platform serves them |
| + |
| + -account <a> account (sqid or name; default "ops") |
| + -account-id <id> account sqid, matched as a sqid ONLY |
| + -console <origin> console to act through (default: the only logged-in one) |
| + |
| +Signed-in only (carlos auth login): a switch is a console-authorized, |
| +audited write. The app reads it off the deployment block its instance token |
| +already fetches — no restart, no env edit; the app picks it up on its next |
| +poll. |
| +` |
| + |
| +const featuresSetUsage = `usage: carlos features set -app <a> [-account <a>] [-account-id <id>] [-console <origin>] key=value [key=value ...] |
| +` |
| + |
| +const featuresListUsage = `usage: carlos features list -app <a> [-account <a>] [-account-id <id>] [-console <origin>] |
| +` |
| + |
| +// runFeatures dispatches the sub-verbs. set/list are positional words, not |
| +// flags, and must never reach fs.Parse — runDomains' shape. |
| +func runFeatures(args []string) { |
| + if len(args) > 0 { |
| + switch args[0] { |
| + case "set": |
| + runFeaturesSet(args[1:]) |
| + return |
| + case "list": |
| + runFeaturesList(args[1:]) |
| + return |
| + } |
| + } |
| + log.Fatal(featuresUsage) |
| +} |
| + |
| +// parseFeatureArgs turns the positional key=value words into the door's |
| +// set map and unset list. `key=` (empty value) is an unset — the one way |
| +// to clear a switch, since the record never stores an empty string. Keys |
| +// are shape-checked here so a typo costs nothing, with the same rule the |
| +// console applies; a key named twice is refused rather than last-wins, |
| +// because "sandbox=on sandbox=off" is never what someone meant. |
| +func parseFeatureArgs(words []string) (map[string]string, []string, error) { |
| + set := map[string]string{} |
| + var unset []string |
| + seen := map[string]bool{} |
| + for _, w := range words { |
| + k, v, ok := strings.Cut(w, "=") |
| + if !ok { |
| + return nil, nil, fmt.Errorf("%q is not key=value", w) |
| + } |
| + if !release.ValidFeatureKey(k) { |
| + return nil, nil, fmt.Errorf("invalid key %q — keys are 1-64 of a-z, 0-9, '_' and '-'", k) |
| + } |
| + if seen[k] { |
| + return nil, nil, fmt.Errorf("key %q named twice", k) |
| + } |
| + seen[k] = true |
| + if v == "" { |
| + unset = append(unset, k) |
| + continue |
| + } |
| + if !release.ValidFeatureValue(v) { |
| + return nil, nil, fmt.Errorf("invalid value for %s — values are 1-256 printable characters", k) |
| + } |
| + set[k] = v |
| + } |
| + return set, unset, nil |
| +} |
| + |
| +func runFeaturesSet(args []string) { |
| + fs := flag.NewFlagSet("features set", flag.ExitOnError) |
| + app := fs.String("app", "", "app whose feature flags to set") |
| + consoleFlag := fs.String("console", "", "console to set 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") |
| + fs.Parse(args) |
| + if fs.NArg() == 0 { |
| + log.Fatal("features set: name at least one key=value\n" + featuresSetUsage) |
| + } |
| + set, unset, err := parseFeatureArgs(fs.Args()) |
| + if err != nil { |
| + log.Fatalf("features set: %v", err) |
| + } |
| + sess := requireFeaturesAPISession(*consoleFlag) |
| + printUsing(stdout, sess) |
| + acct := resolvedAccount(sess, acctRaw, acctIDRaw) |
| + app2 := resolvedApp(sess, acct, *app, featuresSetUsage) |
| + res, err := sess.setFeatures(context.Background(), acct, app2, set, unset) |
| + if err != nil { |
| + log.Fatalf("features set: %v", err) |
| + } |
| + if res.Changed { |
| + fmt.Fprintf(stdout, "%s for %s/%s\n", colorFor(stdout).ok("features updated"), res.Account, res.App) |
| + } else { |
| + fmt.Fprintf(stdout, "%s for %s/%s — nothing changed\n", colorFor(stdout).ok("features already set"), res.Account, res.App) |
| + } |
| + printFeatures(res) |
| +} |
| + |
| +func runFeaturesList(args []string) { |
| + fs := flag.NewFlagSet("features list", flag.ExitOnError) |
| + app := fs.String("app", "", "app whose feature flags to list") |
| + consoleFlag := fs.String("console", "", "console to list 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") |
| + fs.Parse(args) |
| + sess := requireFeaturesAPISession(*consoleFlag) |
| + printUsing(stdout, sess) |
| + acct := resolvedAccount(sess, acctRaw, acctIDRaw) |
| + app2 := resolvedApp(sess, acct, *app, featuresListUsage) |
| + res, err := sess.features(context.Background(), acct, app2) |
| + if err != nil { |
| + log.Fatalf("features list: %v", err) |
| + } |
| + printFeatures(res) |
| +} |
| + |
| +// printFeatures is the one rendering both verbs share, so set's echo and |
| +// list's answer can never disagree about what the platform now serves. |
| +func printFeatures(res apiFeaturesResult) { |
| + if len(res.Features) == 0 { |
| + fmt.Fprintln(stdout, "no features set") |
| + return |
| + } |
| + keys := make([]string, 0, len(res.Features)) |
| + for k := range res.Features { |
| + keys = append(keys, k) |
| + } |
| + sort.Strings(keys) |
| + w := newTable(stdout) |
| + fmt.Fprintln(w, "KEY\tVALUE") |
| + for _, k := range keys { |
| + fmt.Fprintf(w, "%s\t%s\n", k, res.Features[k]) |
| + } |
| + w.Flush() |
| + if res.SetAt != "" { |
| + fmt.Fprintf(stdout, "last changed %s", res.SetAt) |
| + if res.SetBy != "" { |
| + fmt.Fprintf(stdout, " by %s", res.SetBy) |
| + } |
| + fmt.Fprintln(stdout) |
| + } |
| +} |
| + |
| +// requireFeaturesAPISession refuses direct-bucket mode with features' OWN |
| +// message, requireRetentionAPISession's pattern and reason. |
| +func requireFeaturesAPISession(consoleFlag string) *apiSession { |
| + sess, err := resolveMode(consoleFlag) |
| + if err != nil { |
| + log.Fatal(err) |
| + } |
| + if sess == nil { |
| + log.Fatal("carlos features works through a console, and this shell is in direct bucket mode\n" + |
| + "(CARLOS_DEPLOYMENT_BUCKET/CARLOS_DEPLOYMENT_DIR is set). A feature flag is a console write with an\n" + |
| + "audit row. Unset the store env and run: carlos auth login -console <url>.") |
| + } |
| + return sess |
| +} |
diff --git a/features_cli_test.go b/features_cli_test.go| new file mode 100644 |
| index 000000000..75e4a9a83 |
| --- /dev/null |
| +++ b/features_cli_test.go |
| @@ -0,0 +1,84 @@ |
| +package main |
| + |
| +// The CLI-side gates for `carlos features` (issue #347): argument parsing |
| +// and the refusals that fail before any network call. |
| + |
| +import ( |
| + "os/exec" |
| + "reflect" |
| + "strings" |
| + "testing" |
| +) |
| + |
| +func TestParseFeatureArgs(t *testing.T) { |
| + set, unset, err := parseFeatureArgs([]string{"sandbox=on", "beta=", "limit=10 per day"}) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if want := map[string]string{"sandbox": "on", "limit": "10 per day"}; !reflect.DeepEqual(set, want) { |
| + t.Fatalf("set = %v, want %v", set, want) |
| + } |
| + if want := []string{"beta"}; !reflect.DeepEqual(unset, want) { |
| + t.Fatalf("unset = %v, want %v", unset, want) |
| + } |
| +} |
| + |
| +func TestParseFeatureArgsRefusals(t *testing.T) { |
| + for _, c := range []struct { |
| + args []string |
| + want string |
| + }{ |
| + {[]string{"sandbox"}, "key=value"}, |
| + {[]string{"=on"}, "key"}, |
| + {[]string{"Sandbox=on"}, "key"}, |
| + {[]string{"sandbox=on", "sandbox=off"}, "twice"}, |
| + {[]string{"sandbox=on", "sandbox="}, "twice"}, |
| + } { |
| + _, _, err := parseFeatureArgs(c.args) |
| + if err == nil || !strings.Contains(err.Error(), c.want) { |
| + t.Errorf("%v: err = %v, want one mentioning %q", c.args, err, c.want) |
| + } |
| + } |
| +} |
| + |
| +func runFeaturesCLI(t *testing.T, args ...string) (string, error) { |
| + t.Helper() |
| + bin := buildCarlos(t) |
| + cmd := exec.Command(bin, args...) |
| + cmd.Env = carlosTestEnv(t) |
| + cmd.Dir = t.TempDir() |
| + out, err := cmd.CombinedOutput() |
| + return string(out), err |
| +} |
| + |
| +func TestFeaturesNeedsASubVerb(t *testing.T) { |
| + out, err := runFeaturesCLI(t, "features") |
| + if err == nil { |
| + t.Fatalf("bare `features` exited 0: %s", out) |
| + } |
| + if !strings.Contains(out, "set") || !strings.Contains(out, "list") { |
| + t.Fatalf("usage should name the sub-verbs, got: %s", out) |
| + } |
| +} |
| + |
| +func TestFeaturesSetRefusesNoPairsBeforeTheNetwork(t *testing.T) { |
| + // No pairs is a usage error, refused before the session is resolved — |
| + // so it must NOT be the no-target refusal. |
| + out, err := runFeaturesCLI(t, "features", "set", "-app", "titogo") |
| + if err == nil { |
| + t.Fatalf("set with no pairs exited 0: %s", out) |
| + } |
| + if !strings.Contains(out, "key=value") || strings.Contains(out, "no deployment target") { |
| + t.Fatalf("want a key=value usage refusal, got: %s", out) |
| + } |
| +} |
| + |
| +func TestFeaturesListNeedsASession(t *testing.T) { |
| + out, err := runFeaturesCLI(t, "features", "list", "-app", "titogo") |
| + if err == nil { |
| + t.Fatalf("list with no target exited 0: %s", out) |
| + } |
| + if !strings.Contains(out, "no deployment target configured") { |
| + t.Fatalf("want the no-target refusal, got: %s", out) |
| + } |
| +} |
diff --git a/main.go b/main.go| index e1427a9d5..0d8b67ae3 100644 |
| --- a/main.go |
| +++ b/main.go |
| @@ -57,6 +57,8 @@ func main() { |
| runRoutes(args) |
| case "domains": |
| runDomains(args) |
| + case "features": |
| + runFeatures(args) |
| case "ship": |
| runShip(args) |
| case "promote": |
| @@ -182,6 +184,8 @@ Your apps — ship and run software on a CARLOS deployment |
| channel, config environment, status |
| domains a customer's own hostname attached to an app's route: |
| attach | detach | list |
| + features an app's own feature flags, stored and served by the |
| + platform, never interpreted: set | list |
| logs an app's merged log timeline: its own output plus platform |
| events (wakes, restarts, failures); -f follows live |
| ledger open ledgers: append | head | list | publish | blob | verify |