| 1 | // The `carlos features` verb family (issue #347): the app-defined switches |
| 2 | // the platform stores and serves but never interprets — `sandbox=on` for |
| 3 | // titogo's router — set here, audited by the console, and read by the app |
| 4 | // off the deployment block it already fetches. API mode only, like |
| 5 | // retention and for its reason: a switch is a console write with an audit |
| 6 | // row, and there is no store-mode equivalent. |
| 7 | package main |
| 8 | |
| 9 | import ( |
| 10 | "context" |
| 11 | "flag" |
| 12 | "fmt" |
| 13 | "log" |
| 14 | "sort" |
| 15 | "strings" |
| 16 | |
| 17 | "github.com/carlosframework/platform/internal/release" |
| 18 | ) |
| 19 | |
| 20 | const featuresUsage = `usage: carlos features <set|list> [flags] |
| 21 | |
| 22 | set -app <a> [flags] key=value [key=value ...] |
| 23 | set the app's feature flags — keys and values are the |
| 24 | app's own vocabulary; an empty value (key=) clears the key |
| 25 | list -app <a> [flags] |
| 26 | the app's feature flags as the platform serves them |
| 27 | |
| 28 | -account <a> account (sqid or name; default "ops") |
| 29 | -account-id <id> account sqid, matched as a sqid ONLY |
| 30 | -console <origin> console to act through (default: the only logged-in one) |
| 31 | |
| 32 | Signed-in only (carlos auth login): a switch is a console-authorized, |
| 33 | audited write. The app reads it off the deployment block its instance token |
| 34 | already fetches — no restart, no env edit; the app picks it up on its next |
| 35 | poll. |
| 36 | ` |
| 37 | |
| 38 | const featuresSetUsage = `usage: carlos features set -app <a> [-account <a>] [-account-id <id>] [-console <origin>] key=value [key=value ...] |
| 39 | ` |
| 40 | |
| 41 | const featuresListUsage = `usage: carlos features list -app <a> [-account <a>] [-account-id <id>] [-console <origin>] |
| 42 | ` |
| 43 | |
| 44 | // runFeatures dispatches the sub-verbs. set/list are positional words, not |
| 45 | // flags, and must never reach fs.Parse — runDomains' shape. |
| 46 | func runFeatures(args []string) { |
| 47 | if len(args) > 0 { |
| 48 | switch args[0] { |
| 49 | case "set": |
| 50 | runFeaturesSet(args[1:]) |
| 51 | return |
| 52 | case "list": |
| 53 | runFeaturesList(args[1:]) |
| 54 | return |
| 55 | } |
| 56 | } |
| 57 | log.Fatal(featuresUsage) |
| 58 | } |
| 59 | |
| 60 | // parseFeatureArgs turns the positional key=value words into the door's |
| 61 | // set map and unset list. `key=` (empty value) is an unset — the one way |
| 62 | // to clear a switch, since the record never stores an empty string. Keys |
| 63 | // are shape-checked here so a typo costs nothing, with the same rule the |
| 64 | // console applies; a key named twice is refused rather than last-wins, |
| 65 | // because "sandbox=on sandbox=off" is never what someone meant. |
| 66 | func parseFeatureArgs(words []string) (map[string]string, []string, error) { |
| 67 | set := map[string]string{} |
| 68 | var unset []string |
| 69 | seen := map[string]bool{} |
| 70 | for _, w := range words { |
| 71 | k, v, ok := strings.Cut(w, "=") |
| 72 | if !ok { |
| 73 | return nil, nil, fmt.Errorf("%q is not key=value", w) |
| 74 | } |
| 75 | if !release.ValidFeatureKey(k) { |
| 76 | return nil, nil, fmt.Errorf("invalid key %q — keys are 1-64 of a-z, 0-9, '_' and '-'", k) |
| 77 | } |
| 78 | if seen[k] { |
| 79 | return nil, nil, fmt.Errorf("key %q named twice", k) |
| 80 | } |
| 81 | seen[k] = true |
| 82 | if v == "" { |
| 83 | unset = append(unset, k) |
| 84 | continue |
| 85 | } |
| 86 | if !release.ValidFeatureValue(v) { |
| 87 | return nil, nil, fmt.Errorf("invalid value for %s — values are 1-256 printable characters", k) |
| 88 | } |
| 89 | set[k] = v |
| 90 | } |
| 91 | return set, unset, nil |
| 92 | } |
| 93 | |
| 94 | func runFeaturesSet(args []string) { |
| 95 | fs := flag.NewFlagSet("features set", flag.ExitOnError) |
| 96 | messageFlags := addMutationMessageFlags(fs) |
| 97 | app := fs.String("app", "", "app whose feature flags to set") |
| 98 | consoleFlag := consoleOption(fs, "console to set through (default: the only logged-in one)") |
| 99 | acctRaw := fs.String("account", "", "account (sqid or name) the app belongs to (default: your only account, or the configured/chosen one)") |
| 100 | acctIDRaw := fs.String("account-id", "", "account sqid, matched as a sqid ONLY — supersedes -account") |
| 101 | fs.Parse(args) |
| 102 | if fs.NArg() == 0 { |
| 103 | log.Fatal("features set: name at least one key=value\n" + featuresSetUsage) |
| 104 | } |
| 105 | set, unset, err := parseFeatureArgs(fs.Args()) |
| 106 | if err != nil { |
| 107 | log.Fatalf("features set: %v", err) |
| 108 | } |
| 109 | sess := requireFeaturesAPISession(*consoleFlag) |
| 110 | printUsing(stdout, sess) |
| 111 | acct := resolvedAccount(sess, acctRaw, acctIDRaw) |
| 112 | app2 := resolvedApp(sess, acct, *app, featuresSetUsage) |
| 113 | message, merr := resolveMutationMessage(fs, messageFlags) |
| 114 | if merr != nil { |
| 115 | log.Fatalf("features set: %v", merr) |
| 116 | } |
| 117 | res, err := sess.setFeatures(context.Background(), acct, app2, set, unset, message) |
| 118 | if err != nil { |
| 119 | log.Fatalf("features set: %v", err) |
| 120 | } |
| 121 | if res.Changed { |
| 122 | fmt.Fprintf(stdout, "%s for %s/%s\n", colorFor(stdout).ok("features updated"), res.Account, res.App) |
| 123 | } else { |
| 124 | fmt.Fprintf(stdout, "%s for %s/%s — nothing changed\n", colorFor(stdout).ok("features already set"), res.Account, res.App) |
| 125 | } |
| 126 | printFeatures(res) |
| 127 | } |
| 128 | |
| 129 | func runFeaturesList(args []string) { |
| 130 | fs := flag.NewFlagSet("features list", flag.ExitOnError) |
| 131 | app := fs.String("app", "", "app whose feature flags to list") |
| 132 | consoleFlag := consoleOption(fs, "console to list through (default: the only logged-in one)") |
| 133 | acctRaw := fs.String("account", "", "account (sqid or name) the app belongs to (default: your only account, or the configured/chosen one)") |
| 134 | acctIDRaw := fs.String("account-id", "", "account sqid, matched as a sqid ONLY — supersedes -account") |
| 135 | fs.Parse(args) |
| 136 | sess := requireFeaturesAPISession(*consoleFlag) |
| 137 | printUsing(stdout, sess) |
| 138 | acct := resolvedAccount(sess, acctRaw, acctIDRaw) |
| 139 | app2 := resolvedApp(sess, acct, *app, featuresListUsage) |
| 140 | res, err := sess.features(context.Background(), acct, app2) |
| 141 | if err != nil { |
| 142 | log.Fatalf("features list: %v", err) |
| 143 | } |
| 144 | printFeatures(res) |
| 145 | } |
| 146 | |
| 147 | // printFeatures is the one rendering both verbs share, so set's echo and |
| 148 | // list's answer can never disagree about what the platform now serves. |
| 149 | func printFeatures(res apiFeaturesResult) { |
| 150 | if len(res.Features) == 0 { |
| 151 | fmt.Fprintln(stdout, "no features set") |
| 152 | return |
| 153 | } |
| 154 | keys := make([]string, 0, len(res.Features)) |
| 155 | for k := range res.Features { |
| 156 | keys = append(keys, k) |
| 157 | } |
| 158 | sort.Strings(keys) |
| 159 | w := newTable(stdout) |
| 160 | fmt.Fprintln(w, "KEY\tVALUE") |
| 161 | for _, k := range keys { |
| 162 | fmt.Fprintf(w, "%s\t%s\n", k, res.Features[k]) |
| 163 | } |
| 164 | w.Flush() |
| 165 | if res.SetAt != "" { |
| 166 | fmt.Fprintf(stdout, "last changed %s", res.SetAt) |
| 167 | if res.SetBy != "" { |
| 168 | fmt.Fprintf(stdout, " by %s", res.SetBy) |
| 169 | } |
| 170 | fmt.Fprintln(stdout) |
| 171 | } |
| 172 | } |
| 173 | |
| 174 | // requireFeaturesAPISession refuses direct-bucket mode with features' OWN |
| 175 | // message, requireRetentionAPISession's pattern and reason. |
| 176 | func requireFeaturesAPISession(consoleFlag string) *apiSession { |
| 177 | sess, err := resolveMode(consoleFlag) |
| 178 | if err != nil { |
| 179 | log.Fatal(err) |
| 180 | } |
| 181 | if sess == nil { |
| 182 | log.Fatal("carlos features works through a console, and this shell is in direct bucket mode\n" + |
| 183 | "(CARLOS_DEPLOYMENT_BUCKET/CARLOS_DEPLOYMENT_DIR is set). A feature flag is a console write with an\n" + |
| 184 | "audit row. Unset the store env and run: carlos auth login -console <url>.") |
| 185 | } |
| 186 | return sess |
| 187 | } |
| 188 | |