carlos / platform Public

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

Plain git — no account needed to clone.

Download

Download this file

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`'.
19package release
20
21import (
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.
35const FeaturesMax = 64
36
37const (
38 featureKeyMax = 64
39 featureValueMax = 256
40)
41
42// Features is the wire shape at apps/<acct>/<app>/features.json.
43type 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.
53func 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.
58func 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.
76func 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.
92func 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
97func 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.
134func 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}
214func 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