| 1 | // The app feature-flags record (issue #347): one small unsigned object per |
| 2 | // app holding the switches the APP defines and the platform merely stores |
| 3 | // and serves — `{"sandbox": "on"}` — read back by the app off the deployment |
| 4 | // block it already fetches. The platform never interprets a key or a value; |
| 5 | // what it owns is the write path (a console door, audited) so that enabling |
| 6 | // an app capability is never an app editing root-owned env on an edge box. |
| 7 | // |
| 8 | // Bucket layout addition: |
| 9 | // |
| 10 | // apps/<acct>/<app>/features.json CAS-guarded, schema-1 record |
| 11 | // |
| 12 | // ABSENT IS LEGAL FOREVER: no record means no features set — the |
| 13 | // retention.json convention. Nothing on a box reads this key; the only |
| 14 | // consumer is the console, so the shape is purely additive. |
| 15 | // |
| 16 | // Not secrets, not config: values are short strings. Anything an app wants |
| 17 | // delivered into a process environment is `carlos env`'s job, and anything |
| 18 | // it wants sealed is `carlos secrets`'. |
| 19 | package release |
| 20 | |
| 21 | import ( |
| 22 | "context" |
| 23 | "encoding/json" |
| 24 | "errors" |
| 25 | "fmt" |
| 26 | "os" |
| 27 | "sort" |
| 28 | "time" |
| 29 | "unicode" |
| 30 | ) |
| 31 | |
| 32 | // FeaturesMax caps how many keys one app's record may hold. The record is |
| 33 | // read on every domains-door answer, so it stays a handful of switches |
| 34 | // rather than growing into a config store by another name. |
| 35 | const FeaturesMax = 64 |
| 36 | |
| 37 | const ( |
| 38 | featureKeyMax = 64 |
| 39 | featureValueMax = 256 |
| 40 | ) |
| 41 | |
| 42 | // Features is the wire shape at apps/<acct>/<app>/features.json. |
| 43 | type Features struct { |
| 44 | Schema int `json:"schema"` // 1 |
| 45 | App string `json:"app"` // must agree with the key path |
| 46 | Features map[string]string `json:"features"` |
| 47 | SetBy string `json:"set_by,omitempty"` |
| 48 | SetAt time.Time `json:"set_at"` |
| 49 | } |
| 50 | |
| 51 | // FeaturesKey is the bucket key of app's feature-flags record, derived from |
| 52 | // AppPrefix like every other per-app key. |
| 53 | func FeaturesKey(acct, app string) string { return AppPrefix(acct, app) + "features.json" } |
| 54 | |
| 55 | // ValidFeatureKey reports whether k is a well-formed feature key: 1-64 |
| 56 | // bytes of lowercase ASCII letters, digits, '_' and '-'. Lowercase only so |
| 57 | // that "Sandbox" and "sandbox" can never be two switches. |
| 58 | func ValidFeatureKey(k string) bool { |
| 59 | if k == "" || len(k) > featureKeyMax { |
| 60 | return false |
| 61 | } |
| 62 | for _, r := range k { |
| 63 | switch { |
| 64 | case r >= 'a' && r <= 'z', r >= '0' && r <= '9', r == '_', r == '-': |
| 65 | default: |
| 66 | return false |
| 67 | } |
| 68 | } |
| 69 | return true |
| 70 | } |
| 71 | |
| 72 | // ValidFeatureValue reports whether v is a well-formed feature value: 1-256 |
| 73 | // bytes of printable characters. Empty is not a value — the CLI spells |
| 74 | // "clear this key" as `key=`, and that must arrive as an unset, never as a |
| 75 | // stored empty string an app then has to treat as a third state. |
| 76 | func ValidFeatureValue(v string) bool { |
| 77 | if v == "" || len(v) > featureValueMax { |
| 78 | return false |
| 79 | } |
| 80 | for _, r := range v { |
| 81 | if !unicode.IsPrint(r) { |
| 82 | return false |
| 83 | } |
| 84 | } |
| 85 | return true |
| 86 | } |
| 87 | |
| 88 | // GetFeatures reads the app's record. (nil, nil) when absent — no features |
| 89 | // is a legal permanent state. A record that lies about identity or carries |
| 90 | // an unknown schema is refused, never repaired. A present record always |
| 91 | // carries a non-nil map, so consumers range over it without a nil check. |
| 92 | func GetFeatures(ctx context.Context, s Store, acct, app string) (*Features, error) { |
| 93 | rec, _, err := getFeatures(ctx, s, acct, app) |
| 94 | return rec, err |
| 95 | } |
| 96 | |
| 97 | func getFeatures(ctx context.Context, s Store, acct, app string) (*Features, string, error) { |
| 98 | key := FeaturesKey(acct, app) |
| 99 | raw, etag, err := s.Get(ctx, key) |
| 100 | if errors.Is(err, os.ErrNotExist) { |
| 101 | return nil, "", nil |
| 102 | } |
| 103 | if err != nil { |
| 104 | return nil, "", err |
| 105 | } |
| 106 | var rec Features |
| 107 | if err := json.Unmarshal(raw, &rec); err != nil { |
| 108 | return nil, "", fmt.Errorf("features at %s: %w", key, err) |
| 109 | } |
| 110 | if rec.Schema != 1 { |
| 111 | return nil, "", fmt.Errorf("features at %s: unknown schema %d", key, rec.Schema) |
| 112 | } |
| 113 | if rec.App != app { |
| 114 | return nil, "", fmt.Errorf("features at %s names app %q", key, rec.App) |
| 115 | } |
| 116 | if rec.Features == nil { |
| 117 | rec.Features = map[string]string{} |
| 118 | } |
| 119 | return &rec, etag, nil |
| 120 | } |
| 121 | |
| 122 | // UpdateFeatures applies set (key -> value) and unset (keys) onto the app's |
| 123 | // current map and writes the result. It returns the record as it now |
| 124 | // stands and whether this call changed anything: a set to the value already |
| 125 | // held, or an unset of an absent key, is a no-op that rewrites nothing — |
| 126 | // so set_by/set_at keep naming the last REAL change and a door can skip the |
| 127 | // audit row. |
| 128 | // |
| 129 | // Every key and value is validated before anything is read or written, so a |
| 130 | // call with one bad entry lands nothing. CAS with a short retry, the |
| 131 | // retention pattern: two writers racing is last-writer-wins on the MERGED |
| 132 | // map, which is the right answer for a handful of switches — but a |
| 133 | // conflict must re-read and re-merge, never blind-write. |
| 134 | func claimRawUpdateFeatures(ctx context.Context, s Store, acct, app string, set map[string]string, unset []string, setBy string, now time.Time) (*Features, bool, error) { |
| 135 | if !ValidAccount(acct) { |
| 136 | return nil, false, fmt.Errorf("features: invalid account %q", acct) |
| 137 | } |
| 138 | if !ValidApp(app) { |
| 139 | return nil, false, fmt.Errorf("features: invalid app %q", app) |
| 140 | } |
| 141 | for k, v := range set { |
| 142 | if !ValidFeatureKey(k) { |
| 143 | return nil, false, fmt.Errorf("features: invalid key %q — 1-%d of a-z, 0-9, '_' and '-'", k, featureKeyMax) |
| 144 | } |
| 145 | if !ValidFeatureValue(v) { |
| 146 | return nil, false, fmt.Errorf("features: invalid value for %s — 1-%d printable characters", k, featureValueMax) |
| 147 | } |
| 148 | } |
| 149 | for _, k := range unset { |
| 150 | if !ValidFeatureKey(k) { |
| 151 | return nil, false, fmt.Errorf("features: invalid key %q — 1-%d of a-z, 0-9, '_' and '-'", k, featureKeyMax) |
| 152 | } |
| 153 | } |
| 154 | key := FeaturesKey(acct, app) |
| 155 | var lastErr error |
| 156 | for attempt := 0; attempt < 3; attempt++ { |
| 157 | cur, etag, err := getFeatures(ctx, s, acct, app) |
| 158 | if err != nil { |
| 159 | return nil, false, err |
| 160 | } |
| 161 | next := &Features{Schema: 1, App: app, Features: map[string]string{}} |
| 162 | if cur != nil { |
| 163 | next.SetBy, next.SetAt = cur.SetBy, cur.SetAt |
| 164 | for k, v := range cur.Features { |
| 165 | next.Features[k] = v |
| 166 | } |
| 167 | } |
| 168 | changed := false |
| 169 | for _, k := range unset { |
| 170 | if _, ok := next.Features[k]; ok { |
| 171 | delete(next.Features, k) |
| 172 | changed = true |
| 173 | } |
| 174 | } |
| 175 | // Sorted so a cap refusal is deterministic across calls with the |
| 176 | // same input, whatever order the map ranges in. |
| 177 | keys := make([]string, 0, len(set)) |
| 178 | for k := range set { |
| 179 | keys = append(keys, k) |
| 180 | } |
| 181 | sort.Strings(keys) |
| 182 | for _, k := range keys { |
| 183 | if next.Features[k] == set[k] { |
| 184 | continue |
| 185 | } |
| 186 | if _, exists := next.Features[k]; !exists && len(next.Features) >= FeaturesMax { |
| 187 | return nil, false, fmt.Errorf("features: at most %d keys per app", FeaturesMax) |
| 188 | } |
| 189 | next.Features[k] = set[k] |
| 190 | changed = true |
| 191 | } |
| 192 | if !changed { |
| 193 | if cur != nil { |
| 194 | return cur, false, nil |
| 195 | } |
| 196 | return next, false, nil |
| 197 | } |
| 198 | next.SetBy, next.SetAt = setBy, now.UTC() |
| 199 | raw, err := json.Marshal(next) |
| 200 | if err != nil { |
| 201 | return nil, false, err |
| 202 | } |
| 203 | if _, err := s.Put(ctx, key, raw, etag); err != nil { |
| 204 | if errors.Is(err, ErrCASConflict) { |
| 205 | lastErr = err |
| 206 | continue |
| 207 | } |
| 208 | return nil, false, err |
| 209 | } |
| 210 | return next, true, nil |
| 211 | } |
| 212 | return nil, false, fmt.Errorf("features: could not write %s: %w", key, lastErr) |
| 213 | } |
| 214 | func UpdateFeatures(ctx context.Context, s Store, acct, app string, set map[string]string, unset []string, setBy string, now time.Time) (claimResult0 *Features, claimResult1 bool, claimResult2 error) { |
| 215 | claimResult2 = WithAppMutation(ctx, s, acct, app, "release.UpdateFeatures", func(claimContext context.Context) error { |
| 216 | claimResult0, claimResult1, claimResult2 = claimRawUpdateFeatures(claimContext, s, acct, app, set, unset, setBy, now) |
| 217 | return claimResult2 |
| 218 | }) |
| 219 | return |
| 220 | } |
| 221 | |