// 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 [flags] set -app [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 [flags] the app's feature flags as the platform serves them -account account (sqid or name; default "ops") -account-id account sqid, matched as a sqid ONLY -console 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 [-account ] [-account-id ] [-console ] key=value [key=value ...] ` const featuresListUsage = `usage: carlos features list -app [-account ] [-account-id ] [-console ] ` // 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) messageFlags := addMutationMessageFlags(fs) app := fs.String("app", "", "app whose feature flags to set") consoleFlag := consoleOption(fs, "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) message, merr := resolveMutationMessage(fs, messageFlags) if merr != nil { log.Fatalf("features set: %v", merr) } res, err := sess.setFeatures(context.Background(), acct, app2, set, unset, message) 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 := consoleOption(fs, "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 .") } return sess }