Merge pull request #350 from carlosframework/feature-flags
App feature flags on the deployment object — `carlos features` (#347)
15 files changed,
+1300
−3
apimode.go+44 −0docs/site/cli.md+18 −0features_cli.go+182 −0features_cli_test.go+84 −0internal/console/apidomains.go+2 −2internal/console/apifeatures.go+197 −0internal/console/apifeatures_test.go+302 −0internal/console/appsettings.go+41 −0internal/console/authcli.go+10 −0internal/console/domainstate.go+14 −1internal/console/svcroutes_test.go+9 −0internal/console/templates/app_settings.html+21 −0internal/release/features.go+213 −0internal/release/features_test.go+159 −0main.go+4 −0
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/internal/console/apidomains.go b/internal/console/apidomains.go| index e93fa55b7..d9595ecc9 100644 |
| --- a/internal/console/apidomains.go |
| +++ b/internal/console/apidomains.go |
| @@ -722,7 +722,7 @@ func (s *Server) handleAPIDomainList(w http.ResponseWriter, r *http.Request) { |
| // The self-describing block (#299 §2c). An app reads managed_domains |
| // to know the platform owns verification and certificates here, and |
| // stops implementing them itself. |
| - "deployment": s.domainCapability(), |
| + "deployment": s.domainCapability(ctx, acct.Sqid, app), |
| }) |
| } |
| @@ -820,7 +820,7 @@ func (s *Server) handleAPIDomainGet(w http.ResponseWriter, r *http.Request) { |
| now := time.Now() |
| writeJSON(w, http.StatusOK, map[string]any{ |
| "domain": s.domainItem(ctx, *c, aliasReadings(ctx, s.cfg.Releases, now), s.dnsTargets(), now, c.Wildcard && s.delegationZone != ""), |
| - "deployment": s.domainCapability(), |
| + "deployment": s.domainCapability(ctx, acct.Sqid, app), |
| }) |
| } |
diff --git a/internal/console/apifeatures.go b/internal/console/apifeatures.go| new file mode 100644 |
| index 000000000..6073a5dc7 |
| --- /dev/null |
| +++ b/internal/console/apifeatures.go |
| @@ -0,0 +1,197 @@ |
| +// The `carlos features` doors (issue #347): the app-defined switches the |
| +// platform stores and serves but never interprets — `sandbox=on` for |
| +// titogo's router — written here, audited, and read back by the app off the |
| +// deployment block the domains doors already answer with (domainCapability, |
| +// domainstate.go). This is managed_domains generalized: the platform owns |
| +// the write path so that enabling an app capability is never an app editing |
| +// root-owned env on an edge box. |
| +// |
| +// Gate order is handleAPIRetentionSet's exactly — token, one byte-identical |
| +// 404 for every identity failure, the shared per-principal write budget, 503 |
| +// when the console has no bucket. What differs is what happens AFTER a |
| +// successful write: no serial bump. Nothing on a box reads features.json; |
| +// the app polls the console for it, so announcing a write would send every |
| +// box looking for a change none of them can see. |
| +// |
| +// Roles (authcli.go): the app's own instance credential READS — it is the |
| +// consumer — and never writes. A switch an app could flip on itself would |
| +// be a root env flag by another name, the exact thing this door retires. |
| +package console |
| + |
| +import ( |
| + "context" |
| + "log" |
| + "net/http" |
| + "sort" |
| + "strconv" |
| + "strings" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/release" |
| +) |
| + |
| +// apiFeaturesResponse is the one wire shape both doors answer with. |
| +// Features is never omitted and never null on a successful answer: an app |
| +// ranges over it, and "no flags set" is spelled {}. |
| +type apiFeaturesResponse struct { |
| + Account string `json:"account"` |
| + App string `json:"app"` |
| + Features map[string]string `json:"features"` |
| + SetBy string `json:"set_by,omitempty"` |
| + SetAt string `json:"set_at,omitempty"` |
| + // Changed is the set door's answer only: whether this call wrote |
| + // anything. Absent on a read. |
| + Changed *bool `json:"changed,omitempty"` |
| +} |
| + |
| +func featuresResponse(acct, app string, rec *release.Features) apiFeaturesResponse { |
| + resp := apiFeaturesResponse{Account: acct, App: app, Features: map[string]string{}} |
| + if rec != nil { |
| + resp.Features = rec.Features |
| + resp.SetBy = rec.SetBy |
| + if !rec.SetAt.IsZero() { |
| + resp.SetAt = rec.SetAt.UTC().Format(time.RFC3339) |
| + } |
| + } |
| + return resp |
| +} |
| + |
| +// handleAPIFeaturesGet answers `carlos features list -app X`. No write |
| +// budget and no audit: it is a read. An unreadable record is a 502, not an |
| +// empty map — the dedicated read must never tell an operator "nothing set" |
| +// about a record it could not parse. |
| +func (s *Server) handleAPIFeaturesGet(w http.ResponseWriter, r *http.Request) { |
| + _, acct, app, ok := s.apiAccountApp(w, r) |
| + if !ok { |
| + return |
| + } |
| + if s.cfg.Releases == nil { |
| + jsonError(w, http.StatusServiceUnavailable, |
| + "no deployment bucket on this host — this console cannot read features") |
| + return |
| + } |
| + ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second) |
| + defer cancel() |
| + rec, err := release.GetFeatures(ctx, s.cfg.Releases, acct.Sqid, app) |
| + if err != nil { |
| + log.Printf("console: api features get %s/%s: %v", acct.Sqid, app, err) |
| + jsonError(w, http.StatusBadGateway, "could not read the app's features — try again") |
| + return |
| + } |
| + writeJSON(w, http.StatusOK, featuresResponse(acct.Sqid, app, rec)) |
| +} |
| + |
| +// handleAPIFeaturesSet answers `carlos features set -app X k=v [k=]`: set |
| +// merges onto the current map, unset removes keys, in one CAS-guarded |
| +// write. Only an actual CHANGE audits — a set to the value already held is |
| +// a no-op, and a row per no-op would bury the app's log under a caller |
| +// re-asserting the same switch on every deploy. |
| +func (s *Server) handleAPIFeaturesSet(w http.ResponseWriter, r *http.Request) { |
| + _, acct, app, ok := s.apiAccountApp(w, r) |
| + if !ok { |
| + return |
| + } |
| + p, _ := principalFrom(r.Context()) |
| + if !s.allowWrite(p, time.Now()) { |
| + jsonError(w, http.StatusTooManyRequests, "too many requests — wait a minute and try again") |
| + return |
| + } |
| + if s.cfg.Releases == nil { |
| + jsonError(w, http.StatusServiceUnavailable, |
| + "no deployment bucket on this host — this console cannot write features") |
| + return |
| + } |
| + var in struct { |
| + Set map[string]string `json:"set"` |
| + Unset []string `json:"unset"` |
| + } |
| + if !readJSON(w, r, &in) { |
| + return |
| + } |
| + if len(in.Set) == 0 && len(in.Unset) == 0 { |
| + jsonError(w, http.StatusBadRequest, "nothing to do — name at least one key to set or unset") |
| + return |
| + } |
| + // Shape-checked HERE, before the store is touched, so a bad key is a |
| + // 400 the caller can fix rather than a 502 they cannot — and so the |
| + // message names the rule, not just the refusal. |
| + for k, v := range in.Set { |
| + if !release.ValidFeatureKey(k) { |
| + jsonError(w, http.StatusBadRequest, "invalid key "+k+" — keys are 1-64 of a-z, 0-9, '_' and '-'") |
| + return |
| + } |
| + if !release.ValidFeatureValue(v) { |
| + jsonError(w, http.StatusBadRequest, "invalid value for "+k+" — values are 1-256 printable characters (to clear a key, unset it)") |
| + return |
| + } |
| + } |
| + for _, k := range in.Unset { |
| + if !release.ValidFeatureKey(k) { |
| + jsonError(w, http.StatusBadRequest, "invalid key "+k+" — keys are 1-64 of a-z, 0-9, '_' and '-'") |
| + return |
| + } |
| + } |
| + principal := auditActor(p, acct) |
| + ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second) |
| + defer cancel() |
| + rec, changed, err := release.UpdateFeatures(ctx, s.cfg.Releases, acct.Sqid, app, in.Set, in.Unset, principal, time.Now()) |
| + if err != nil { |
| + if strings.Contains(err.Error(), "at most") { |
| + jsonError(w, http.StatusBadRequest, "an app may hold at most "+strconv.Itoa(release.FeaturesMax)+" features") |
| + return |
| + } |
| + log.Printf("console: api features set %s/%s: %v", acct.Sqid, app, err) |
| + jsonError(w, http.StatusBadGateway, "could not write the app's features — nothing was changed; try again") |
| + return |
| + } |
| + if changed { |
| + s.audit(acct, app, principal, "features", featuresAuditDetail(in.Set, in.Unset)) |
| + } |
| + resp := featuresResponse(acct.Sqid, app, rec) |
| + resp.Changed = &changed |
| + writeJSON(w, http.StatusOK, resp) |
| +} |
| + |
| +// featuresAuditDetail spells one call out the way the operator typed it, |
| +// keys sorted so two audits of the same call read the same. |
| +func featuresAuditDetail(set map[string]string, unset []string) string { |
| + keys := make([]string, 0, len(set)) |
| + for k := range set { |
| + keys = append(keys, k) |
| + } |
| + sort.Strings(keys) |
| + parts := make([]string, 0, len(keys)+1) |
| + for _, k := range keys { |
| + parts = append(parts, k+"="+set[k]) |
| + } |
| + if len(unset) > 0 { |
| + u := append([]string(nil), unset...) |
| + sort.Strings(u) |
| + parts = append(parts, "unset "+strings.Join(u, ", ")) |
| + } |
| + return "set features: " + strings.Join(parts, ", ") + " (cli)" |
| +} |
| + |
| +// appFeatures is the deployment-block read (domainCapability): the app's |
| +// map, plus when it last changed. Best-effort by design — this rides on the |
| +// domains answer, and a features record the console cannot read must not |
| +// take a domains listing down with it. The nil map (JSON null) is the |
| +// "unknown" reading; an app tells it from {} ("none set") by that. |
| +func (s *Server) appFeatures(ctx context.Context, acct, app string) (map[string]string, string) { |
| + if s.cfg.Releases == nil { |
| + return nil, "" |
| + } |
| + rec, err := release.GetFeatures(ctx, s.cfg.Releases, acct, app) |
| + if err != nil { |
| + log.Printf("console: api features block %s/%s: %v", acct, app, err) |
| + return nil, "" |
| + } |
| + if rec == nil { |
| + return map[string]string{}, "" |
| + } |
| + setAt := "" |
| + if !rec.SetAt.IsZero() { |
| + setAt = rec.SetAt.UTC().Format(time.RFC3339) |
| + } |
| + return rec.Features, setAt |
| +} |
diff --git a/internal/console/apifeatures_test.go b/internal/console/apifeatures_test.go| new file mode 100644 |
| index 000000000..bea0555bc |
| --- /dev/null |
| +++ b/internal/console/apifeatures_test.go |
| @@ -0,0 +1,302 @@ |
| +package console |
| + |
| +import ( |
| + "context" |
| + "encoding/json" |
| + "net/http" |
| + "strings" |
| + "testing" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/release" |
| + "github.com/carlosframework/platform/internal/svcrole" |
| +) |
| + |
| +func featuresURL(fx *consoleFixture, acct, app string) string { |
| + return fx.ts.URL + "/api/cli/apps/" + acct + "/" + app + "/features" |
| +} |
| + |
| +// featuresAnswer is the wire shape both doors answer with. |
| +type featuresAnswer 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 decodeFeatures(t *testing.T, body []byte) featuresAnswer { |
| + t.Helper() |
| + var out featuresAnswer |
| + if err := json.Unmarshal(body, &out); err != nil { |
| + t.Fatalf("features body %s: %v", body, err) |
| + } |
| + return out |
| +} |
| + |
| +func TestAPIFeaturesSetMergesAuditsAndDoesNotAnnounce(t *testing.T) { |
| + fx, token := instanceFixture(t, "code-feat-set") |
| + status, out := postBody(t, featuresURL(fx, "bab", "titogo"), token, `{"set": {"sandbox": "on"}}`) |
| + if status != http.StatusOK { |
| + t.Fatalf("set = %d %s, want 200", status, out) |
| + } |
| + res := decodeFeatures(t, out) |
| + if res.Account != "bab" || res.App != "titogo" || res.Features["sandbox"] != "on" || res.Changed == nil || !*res.Changed { |
| + t.Fatalf("set body = %+v", res) |
| + } |
| + if res.SetBy != "alice@keymail.dev" || res.SetAt == "" { |
| + t.Fatalf("set body = %+v, want set_by alice and a set_at", res) |
| + } |
| + rec, err := release.GetFeatures(context.Background(), fx.rel, release.OpsAccount, "titogo") |
| + if err != nil || rec == nil || rec.Features["sandbox"] != "on" { |
| + t.Fatalf("record after set = %+v, %v", rec, err) |
| + } |
| + |
| + // A second set MERGES and an unset in the same call lands too. |
| + status, out = postBody(t, featuresURL(fx, "bab", "titogo"), token, `{"set": {"beta": "1"}, "unset": ["sandbox"]}`) |
| + if status != http.StatusOK { |
| + t.Fatalf("merge = %d %s, want 200", status, out) |
| + } |
| + res = decodeFeatures(t, out) |
| + if _, ok := res.Features["sandbox"]; ok || res.Features["beta"] != "1" { |
| + t.Fatalf("after merge = %+v, want beta only", res.Features) |
| + } |
| + |
| + events, err := fx.st.Events(OpsAccountID, "titogo", 10) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + var rows []string |
| + for _, e := range events { |
| + if e.Action == "features" { |
| + rows = append(rows, e.Detail) |
| + } |
| + } |
| + if len(rows) != 2 { |
| + t.Fatalf("features audit rows = %q, want 2", rows) |
| + } |
| + // Newest first: the merge names both what it set and what it cleared. |
| + if !strings.Contains(rows[0], "beta=1") || !strings.Contains(rows[0], "unset sandbox") { |
| + t.Fatalf("merge audit detail = %q, want it to name beta=1 and the unset", rows[0]) |
| + } |
| + if !strings.Contains(rows[1], "sandbox=on") { |
| + t.Fatalf("first audit detail = %q, want sandbox=on", rows[1]) |
| + } |
| + // No box consumes features.json — the app reads it off the console — |
| + // so a set must not send every box looking for a change it cannot see. |
| + if got := serial(t, fx); got != 0 { |
| + t.Fatalf("serial = %d, want 0 — features are not box-consumed", got) |
| + } |
| +} |
| + |
| +func TestAPIFeaturesNoOpSetWritesNoAuditRow(t *testing.T) { |
| + fx, token := instanceFixture(t, "code-feat-noop") |
| + postBody(t, featuresURL(fx, "bab", "titogo"), token, `{"set": {"sandbox": "on"}}`) |
| + status, out := postBody(t, featuresURL(fx, "bab", "titogo"), token, `{"set": {"sandbox": "on"}, "unset": ["absent"]}`) |
| + if status != http.StatusOK { |
| + t.Fatalf("no-op = %d %s, want 200", status, out) |
| + } |
| + if res := decodeFeatures(t, out); res.Changed == nil || *res.Changed { |
| + t.Fatalf("no-op body = %+v, want changed false", res) |
| + } |
| + events, _ := fx.st.Events(OpsAccountID, "titogo", 10) |
| + n := 0 |
| + for _, e := range events { |
| + if e.Action == "features" { |
| + n++ |
| + } |
| + } |
| + if n != 1 { |
| + t.Fatalf("features audit rows = %d, want 1 — a no-op must not audit", n) |
| + } |
| +} |
| + |
| +func TestAPIFeaturesGetAbsentIsEmptyNotError(t *testing.T) { |
| + fx, token := instanceFixture(t, "code-feat-get") |
| + status, out := getRetention(t, featuresURL(fx, "bab", "titogo"), token) |
| + if status != http.StatusOK { |
| + t.Fatalf("get = %d %s, want 200", status, out) |
| + } |
| + res := decodeFeatures(t, out) |
| + if res.Features == nil || len(res.Features) != 0 || res.SetAt != "" || res.Changed != nil { |
| + t.Fatalf("absent get = %s, want an empty (non-null) map and no set_at", out) |
| + } |
| + if !strings.Contains(string(out), `"features":{}`) { |
| + t.Fatalf("absent get = %s, want features spelled {} (an app ranges over it)", out) |
| + } |
| +} |
| + |
| +func TestAPIFeaturesBadShapesAre400AndWriteNothing(t *testing.T) { |
| + fx, token := instanceFixture(t, "code-feat-bad") |
| + for _, body := range []string{ |
| + `{"set": {"Sandbox": "on"}}`, |
| + `{"set": {"sandbox": ""}}`, |
| + `{"unset": ["bad key"]}`, |
| + `{}`, |
| + `{"set": {}, "unset": []}`, |
| + } { |
| + status, out := postBody(t, featuresURL(fx, "bab", "titogo"), token, body) |
| + if status != http.StatusBadRequest { |
| + t.Errorf("%s = %d %s, want 400", body, status, out) |
| + } |
| + } |
| + if rec, _ := release.GetFeatures(context.Background(), fx.rel, release.OpsAccount, "titogo"); rec != nil { |
| + t.Fatalf("a refused set wrote %+v", rec) |
| + } |
| +} |
| + |
| +// The point of the feature (#347): the app reads its flags off the |
| +// deployment block it already fetches with its CARLOS_INSTANCE_TOKEN, beside |
| +// managed_domains — no new call, no new credential, no restart. |
| +func TestAPIFeaturesRideTheDomainsDeploymentBlock(t *testing.T) { |
| + fx, member := instanceFixture(t, "code-feat-block") |
| + app := serviceToken(t, fx.st, OpsAccountID, "router", "titogo", svcrole.Instance) |
| + |
| + type block struct { |
| + Deployment struct { |
| + ManagedDomains bool `json:"managed_domains"` |
| + Features map[string]string `json:"features"` |
| + FeaturesSetAt string `json:"features_set_at"` |
| + Raw map[string]any `json:"-"` |
| + } `json:"deployment"` |
| + } |
| + read := func() (block, string) { |
| + code, _, out := rawReq(t, http.MethodGet, domainsURL(fx, "bab", "titogo"), app, "") |
| + if code != http.StatusOK { |
| + t.Fatalf("domains list as the app = %d %s", code, out) |
| + } |
| + var b block |
| + if err := json.Unmarshal([]byte(out), &b); err != nil { |
| + t.Fatal(err) |
| + } |
| + return b, out |
| + } |
| + |
| + // Before anything is set: present and EMPTY, never null — an app tells |
| + // "no flags" from "could not read" by that. |
| + b, raw := read() |
| + if !b.Deployment.ManagedDomains { |
| + t.Fatal("managed_domains lost from the block") |
| + } |
| + if b.Deployment.Features == nil || !strings.Contains(raw, `"features":{}`) { |
| + t.Fatalf("block before any set = %s, want \"features\":{}", raw) |
| + } |
| + |
| + postBody(t, featuresURL(fx, "bab", "titogo"), member, `{"set": {"sandbox": "on"}}`) |
| + b, _ = read() |
| + if b.Deployment.Features["sandbox"] != "on" || b.Deployment.FeaturesSetAt == "" { |
| + t.Fatalf("block after set = %+v, want sandbox=on with a features_set_at", b.Deployment) |
| + } |
| + if _, err := time.Parse(time.RFC3339, b.Deployment.FeaturesSetAt); err != nil { |
| + t.Fatalf("features_set_at %q is not RFC3339: %v", b.Deployment.FeaturesSetAt, err) |
| + } |
| + |
| + // The per-host door carries the same block. |
| + attachDomain(t, fx, member, "flag.example.com", "go.tito.io") |
| + code, _, out := rawReq(t, http.MethodGet, domainGetURL(fx, "bab", "titogo", "flag.example.com"), app, "") |
| + if code != http.StatusOK { |
| + t.Fatalf("per-host get = %d %s", code, out) |
| + } |
| + var one block |
| + if err := json.Unmarshal([]byte(out), &one); err != nil { |
| + t.Fatal(err) |
| + } |
| + if one.Deployment.Features["sandbox"] != "on" { |
| + t.Fatalf("per-host block = %+v, want sandbox=on", one.Deployment) |
| + } |
| +} |
| + |
| +// A record the console cannot parse must not take the domains answer down |
| +// with it — the block reads features as null ("unknown"), which is neither |
| +// the empty map nor a stale value. |
| +func TestAPIFeaturesUnreadableRecordDegradesTheBlockToNull(t *testing.T) { |
| + fx, member := instanceFixture(t, "code-feat-degrade") |
| + if _, err := fx.rel.Put(context.Background(), release.FeaturesKey(release.OpsAccount, "titogo"), []byte(`{"schema":9}`), ""); err != nil { |
| + t.Fatal(err) |
| + } |
| + code, _, out := rawReq(t, http.MethodGet, domainsURL(fx, "bab", "titogo"), member, "") |
| + if code != http.StatusOK { |
| + t.Fatalf("domains list with a bad features record = %d %s, want 200", code, out) |
| + } |
| + if !strings.Contains(out, `"features":null`) { |
| + t.Fatalf("block = %s, want \"features\":null for an unreadable record", out) |
| + } |
| + // And the dedicated read says so out loud rather than lying with {}. |
| + status, body := getRetention(t, featuresURL(fx, "bab", "titogo"), member) |
| + if status != http.StatusBadGateway { |
| + t.Fatalf("features get with a bad record = %d %s, want 502", status, body) |
| + } |
| +} |
| + |
| +// Roles: the app's own instance credential READS (it is the consumer) and |
| +// may never WRITE — a switch the app could flip on itself is a root env |
| +// flag by another name, the exact thing #347 exists to retire. Publish reads |
| +// too; only admin sets. |
| +func TestAPIFeaturesRoles(t *testing.T) { |
| + fx, _ := instanceFixture(t, "code-feat-roles") |
| + instance := serviceToken(t, fx.st, OpsAccountID, "router", "titogo", svcrole.Instance) |
| + publish := serviceToken(t, fx.st, OpsAccountID, "ci", "titogo", svcrole.Publish) |
| + operate := serviceToken(t, fx.st, OpsAccountID, "ops", "titogo", svcrole.Operate) |
| + admin := serviceToken(t, fx.st, OpsAccountID, "root", "titogo", svcrole.Admin) |
| + |
| + for name, tok := range map[string]string{"instance": instance, "publish": publish} { |
| + if status, out := getRetention(t, featuresURL(fx, "bab", "titogo"), tok); status != http.StatusOK { |
| + t.Errorf("%s GET = %d %s, want 200", name, status, out) |
| + } |
| + } |
| + if status, _ := getRetention(t, featuresURL(fx, "bab", "titogo"), operate); status != http.StatusForbidden { |
| + t.Errorf("operate GET = %d, want 403", status) |
| + } |
| + for name, tok := range map[string]string{"instance": instance, "publish": publish, "operate": operate} { |
| + if status, out := postBody(t, featuresURL(fx, "bab", "titogo"), tok, `{"set": {"sandbox": "on"}}`); status != http.StatusForbidden { |
| + t.Errorf("%s POST = %d %s, want 403", name, status, out) |
| + } |
| + } |
| + if status, out := postBody(t, featuresURL(fx, "bab", "titogo"), admin, `{"set": {"sandbox": "on"}}`); status != http.StatusOK { |
| + t.Errorf("admin POST = %d %s, want 200", status, out) |
| + } |
| +} |
| + |
| +// The console surface (#347): the app Settings page shows the flags the |
| +// platform serves, read-only, with the last change beside them — so a |
| +// member can see what an operator set without a terminal. Writes stay on |
| +// the CLI door, where they are role-gated and audited. |
| +func TestSettingsPageShowsFeatureFlags(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + c := signInPaul(t, fx) |
| + freshApp(t, fx, "titogo") |
| + when := time.Date(2026, 8, 28, 9, 30, 0, 0, time.UTC) |
| + if _, _, err := release.UpdateFeatures(context.Background(), fx.rel, release.OpsAccount, "titogo", |
| + map[string]string{"sandbox": "on", "beta-ui": "v2"}, nil, "alice@keymail.dev", when); err != nil { |
| + t.Fatal(err) |
| + } |
| + |
| + status, _, body := get(t, c, fx.u("/apps/titogo/settings")) |
| + if status != http.StatusOK { |
| + t.Fatalf("settings page = %d:\n%s", status, body) |
| + } |
| + for _, want := range []string{ |
| + "<h2>Feature flags</h2>", |
| + "<code>sandbox</code>", "<code>on</code>", |
| + "<code>beta-ui</code>", "<code>v2</code>", |
| + "alice@keymail.dev", |
| + "carlos features set --app titogo", |
| + } { |
| + if !strings.Contains(body, want) { |
| + t.Fatalf("the feature flags card is missing %q:\n%s", want, body) |
| + } |
| + } |
| + // Sorted by key: beta-ui before sandbox, whatever order the map ranges. |
| + if strings.Index(body, "<code>beta-ui</code>") > strings.Index(body, "<code>sandbox</code>") { |
| + t.Fatalf("flags are not key-sorted:\n%s", body) |
| + } |
| + |
| + // An app with none set says so, and still tells the member how one |
| + // gets set. |
| + freshApp(t, fx, "sprout") |
| + _, _, body = get(t, c, fx.u("/apps/sprout/settings")) |
| + if !strings.Contains(body, "No feature flags") || !strings.Contains(body, "carlos features set --app sprout") { |
| + t.Fatalf("an app with no flags is not told so:\n%s", body) |
| + } |
| +} |
diff --git a/internal/console/appsettings.go b/internal/console/appsettings.go| index 81527deed..a8f2ae177 100644 |
| --- a/internal/console/appsettings.go |
| +++ b/internal/console/appsettings.go |
| @@ -34,6 +34,7 @@ import ( |
| "log" |
| "net/http" |
| "net/url" |
| + "sort" |
| "strings" |
| "time" |
| @@ -80,6 +81,13 @@ type SettingsView struct { |
| // on a read failure, a zero card when there is genuinely no record. |
| Mail *MailCard |
| + // Features is the app's feature flags as the platform serves them |
| + // (#347, apifeatures.go) — read-only here; the write is the audited, |
| + // admin-gated CLI door. Same error discipline as Store: nil on a read |
| + // failure (the page says nothing rather than "none set" about a record |
| + // it could not parse), a zero card when there genuinely are none. |
| + Features *FeaturesCard |
| + |
| // Domains is this page's OWN Domains & TLS rows (settingsDomains): a |
| // copy of AppView.Domains with a placeholder row appended for any of |
| // this app's claims that have no registry row yet. Deliberately not |
| @@ -247,6 +255,7 @@ func (s *Server) assembleSettings(ctx context.Context, p *page, acct *Account) * |
| } |
| sv.Store = s.storeCard(ctx, acct, p.AppName) |
| sv.Mail = s.mailCard(ctx, acct, p.AppName) |
| + sv.Features = s.featuresCard(ctx, acct, p.AppName) |
| if ra := p.Detail.RouteAccount; ra != "" && p.Detail.AllMismatched { |
| sv.MismatchTarget = ra |
| if target, err := s.store.AccountBySqid(ra); err == nil && target != nil { |
| @@ -259,6 +268,38 @@ func (s *Server) assembleSettings(ctx context.Context, p *page, acct *Account) * |
| return sv |
| } |
| +// FeaturesCard is the Settings page's read of the app's feature flags. |
| +type FeaturesCard struct { |
| + Flags []FeatureFlag // key-sorted |
| + SetBy string |
| + SetAt string // "" when no record |
| +} |
| + |
| +type FeatureFlag struct{ Key, Value string } |
| + |
| +// featuresCard folds the features record into its Settings card, storeCard's |
| +// discipline: nil on a read failure, a zero card when there is no record. |
| +func (s *Server) featuresCard(ctx context.Context, acct *Account, app string) *FeaturesCard { |
| + rec, err := release.GetFeatures(ctx, s.cfg.Releases, acct.Sqid, app) |
| + if err != nil { |
| + log.Printf("console: settings features read %s/%s: %v", acct.Sqid, app, err) |
| + return nil |
| + } |
| + card := &FeaturesCard{} |
| + if rec == nil { |
| + return card |
| + } |
| + for k, v := range rec.Features { |
| + card.Flags = append(card.Flags, FeatureFlag{k, v}) |
| + } |
| + sort.Slice(card.Flags, func(i, j int) bool { return card.Flags[i].Key < card.Flags[j].Key }) |
| + card.SetBy = rec.SetBy |
| + if !rec.SetAt.IsZero() { |
| + card.SetAt = rec.SetAt.UTC().Format("2006-01-02 15:04 UTC") |
| + } |
| + return card |
| +} |
| + |
| // storeCard folds the store record into the Settings card. Best-effort like |
| // everything else this page assembles: a read failure returns nil and the |
| // section is simply absent, because "could not read the record" and "there is |
diff --git a/internal/console/authcli.go b/internal/console/authcli.go| index 4c66718d2..14eab60b2 100644 |
| --- a/internal/console/authcli.go |
| +++ b/internal/console/authcli.go |
| @@ -610,6 +610,16 @@ func (s *Server) registerCLIRoutes() { |
| s.cliRoute("GET /api/cli/apps/{account}/{app}/domains/{host}", svcrole.Allow(svcrole.Publish, svcrole.Instance), s.handleAPIDomainGet) |
| s.cliRoute("POST /api/cli/apps/{account}/{app}/domains", svcrole.Allow(svcrole.Publish, svcrole.Instance), s.handleAPIDomainAttach) |
| s.cliRoute("DELETE /api/cli/apps/{account}/{app}/domains/{host}", svcrole.Allow(svcrole.Publish, svcrole.Instance), s.handleAPIDomainDetach) |
| + // App feature flags (apifeatures.go, #347). The read carries Instance |
| + // because the app IS the consumer — its router reads its own switches |
| + // with the CARLOS_INSTANCE_TOKEN, the same credential and the same |
| + // answer (the domains deployment block) it already uses. The write is |
| + // Admin only: these are production switches, and the register is |
| + // `pipeline` PUT's and `apps place`'s, not promote's. An app's own |
| + // credential must never reach the write — a switch an app could flip |
| + // on itself is a root env flag by another name. |
| + s.cliRoute("GET /api/cli/apps/{account}/{app}/features", svcrole.Allow(svcrole.Publish, svcrole.Instance), s.handleAPIFeaturesGet) |
| + s.cliRoute("POST /api/cli/apps/{account}/{app}/features", svcrole.Allow(svcrole.Admin), s.handleAPIFeaturesSet) |
| // The config API (apiconfig.go) — `carlos env` and `carlos secrets` |
| // through the same gate as ship/promote, so deploy access no longer |
| // means bucket credentials (issue #43). Secrets arrive already sealed; |
diff --git a/internal/console/domainstate.go b/internal/console/domainstate.go| index 94ede8906..861452c89 100644 |
| --- a/internal/console/domainstate.go |
| +++ b/internal/console/domainstate.go |
| @@ -1,6 +1,7 @@ |
| package console |
| import ( |
| + "context" |
| "strings" |
| "time" |
| @@ -190,10 +191,20 @@ type deploymentCapability struct { |
| RecordTTL int `json:"record_ttl"` |
| // States is the vocabulary THIS deployment can return (see domainStates). |
| States []string `json:"states"` |
| + // Features is the app's own switches (#347, apifeatures.go) — the one |
| + // per-APP entry in an otherwise per-deployment block, placed here |
| + // because this block is the fetch the app already makes. Three |
| + // readings, deliberately distinct on the wire: {} is "none set", null |
| + // is "the console could not read the record", and a missing key is an |
| + // older console. FeaturesSetAt is when the map last changed, the |
| + // checked_at-style freshness stamp an app's own poll ages against. |
| + Features map[string]string `json:"features"` |
| + FeaturesSetAt string `json:"features_set_at,omitempty"` |
| } |
| -func (s *Server) domainCapability() deploymentCapability { |
| +func (s *Server) domainCapability(ctx context.Context, acct, app string) deploymentCapability { |
| t := s.dnsTargets() |
| + features, setAt := s.appFeatures(ctx, acct, app) |
| return deploymentCapability{ |
| ManagedDomains: true, |
| AppsDomain: strings.TrimSpace(s.cfg.AppsDomain), |
| @@ -202,5 +213,7 @@ func (s *Server) domainCapability() deploymentCapability { |
| DelegationZone: s.delegationZone, |
| RecordTTL: domainRecordTTL, |
| States: domainStates, |
| + Features: features, |
| + FeaturesSetAt: setAt, |
| } |
| } |
diff --git a/internal/console/svcroutes_test.go b/internal/console/svcroutes_test.go| index c7ab9d85a..c7fd77ac8 100644 |
| --- a/internal/console/svcroutes_test.go |
| +++ b/internal/console/svcroutes_test.go |
| @@ -214,9 +214,18 @@ func TestNoRoleReachesAMintFleetOrPolicyDoor(t *testing.T) { |
| // its CARLOS_INSTANCE_TOKEN, which is the entire reason the door was added. |
| // It discloses strictly less than the list door beside it — one hostname the |
| // caller already owns — so it cannot need a role that list does not have. |
| +// |
| +// GET .../features is the third (#347), on the same footing: it did not |
| +// exist before service principals, and the instance role reaches it |
| +// DELIBERATELY because the app IS the consumer — its router reads its own |
| +// switches with its CARLOS_INSTANCE_TOKEN. The same map already rides on |
| +// the domains list answer that role has always reached, so this discloses |
| +// nothing the list does not. The WRITE beside it is admin-only, and |
| +// TestAPIFeaturesRoles pins that an instance credential never reaches it. |
| var exemptFromLegacyPin = map[string]bool{ |
| "GET /api/cli/apps": true, |
| "GET /api/cli/apps/{account}/{app}/domains/{host}": true, |
| + "GET /api/cli/apps/{account}/{app}/features": true, |
| } |
| // TestInstanceRoleMatchesTodaysAppTokenReach is the compatibility pin of spec |
diff --git a/internal/console/templates/app_settings.html b/internal/console/templates/app_settings.html| index 5748af769..75f72e3e8 100644 |
| --- a/internal/console/templates/app_settings.html |
| +++ b/internal/console/templates/app_settings.html |
| @@ -163,6 +163,27 @@ credentials — an operator of this deployment has to tear it down.</p> |
| and delivers its credentials into this app's config.</p> |
| {{end}} |
| {{end}}{{end}} |
| +{{if .Settings}}{{with .Settings.Features}} |
| +<h2>Feature flags</h2> |
| +{{if .Flags}} |
| +<div class="tablewrap"> |
| +<table> |
| + <tr><th>Key</th><th>Value</th></tr> |
| + {{range .Flags}}<tr><td><code>{{.Key}}</code></td><td><code>{{.Value}}</code></td></tr> |
| + {{end}} |
| +</table> |
| +</div> |
| +<p class="small">Last changed {{.SetAt}}{{if .SetBy}} by {{.SetBy}}{{end}}. The platform |
| +stores and serves these; what each one means is this app's own business. It |
| +reads them off the deployment block its instance token fetches, so a change |
| +lands on its next poll — no restart.</p> |
| +{{else}} |
| +<p class="small">No feature flags set.</p> |
| +{{end}} |
| +<p class="small">Set or clear one with |
| +<code>carlos features set --app {{$.Detail.App}} key=value</code> |
| +(<code>key=</code> clears); every change is audited under Activity.</p> |
| +{{end}}{{end}} |
| {{if .Settings}}{{with .Settings.Mail}} |
| <h2>Email</h2> |
| {{if or .Declared .Granted .Provisioned .Standalone}} |
diff --git a/internal/release/features.go b/internal/release/features.go| new file mode 100644 |
| index 000000000..0f517de36 |
| --- /dev/null |
| +++ b/internal/release/features.go |
| @@ -0,0 +1,213 @@ |
| +// The app feature-flags record (issue #347): one small unsigned object per |
| +// app holding the switches the APP defines and the platform merely stores |
| +// and serves — `{"sandbox": "on"}` — read back by the app off the deployment |
| +// block it already fetches. The platform never interprets a key or a value; |
| +// what it owns is the write path (a console door, audited) so that enabling |
| +// an app capability is never an app editing root-owned env on an edge box. |
| +// |
| +// Bucket layout addition: |
| +// |
| +// apps/<acct>/<app>/features.json CAS-guarded, schema-1 record |
| +// |
| +// ABSENT IS LEGAL FOREVER: no record means no features set — the |
| +// retention.json convention. Nothing on a box reads this key; the only |
| +// consumer is the console, so the shape is purely additive. |
| +// |
| +// Not secrets, not config: values are short strings. Anything an app wants |
| +// delivered into a process environment is `carlos env`'s job, and anything |
| +// it wants sealed is `carlos secrets`'. |
| +package release |
| + |
| +import ( |
| + "context" |
| + "encoding/json" |
| + "errors" |
| + "fmt" |
| + "os" |
| + "sort" |
| + "time" |
| + "unicode" |
| +) |
| + |
| +// FeaturesMax caps how many keys one app's record may hold. The record is |
| +// read on every domains-door answer, so it stays a handful of switches |
| +// rather than growing into a config store by another name. |
| +const FeaturesMax = 64 |
| + |
| +const ( |
| + featureKeyMax = 64 |
| + featureValueMax = 256 |
| +) |
| + |
| +// Features is the wire shape at apps/<acct>/<app>/features.json. |
| +type Features struct { |
| + Schema int `json:"schema"` // 1 |
| + App string `json:"app"` // must agree with the key path |
| + Features map[string]string `json:"features"` |
| + SetBy string `json:"set_by,omitempty"` |
| + SetAt time.Time `json:"set_at"` |
| +} |
| + |
| +// FeaturesKey is the bucket key of app's feature-flags record, derived from |
| +// AppPrefix like every other per-app key. |
| +func FeaturesKey(acct, app string) string { return AppPrefix(acct, app) + "features.json" } |
| + |
| +// ValidFeatureKey reports whether k is a well-formed feature key: 1-64 |
| +// bytes of lowercase ASCII letters, digits, '_' and '-'. Lowercase only so |
| +// that "Sandbox" and "sandbox" can never be two switches. |
| +func ValidFeatureKey(k string) bool { |
| + if k == "" || len(k) > featureKeyMax { |
| + return false |
| + } |
| + for _, r := range k { |
| + switch { |
| + case r >= 'a' && r <= 'z', r >= '0' && r <= '9', r == '_', r == '-': |
| + default: |
| + return false |
| + } |
| + } |
| + return true |
| +} |
| + |
| +// ValidFeatureValue reports whether v is a well-formed feature value: 1-256 |
| +// bytes of printable characters. Empty is not a value — the CLI spells |
| +// "clear this key" as `key=`, and that must arrive as an unset, never as a |
| +// stored empty string an app then has to treat as a third state. |
| +func ValidFeatureValue(v string) bool { |
| + if v == "" || len(v) > featureValueMax { |
| + return false |
| + } |
| + for _, r := range v { |
| + if !unicode.IsPrint(r) { |
| + return false |
| + } |
| + } |
| + return true |
| +} |
| + |
| +// GetFeatures reads the app's record. (nil, nil) when absent — no features |
| +// is a legal permanent state. A record that lies about identity or carries |
| +// an unknown schema is refused, never repaired. A present record always |
| +// carries a non-nil map, so consumers range over it without a nil check. |
| +func GetFeatures(ctx context.Context, s Store, acct, app string) (*Features, error) { |
| + rec, _, err := getFeatures(ctx, s, acct, app) |
| + return rec, err |
| +} |
| + |
| +func getFeatures(ctx context.Context, s Store, acct, app string) (*Features, string, error) { |
| + key := FeaturesKey(acct, app) |
| + raw, etag, err := s.Get(ctx, key) |
| + if errors.Is(err, os.ErrNotExist) { |
| + return nil, "", nil |
| + } |
| + if err != nil { |
| + return nil, "", err |
| + } |
| + var rec Features |
| + if err := json.Unmarshal(raw, &rec); err != nil { |
| + return nil, "", fmt.Errorf("features at %s: %w", key, err) |
| + } |
| + if rec.Schema != 1 { |
| + return nil, "", fmt.Errorf("features at %s: unknown schema %d", key, rec.Schema) |
| + } |
| + if rec.App != app { |
| + return nil, "", fmt.Errorf("features at %s names app %q", key, rec.App) |
| + } |
| + if rec.Features == nil { |
| + rec.Features = map[string]string{} |
| + } |
| + return &rec, etag, nil |
| +} |
| + |
| +// UpdateFeatures applies set (key -> value) and unset (keys) onto the app's |
| +// current map and writes the result. It returns the record as it now |
| +// stands and whether this call changed anything: a set to the value already |
| +// held, or an unset of an absent key, is a no-op that rewrites nothing — |
| +// so set_by/set_at keep naming the last REAL change and a door can skip the |
| +// audit row. |
| +// |
| +// Every key and value is validated before anything is read or written, so a |
| +// call with one bad entry lands nothing. CAS with a short retry, the |
| +// retention pattern: two writers racing is last-writer-wins on the MERGED |
| +// map, which is the right answer for a handful of switches — but a |
| +// conflict must re-read and re-merge, never blind-write. |
| +func UpdateFeatures(ctx context.Context, s Store, acct, app string, set map[string]string, unset []string, setBy string, now time.Time) (*Features, bool, error) { |
| + if !ValidAccount(acct) { |
| + return nil, false, fmt.Errorf("features: invalid account %q", acct) |
| + } |
| + if !ValidApp(app) { |
| + return nil, false, fmt.Errorf("features: invalid app %q", app) |
| + } |
| + for k, v := range set { |
| + if !ValidFeatureKey(k) { |
| + return nil, false, fmt.Errorf("features: invalid key %q — 1-%d of a-z, 0-9, '_' and '-'", k, featureKeyMax) |
| + } |
| + if !ValidFeatureValue(v) { |
| + return nil, false, fmt.Errorf("features: invalid value for %s — 1-%d printable characters", k, featureValueMax) |
| + } |
| + } |
| + for _, k := range unset { |
| + if !ValidFeatureKey(k) { |
| + return nil, false, fmt.Errorf("features: invalid key %q — 1-%d of a-z, 0-9, '_' and '-'", k, featureKeyMax) |
| + } |
| + } |
| + key := FeaturesKey(acct, app) |
| + var lastErr error |
| + for attempt := 0; attempt < 3; attempt++ { |
| + cur, etag, err := getFeatures(ctx, s, acct, app) |
| + if err != nil { |
| + return nil, false, err |
| + } |
| + next := &Features{Schema: 1, App: app, Features: map[string]string{}} |
| + if cur != nil { |
| + next.SetBy, next.SetAt = cur.SetBy, cur.SetAt |
| + for k, v := range cur.Features { |
| + next.Features[k] = v |
| + } |
| + } |
| + changed := false |
| + for _, k := range unset { |
| + if _, ok := next.Features[k]; ok { |
| + delete(next.Features, k) |
| + changed = true |
| + } |
| + } |
| + // Sorted so a cap refusal is deterministic across calls with the |
| + // same input, whatever order the map ranges in. |
| + keys := make([]string, 0, len(set)) |
| + for k := range set { |
| + keys = append(keys, k) |
| + } |
| + sort.Strings(keys) |
| + for _, k := range keys { |
| + if next.Features[k] == set[k] { |
| + continue |
| + } |
| + if _, exists := next.Features[k]; !exists && len(next.Features) >= FeaturesMax { |
| + return nil, false, fmt.Errorf("features: at most %d keys per app", FeaturesMax) |
| + } |
| + next.Features[k] = set[k] |
| + changed = true |
| + } |
| + if !changed { |
| + if cur != nil { |
| + return cur, false, nil |
| + } |
| + return next, false, nil |
| + } |
| + next.SetBy, next.SetAt = setBy, now.UTC() |
| + raw, err := json.Marshal(next) |
| + if err != nil { |
| + return nil, false, err |
| + } |
| + if _, err := s.Put(ctx, key, raw, etag); err != nil { |
| + if errors.Is(err, ErrCASConflict) { |
| + lastErr = err |
| + continue |
| + } |
| + return nil, false, err |
| + } |
| + return next, true, nil |
| + } |
| + return nil, false, fmt.Errorf("features: could not write %s: %w", key, lastErr) |
| +} |
diff --git a/internal/release/features_test.go b/internal/release/features_test.go| new file mode 100644 |
| index 000000000..c3636388b |
| --- /dev/null |
| +++ b/internal/release/features_test.go |
| @@ -0,0 +1,159 @@ |
| +package release |
| + |
| +import ( |
| + "context" |
| + "strings" |
| + "testing" |
| + "time" |
| +) |
| + |
| +func TestFeaturesRoundTrip(t *testing.T) { |
| + ctx := context.Background() |
| + s := NewDirStore(t.TempDir()) |
| + now := time.Date(2026, 8, 28, 12, 0, 0, 0, time.UTC) |
| + |
| + // Absent is legal forever: no features, not an error. |
| + got, err := GetFeatures(ctx, s, "bab", "titogo") |
| + if err != nil || got != nil { |
| + t.Fatalf("absent features = %v, %v; want nil, nil", got, err) |
| + } |
| + |
| + rec, changed, err := UpdateFeatures(ctx, s, "bab", "titogo", map[string]string{"sandbox": "on"}, nil, "alice@keymail.dev", now) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if !changed || rec.Features["sandbox"] != "on" || rec.SetBy != "alice@keymail.dev" || !rec.SetAt.Equal(now) { |
| + t.Fatalf("first set = %+v changed=%v, want sandbox=on by alice, changed", rec, changed) |
| + } |
| + got, err = GetFeatures(ctx, s, "bab", "titogo") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if got == nil || got.Features["sandbox"] != "on" || got.Schema != 1 || got.App != "titogo" { |
| + t.Fatalf("features = %+v, want schema 1 titogo sandbox=on", got) |
| + } |
| + |
| + // A second set MERGES: the existing key survives, the new one lands. |
| + rec, changed, err = UpdateFeatures(ctx, s, "bab", "titogo", map[string]string{"beta": "1"}, nil, "bob@keymail.dev", now.Add(time.Hour)) |
| + if err != nil || !changed { |
| + t.Fatalf("merge set: changed=%v err=%v", changed, err) |
| + } |
| + if rec.Features["sandbox"] != "on" || rec.Features["beta"] != "1" || rec.SetBy != "bob@keymail.dev" { |
| + t.Fatalf("after merge = %+v, want both keys, set by bob", rec) |
| + } |
| + |
| + // Setting the same value again changes nothing — and says so, so the |
| + // door can skip the audit row. |
| + _, changed, err = UpdateFeatures(ctx, s, "bab", "titogo", map[string]string{"beta": "1"}, nil, "carol", now.Add(2*time.Hour)) |
| + if err != nil || changed { |
| + t.Fatalf("no-op set: changed=%v err=%v, want false, nil", changed, err) |
| + } |
| + got, _ = GetFeatures(ctx, s, "bab", "titogo") |
| + if got.SetBy != "bob@keymail.dev" { |
| + t.Fatalf("a no-op set rewrote the record: set_by %q", got.SetBy) |
| + } |
| + |
| + // Unset removes a key; unsetting an absent key is a no-op. |
| + rec, changed, err = UpdateFeatures(ctx, s, "bab", "titogo", nil, []string{"beta", "never"}, "dave", now.Add(3*time.Hour)) |
| + if err != nil || !changed { |
| + t.Fatalf("unset: changed=%v err=%v", changed, err) |
| + } |
| + if _, ok := rec.Features["beta"]; ok || rec.Features["sandbox"] != "on" { |
| + t.Fatalf("after unset = %+v, want only sandbox", rec.Features) |
| + } |
| + _, changed, err = UpdateFeatures(ctx, s, "bab", "titogo", nil, []string{"never"}, "dave", now.Add(4*time.Hour)) |
| + if err != nil || changed { |
| + t.Fatalf("unset absent: changed=%v err=%v, want false, nil", changed, err) |
| + } |
| + |
| + // Unsetting the last key leaves an EMPTY record, not an absent one: the |
| + // record's set_at still tells a reader when the last change happened. |
| + rec, _, err = UpdateFeatures(ctx, s, "bab", "titogo", nil, []string{"sandbox"}, "erin", now.Add(5*time.Hour)) |
| + if err != nil || len(rec.Features) != 0 { |
| + t.Fatalf("after last unset = %+v err=%v", rec, err) |
| + } |
| + got, err = GetFeatures(ctx, s, "bab", "titogo") |
| + if err != nil || got == nil || len(got.Features) != 0 || got.SetBy != "erin" { |
| + t.Fatalf("empty record = %+v, %v; want present, empty, by erin", got, err) |
| + } |
| +} |
| + |
| +// Keys and values are the app's own vocabulary — but they are also bucket |
| +// bytes every consumer parses, so the shape is bounded: short lowercase |
| +// keys, short printable values, and a cap on how many. |
| +func TestUpdateFeaturesRefusesBadShapes(t *testing.T) { |
| + ctx := context.Background() |
| + s := NewDirStore(t.TempDir()) |
| + now := time.Now() |
| + cases := []struct { |
| + name string |
| + set map[string]string |
| + want string |
| + }{ |
| + {"uppercase key", map[string]string{"Sandbox": "on"}, "key"}, |
| + {"empty key", map[string]string{"": "on"}, "key"}, |
| + {"key with space", map[string]string{"sand box": "on"}, "key"}, |
| + {"key too long", map[string]string{strings.Repeat("k", 65): "on"}, "key"}, |
| + {"empty value", map[string]string{"sandbox": ""}, "value"}, |
| + {"value too long", map[string]string{"sandbox": strings.Repeat("v", 257)}, "value"}, |
| + {"control char in value", map[string]string{"sandbox": "on\n"}, "value"}, |
| + } |
| + for _, c := range cases { |
| + t.Run(c.name, func(t *testing.T) { |
| + _, _, err := UpdateFeatures(ctx, s, "bab", "titogo", c.set, nil, "x", now) |
| + if err == nil || !strings.Contains(err.Error(), c.want) { |
| + t.Fatalf("err = %v, want one naming the %s", err, c.want) |
| + } |
| + }) |
| + } |
| + if got, err := GetFeatures(ctx, s, "bab", "titogo"); err != nil || got != nil { |
| + t.Fatalf("a refused set wrote something: %+v, %v", got, err) |
| + } |
| + |
| + // The count cap: FeaturesMax keys is fine, one more is refused — |
| + // whether they arrive at once or on top of an existing record. |
| + set := map[string]string{} |
| + for i := 0; i < FeaturesMax; i++ { |
| + set["k"+strings.Repeat("x", i%3)+string(rune('a'+i%26))+string(rune('a'+i/26))] = "v" |
| + } |
| + if len(set) != FeaturesMax { |
| + t.Fatalf("test bug: %d keys", len(set)) |
| + } |
| + if _, _, err := UpdateFeatures(ctx, s, "bab", "titogo", set, nil, "x", now); err != nil { |
| + t.Fatalf("%d keys refused: %v", FeaturesMax, err) |
| + } |
| + _, _, err := UpdateFeatures(ctx, s, "bab", "titogo", map[string]string{"one-more": "v"}, nil, "x", now) |
| + if err == nil || !strings.Contains(err.Error(), "at most") { |
| + t.Fatalf("key %d = %v, want a cap error", FeaturesMax+1, err) |
| + } |
| + // Replacing an existing key at the cap is not an addition. |
| + if _, _, err := UpdateFeatures(ctx, s, "bab", "titogo", map[string]string{"kaa": "changed"}, nil, "x", now); err != nil { |
| + t.Fatalf("replacing at the cap refused: %v", err) |
| + } |
| +} |
| + |
| +func TestGetFeaturesRefusesLiesAndUnknownSchema(t *testing.T) { |
| + ctx := context.Background() |
| + s := NewDirStore(t.TempDir()) |
| + put := func(body string) { |
| + s.Delete(ctx, FeaturesKey("bab", "titogo")) |
| + if _, err := s.Put(ctx, FeaturesKey("bab", "titogo"), []byte(body), ""); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + put(`{"schema":1,"app":"other","features":{"a":"b"},"set_at":"2026-08-28T12:00:00Z"}`) |
| + if _, err := GetFeatures(ctx, s, "bab", "titogo"); err == nil { |
| + t.Fatal("a body naming a different app was accepted") |
| + } |
| + put(`{"schema":2,"app":"titogo","features":{"a":"b"},"set_at":"2026-08-28T12:00:00Z"}`) |
| + if _, err := GetFeatures(ctx, s, "bab", "titogo"); err == nil { |
| + t.Fatal("an unknown schema was accepted") |
| + } |
| + // A record with no features map at all reads as empty, never nil — |
| + // consumers range over it without a nil check. |
| + put(`{"schema":1,"app":"titogo","set_at":"2026-08-28T12:00:00Z"}`) |
| + got, err := GetFeatures(ctx, s, "bab", "titogo") |
| + if err != nil || got == nil || got.Features == nil { |
| + t.Fatalf("missing map = %+v, %v; want a non-nil empty map", got, err) |
| + } |
| +} |
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 |