carlos / platform Public

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>
Paul Campbell pushed by paul@keymail.dev ec327e569ded83420250b10e657f7e48b7e65fe4 parent 7648d71
5 files changed, +332 −0
  • apimode.go +44 −0
  • docs/site/cli.md +18 −0
  • features_cli.go +182 −0
  • features_cli_test.go +84 −0
  • main.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