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` doors (issue #347): the app-defined switches the
2// platform stores and serves but never interprets — `sandbox=on` for
3// titogo's router — written here, audited, and read back by the app off the
4// deployment block the domains doors already answer with (domainCapability,
5// domainstate.go). This is managed_domains generalized: the platform owns
6// the write path so that enabling an app capability is never an app editing
7// root-owned env on an edge box.
8//
9// Gate order is handleAPIRetentionSet's exactly — token, one byte-identical
10// 404 for every identity failure, the shared per-principal write budget, 503
11// when the console has no bucket. What differs is what happens AFTER a
12// successful write: no serial bump. Nothing on a box reads features.json;
13// the app polls the console for it, so announcing a write would send every
14// box looking for a change none of them can see.
15//
16// Roles (authcli.go): the app's own instance credential READS — it is the
17// consumer — and never writes. A switch an app could flip on itself would
18// be a root env flag by another name, the exact thing this door retires.
19package console
20
21import (
22 "context"
23 "log"
24 "net/http"
25 "sort"
26 "strconv"
27 "strings"
28 "time"
29
30 "github.com/carlosframework/platform/internal/release"
31)
32
33// apiFeaturesResponse is the one wire shape both doors answer with.
34// Features is never omitted and never null on a successful answer: an app
35// ranges over it, and "no flags set" is spelled {}.
36type apiFeaturesResponse struct {
37 Account string `json:"account"`
38 App string `json:"app"`
39 Features map[string]string `json:"features"`
40 SetBy string `json:"set_by,omitempty"`
41 SetAt string `json:"set_at,omitempty"`
42 // Changed is the set door's answer only: whether this call wrote
43 // anything. Absent on a read.
44 Changed *bool `json:"changed,omitempty"`
45}
46
47func featuresResponse(acct, app string, rec *release.Features) apiFeaturesResponse {
48 resp := apiFeaturesResponse{Account: acct, App: app, Features: map[string]string{}}
49 if rec != nil {
50 resp.Features = rec.Features
51 resp.SetBy = rec.SetBy
52 if !rec.SetAt.IsZero() {
53 resp.SetAt = rec.SetAt.UTC().Format(time.RFC3339)
54 }
55 }
56 return resp
57}
58
59// handleAPIFeaturesGet answers `carlos features list -app X`. No write
60// budget and no audit: it is a read. An unreadable record is a 502, not an
61// empty map — the dedicated read must never tell an operator "nothing set"
62// about a record it could not parse.
63func (s *Server) handleAPIFeaturesGet(w http.ResponseWriter, r *http.Request) {
64 _, acct, app, ok := s.apiAccountApp(w, r)
65 if !ok {
66 return
67 }
68 if s.cfg.Releases == nil {
69 jsonError(w, http.StatusServiceUnavailable,
70 "no deployment bucket on this host — this console cannot read features")
71 return
72 }
73 ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second)
74 defer cancel()
75 rec, err := release.GetFeatures(ctx, s.cfg.Releases, acct.Sqid, app)
76 if err != nil {
77 log.Printf("console: api features get %s/%s: %v", acct.Sqid, app, err)
78 jsonError(w, http.StatusBadGateway, "could not read the app's features — try again")
79 return
80 }
81 writeJSON(w, http.StatusOK, featuresResponse(acct.Sqid, app, rec))
82}
83
84// handleAPIFeaturesSet answers `carlos features set -app X k=v [k=]`: set
85// merges onto the current map, unset removes keys, in one CAS-guarded
86// write. Only an actual CHANGE audits — a set to the value already held is
87// a no-op, and a row per no-op would bury the app's log under a caller
88// re-asserting the same switch on every deploy.
89func (s *Server) handleAPIFeaturesSet(w http.ResponseWriter, r *http.Request) {
90 _, acct, app, ok := s.apiAccountApp(w, r)
91 if !ok {
92 return
93 }
94 p, _ := principalFrom(r.Context())
95 if !s.allowWrite(p, time.Now()) {
96 jsonError(w, http.StatusTooManyRequests, "too many requests — wait a minute and try again")
97 return
98 }
99 if s.cfg.Releases == nil {
100 jsonError(w, http.StatusServiceUnavailable,
101 "no deployment bucket on this host — this console cannot write features")
102 return
103 }
104 var in struct {
105 Set map[string]string `json:"set"`
106 Unset []string `json:"unset"`
107 Message string `json:"message"`
108 }
109 if !readJSON(w, r, &in) {
110 return
111 }
112 message, ok := checkedActivityMessage(w, in.Message)
113 if !ok {
114 return
115 }
116 if len(in.Set) == 0 && len(in.Unset) == 0 {
117 jsonError(w, http.StatusBadRequest, "nothing to do — name at least one key to set or unset")
118 return
119 }
120 // Shape-checked HERE, before the store is touched, so a bad key is a
121 // 400 the caller can fix rather than a 502 they cannot — and so the
122 // message names the rule, not just the refusal.
123 for k, v := range in.Set {
124 if !release.ValidFeatureKey(k) {
125 jsonError(w, http.StatusBadRequest, "invalid key "+k+" — keys are 1-64 of a-z, 0-9, '_' and '-'")
126 return
127 }
128 if !release.ValidFeatureValue(v) {
129 jsonError(w, http.StatusBadRequest, "invalid value for "+k+" — values are 1-256 printable characters (to clear a key, unset it)")
130 return
131 }
132 }
133 for _, k := range in.Unset {
134 if !release.ValidFeatureKey(k) {
135 jsonError(w, http.StatusBadRequest, "invalid key "+k+" — keys are 1-64 of a-z, 0-9, '_' and '-'")
136 return
137 }
138 }
139 principal := auditActor(p, acct)
140 ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second)
141 defer cancel()
142 rec, changed, err := release.UpdateFeatures(ctx, s.cfg.Releases, acct.Sqid, app, in.Set, in.Unset, principal, time.Now())
143 if err != nil {
144 if strings.Contains(err.Error(), "at most") {
145 jsonError(w, http.StatusBadRequest, "an app may hold at most "+strconv.Itoa(release.FeaturesMax)+" features")
146 return
147 }
148 log.Printf("console: api features set %s/%s: %v", acct.Sqid, app, err)
149 jsonError(w, http.StatusBadGateway, "could not write the app's features — nothing was changed; try again")
150 return
151 }
152 if changed {
153 s.auditActivity(acct, app, principal, Activity{
154 Action: "features", TargetType: "app", Target: app, Message: message,
155 Detail: featuresAuditDetail(in.Set, in.Unset),
156 })
157 }
158 resp := featuresResponse(acct.Sqid, app, rec)
159 resp.Changed = &changed
160 writeJSON(w, http.StatusOK, resp)
161}
162
163// featuresAuditDetail spells one call out the way the operator typed it,
164// keys sorted so two audits of the same call read the same.
165func featuresAuditDetail(set map[string]string, unset []string) string {
166 keys := make([]string, 0, len(set))
167 for k := range set {
168 keys = append(keys, k)
169 }
170 sort.Strings(keys)
171 parts := make([]string, 0, len(keys)+1)
172 for _, k := range keys {
173 parts = append(parts, k+"="+set[k])
174 }
175 if len(unset) > 0 {
176 u := append([]string(nil), unset...)
177 sort.Strings(u)
178 parts = append(parts, "unset "+strings.Join(u, ", "))
179 }
180 return "set features: " + strings.Join(parts, ", ") + " (cli)"
181}
182
183// appFeatures is the deployment-block read (domainCapability): the app's
184// map, plus when it last changed. Best-effort by design — this rides on the
185// domains answer, and a features record the console cannot read must not
186// take a domains listing down with it. The nil map (JSON null) is the
187// "unknown" reading; an app tells it from {} ("none set") by that.
188func (s *Server) appFeatures(ctx context.Context, acct, app string) (map[string]string, string) {
189 if s.cfg.Releases == nil {
190 return nil, ""
191 }
192 rec, err := release.GetFeatures(ctx, s.cfg.Releases, acct, app)
193 if err != nil {
194 log.Printf("console: api features block %s/%s: %v", acct, app, err)
195 return nil, ""
196 }
197 if rec == nil {
198 return map[string]string{}, ""
199 }
200 setAt := ""
201 if !rec.SetAt.IsZero() {
202 setAt = rec.SetAt.UTC().Format(time.RFC3339)
203 }
204 return rec.Features, setAt
205}
206