carlos / platform Public

Clone
git clone https://amadan.net/carlos/platform

Plain git — no account needed to clone.

Download

Download this file

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.
7package main
8
9import (
10 "context"
11 "flag"
12 "fmt"
13 "log"
14 "sort"
15 "strings"
16
17 "github.com/carlosframework/platform/internal/release"
18)
19
20const 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
32Signed-in only (carlos auth login): a switch is a console-authorized,
33audited write. The app reads it off the deployment block its instance token
34already fetches — no restart, no env edit; the app picks it up on its next
35poll.
36`
37
38const featuresSetUsage = `usage: carlos features set -app <a> [-account <a>] [-account-id <id>] [-console <origin>] key=value [key=value ...]
39`
40
41const 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.
46func 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.
66func 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
94func 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
129func 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.
149func 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.
176func 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