carlos / platform Public

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

Plain git — no account needed to clone.

Download

Download this file

1// The instance update settings doors (instance update settings spec §3.3,
2// §4.4, §5.1): GET and PUT /api/cli/apps/{account}/{app}/instances/{host}/updates,
3// and POST …/updates/install ("Update now", handleAPIUpdatesInstall); and the
4// app's policy door, PUT /api/cli/apps/{account}/{app}/updates (§3.2,
5// handleAPIUpdatesPolicy), which writes the instances manifest.
6//
7// A member of the app sets an instance's preferences: updates (auto or
8// manual), a major hold on stable, and a pin to one authorized build. They
9// are preferences among builds §4.1 already authorizes, not a security
10// boundary (§2 decision 5), and the console never chooses a build: it writes
11// the record, and root decides on its next pass.
12//
13// Who: people only. Every service credential is refused at the role gate
14// (svcrole.Set{}): svcrole.Instance is app-wide, and the instance-bound
15// credential §3.3 limits to instance_control is #155 step 6.
16//
17// When: pin, manual and major are turned ON only while every box runs a
18// binary that applies them (§4.4, updatesEnforced); turning them off is
19// never refused. The member is told how many boxes are not ready; which
20// boxes is operator detail, logged here and never shown.
21package console
22
23import (
24 "context"
25 "errors"
26 "fmt"
27 "log"
28 "math"
29 "net/http"
30 "os"
31 "slices"
32 "sort"
33 "strings"
34 "unicode"
35
36 "github.com/carlosframework/platform/internal/provision"
37 "github.com/carlosframework/platform/internal/release"
38 "github.com/carlosframework/platform/internal/updatewords"
39)
40
41type updatesSettings struct {
42 Updates string `json:"updates"`
43 Channel string `json:"channel"`
44 Major *int `json:"major"`
45 Pin string `json:"pin"`
46}
47
48// updatesView is what both doors answer with. Settings are the effective
49// values, Explicit the record's own, and Enforced whether every box applies
50// them (plan 5 renders its absence as "settings not enforced").
51type updatesView struct {
52 Host string `json:"host"`
53 Settings updatesSettings `json:"settings"`
54 Explicit updatesSettings `json:"explicit"`
55 Effective release.UpdateSettings `json:"effective"`
56 Control []string `json:"control"`
57 InstallRequest *provision.InstallRequest `json:"install_request"`
58 Enforced bool `json:"enforced"`
59 MajorIgnored bool `json:"major_ignored"`
60}
61
62func (s *Server) updatesViewOf(ctx context.Context, rec provision.Record, policy *provision.UpdatePolicy) updatesView {
63 v := updatesViewFor(rec, policy)
64 v.Enforced, _, _ = s.updatesEnforced(ctx)
65 return v
66}
67
68// updatesViewFor is updatesViewOf without Enforced, which reads every box's
69// heartbeat: for a caller that needs only the settings (the page's Save).
70func updatesViewFor(rec provision.Record, policy *provision.UpdatePolicy) updatesView {
71 eff := provision.EffectiveUpdates(rec, policy)
72 return updatesView{
73 Host: rec.Host,
74 Settings: updatesSettings{Updates: eff.Updates, Channel: eff.Channel, Major: eff.Major, Pin: eff.Pin},
75 Explicit: updatesSettings{Updates: rec.Updates, Channel: rec.Channel, Major: rec.Major, Pin: rec.Pin},
76 Effective: eff,
77 Control: append([]string{}, eff.Control...),
78 InstallRequest: rec.InstallRequest,
79 MajorIgnored: rec.Major != nil && eff.Major == nil,
80 }
81}
82
83// updatesTarget reads the record and the app policy for a settings door,
84// answering the family's 404 for a host with no record. ok=false means a
85// response has been written.
86func (s *Server) updatesTarget(w http.ResponseWriter, r *http.Request, ctx context.Context, acct *Account, app string) (*provision.Record, string, *provision.UpdatePolicy, bool) {
87 rec, etag, policy, st, msg := s.readUpdatesTarget(ctx, acct, app, r.PathValue("host"))
88 if st != 0 {
89 answerUpdates(w, r, st, msg)
90 return nil, "", nil, false
91 }
92 return rec, etag, policy, true
93}
94
95// handleAPIUpdatesGet is GET …/instances/{host}/updates (spec §5.1).
96func (s *Server) handleAPIUpdatesGet(w http.ResponseWriter, r *http.Request) {
97 _, acct, app, ok := s.instanceGate(w, r)
98 if !ok {
99 return
100 }
101 ctx, cancel := context.WithTimeout(r.Context(), instanceStoreTimeout)
102 defer cancel()
103 rec, _, policy, ok := s.updatesTarget(w, r, ctx, acct, app)
104 if !ok {
105 return
106 }
107 writeJSON(w, http.StatusOK, s.updatesViewOf(ctx, *rec, policy))
108}
109
110// handleAPIUpdatesPut is PUT …/instances/{host}/updates: a member sets any of
111// updates, major and pin; a key present with "" or null clears it, and any
112// other value of the wrong type is refused rather than read as a clear. It
113// writes preferences only: the console never chooses a build (spec §4).
114func (s *Server) handleAPIUpdatesPut(w http.ResponseWriter, r *http.Request) {
115 principal, acct, app, ok := s.instanceGate(w, r)
116 if !ok {
117 return
118 }
119 var req map[string]any
120 if !readJSON(w, r, &req) {
121 return
122 }
123 if _, has := req["channel"]; has {
124 jsonError(w, http.StatusBadRequest, updatewords.Text("settings.console.channel-elsewhere"))
125 return
126 }
127 ctx, cancel := context.WithTimeout(r.Context(), instanceStoreTimeout)
128 defer cancel()
129 rec, etag, policy, ok := s.updatesTarget(w, r, ctx, acct, app)
130 if !ok {
131 return
132 }
133 view, st, msg := s.applyUpdates(ctx, acct, app, principal, rec, etag, policy, req)
134 if st != http.StatusOK {
135 answerUpdates(w, r, st, msg)
136 return
137 }
138 writeJSON(w, http.StatusOK, view)
139}
140
141// enableAllowed is the §4.4 gate both settings doors put in front of a change
142// that turns a setting ON. It answers true when every box applies update
143// settings; otherwise it writes the refusal and answers false. A member is
144// told how many boxes are not ready (R3); which boxes is operator detail,
145// logged with what (the target the log line names) and never shown.
146func (s *Server) enableAllowed(ctx context.Context, w http.ResponseWriter, what string) bool {
147 if st, msg := s.enableRefusal(ctx, what); st != 0 {
148 jsonError(w, st, msg)
149 return false
150 }
151 return true
152}
153
154// updatesValue reads an updates value from a JSON value: "auto" or "manual"
155// sets it, null or "" clears it (""), and anything else is refused.
156func updatesValue(v any) (string, bool) {
157 str, isStr := v.(string)
158 if (v != nil && !isStr) || (str != "" && str != release.UpdatesAuto && str != release.UpdatesManual) {
159 return "", false
160 }
161 return str, true
162}
163
164// majorValue reads a major hold from a JSON value: a whole number from 0 to
165// MaxInt32 sets it, null or "" clears it (nil), and anything else is refused.
166func majorValue(v any) (*int, bool) {
167 switch n := v.(type) {
168 case nil:
169 return nil, true
170 case float64:
171 if n < 0 || n > math.MaxInt32 || n != math.Trunc(n) {
172 return nil, false
173 }
174 i := int(n)
175 return &i, true
176 case string:
177 return nil, n == ""
178 default:
179 return nil, false
180 }
181}
182
183// updatesChanges names what differs between the record before and after, in
184// the activity line's words: "updates manual", "major 2", "pin cleared".
185func updatesChanges(before, after provision.Record) []string {
186 var out []string
187 if before.Updates != after.Updates {
188 out = append(out, "updates "+orCleared(after.Updates))
189 }
190 if !sameMajor(before.Major, after.Major) {
191 if after.Major == nil {
192 out = append(out, "major cleared")
193 } else {
194 out = append(out, fmt.Sprintf("major %d", *after.Major))
195 }
196 }
197 if before.Pin != after.Pin {
198 out = append(out, "pin "+orCleared(after.Pin))
199 }
200 return out
201}
202
203func sameMajor(a, b *int) bool {
204 if a == nil || b == nil {
205 return a == nil && b == nil
206 }
207 return *a == *b
208}
209
210func orCleared(v string) string {
211 if v == "" {
212 return "cleared"
213 }
214 return v
215}
216
217// shownValue is a refused value as a member may be shown it: printable
218// characters only, at most 64 of them, and "that" when nothing is left.
219func shownValue(v string) string {
220 v = strings.Map(func(r rune) rune {
221 if unicode.IsPrint(r) {
222 return r
223 }
224 return -1
225 }, v)
226 if rs := []rune(v); len(rs) > 64 {
227 v = string(rs[:64]) + "..."
228 }
229 if v == "" {
230 return "that"
231 }
232 return v
233}
234
235// handleAPIUpdatesInstall is POST …/instances/{host}/updates/install: "Update
236// now" (spec §4.2, §4.3 rule 2). It raises the record's install_request id by
237// one under the record's etag, retrying a lost race, so ids only ever
238// increase and no request is lost. Root resolves it to a build; the console
239// never names one.
240//
241// It is not one of the settings §4.4 holds until every box applies them: a
242// request a box does not yet act on asks for nothing it could not already get.
243// fast_track (skip the bake window) needs an owner of the account, the
244// authority the console's own fast-track door requires (index Q7). The API
245// cannot run that door's passkey ceremony, so it asks for the role alone.
246func (s *Server) handleAPIUpdatesInstall(w http.ResponseWriter, r *http.Request) {
247 principal, acct, app, ok := s.instanceGate(w, r)
248 if !ok {
249 return
250 }
251 var req struct {
252 FastTrack bool `json:"fast_track"`
253 }
254 if !readJSON(w, r, &req) {
255 return
256 }
257 if req.FastTrack {
258 role, err := s.store.Role(acct.ID, principal)
259 if err != nil {
260 log.Printf("console: api updates install %s/%s: fast track refused, the role could not be read: %v", acct.Sqid, app, err)
261 }
262 if err != nil || role != RoleOwner {
263 jsonError(w, http.StatusForbidden, updatewords.Text("settings.console.fast-track-owner"))
264 return
265 }
266 }
267 ctx, cancel := context.WithTimeout(r.Context(), instanceStoreTimeout)
268 defer cancel()
269 id, st, msg := s.requestInstall(ctx, acct, app, principal, r.PathValue("host"), req.FastTrack)
270 if st != http.StatusOK {
271 answerUpdates(w, r, st, msg)
272 return
273 }
274 writeJSON(w, http.StatusOK, map[string]uint64{"request": id})
275}
276
277// handleAPIUpdatesPolicy is PUT /api/cli/apps/{account}/{app}/updates: the
278// app's instance update policy (spec §3.2), on its instances manifest. The
279// policy is four keys: instance_control (which settings an instance may
280// change itself), default_updates, default_major, and default_channel, which
281// is the manifest's own Channel, the one new instances are created on. A key
282// present with null or "" clears it, except default_channel, which can be
283// changed but never cleared, as at the instances-manifest door. Any other
284// value of the wrong type is refused rather than read as a clear.
285//
286// Who: app members, people only. It writes the manifest, standing policy
287// about the bounds the app's instances run inside, so it is closed to every
288// service credential for the instances-manifest door's reason (svcrole.Set{}
289// at the registration, apiPerson here as the second wall).
290//
291// When: a default manual or a default major is turned ON only while every box
292// applies update settings (§4.4, enableAllowed); clearing is never refused,
293// and instance_control and default_channel never wait.
294func (s *Server) handleAPIUpdatesPolicy(w http.ResponseWriter, r *http.Request) {
295 if !s.apiPerson(w, r) {
296 return
297 }
298 principal, acct, app, ok := s.instanceGate(w, r)
299 if !ok {
300 return
301 }
302 var req map[string]any
303 if !readJSON(w, r, &req) {
304 return
305 }
306 // The policy has exactly these four keys (spec rev 11 §3.2): there is no
307 // default pin, and an unknown key is refused rather than ignored, so a
308 // caller who sends one learns it did nothing. Checked before the store is
309 // read, and in key order so the answer names the same key every time.
310 keys := make([]string, 0, len(req))
311 for k := range req {
312 keys = append(keys, k)
313 }
314 sort.Strings(keys)
315 for _, k := range keys {
316 switch k {
317 case "instance_control", "default_updates", "default_channel", "default_major":
318 default:
319 jsonError(w, http.StatusBadRequest, fmt.Sprintf(updatewords.Text("settings.console.unknown-policy-key"), shownValue(k)))
320 return
321 }
322 }
323 ctx, cancel := context.WithTimeout(r.Context(), instanceStoreTimeout)
324 defer cancel()
325 m, etag, err := provision.GetManifestETag(ctx, s.cfg.Releases, acct.Sqid, app)
326 switch {
327 case errors.Is(err, os.ErrNotExist):
328 jsonError(w, http.StatusConflict, updatewords.Text("settings.console.no-manifest"))
329 return
330 case err != nil:
331 log.Printf("console: api updates policy %s/%s: manifest: %v", acct.Sqid, app, err)
332 jsonError(w, http.StatusBadGateway, updatewords.Text("settings.console.policy-read-failed"))
333 return
334 }
335 var before provision.UpdatePolicy
336 if m.Updates != nil {
337 before = *m.Updates
338 }
339 next := before
340 nextChannel := m.Channel
341 if v, has := req["instance_control"]; has {
342 control, ok := controlValue(v)
343 if !ok {
344 jsonError(w, http.StatusBadRequest, updatewords.Text("settings.console.bad-control"))
345 return
346 }
347 next.InstanceControl = control
348 }
349 if v, has := req["default_updates"]; has {
350 str, ok := updatesValue(v)
351 if !ok {
352 jsonError(w, http.StatusBadRequest, updatewords.Text("settings.console.bad-updates"))
353 return
354 }
355 next.DefaultUpdates = str
356 }
357 if v, has := req["default_major"]; has {
358 major, ok := majorValue(v)
359 if !ok {
360 jsonError(w, http.StatusBadRequest, updatewords.Text("settings.console.bad-major"))
361 return
362 }
363 next.DefaultMajor = major
364 }
365 if v, has := req["default_channel"]; has {
366 // The instances-manifest door's check and its words: a manifest's
367 // channel is what new instances are created on, and "clearing" it is
368 // not a policy act this door offers.
369 str, isStr := v.(string)
370 if !isStr || !release.ValidChannel(str) {
371 jsonError(w, http.StatusBadRequest, "invalid channel name")
372 return
373 }
374 nextChannel = str
375 }
376 if err := provision.ValidatePolicy(&next); err != nil {
377 // Every shape ValidatePolicy refuses is refused above with reviewed
378 // words; reaching this is a new shape, and its plain text is API detail.
379 jsonError(w, http.StatusBadRequest, err.Error())
380 return
381 }
382 changes := policyChanges(before, next, m.Channel, nextChannel)
383 if len(changes) == 0 {
384 // Nothing changed: no write, no serial bump, no audit line, and
385 // nothing to refuse.
386 writeJSON(w, http.StatusOK, policyView(m.Channel, before))
387 return
388 }
389 // Only a change that turns a default ON waits for the fleet: manual newly
390 // set, or a new major. A value re-sent unchanged beside a clear is not an
391 // enable, so the clear is never refused (§4.4).
392 enabling := (next.DefaultUpdates == release.UpdatesManual && before.DefaultUpdates != release.UpdatesManual) ||
393 (next.DefaultMajor != nil && !sameMajor(before.DefaultMajor, next.DefaultMajor))
394 if enabling && !s.enableAllowed(ctx, w, acct.Sqid+"/"+app+" policy") {
395 return
396 }
397 out := *m
398 out.Channel = nextChannel
399 out.Updates = nil
400 if next.InstanceControl != nil || next.DefaultUpdates != "" || next.DefaultMajor != nil {
401 p := next
402 out.Updates = &p
403 }
404 if err := provision.PutManifestIfMatch(ctx, s.cfg.Releases, acct.Sqid, app, out, etag); err != nil {
405 if errors.Is(err, release.ErrCASConflict) {
406 jsonError(w, http.StatusConflict, updatewords.Text("settings.console.changed"))
407 return
408 }
409 log.Printf("console: api updates policy %s/%s: %v", acct.Sqid, app, err)
410 jsonError(w, http.StatusBadGateway, updatewords.Text("settings.console.store-failed"))
411 return
412 }
413 s.auditActivity(acct, app, principal, Activity{Action: "app-update-policy", TargetType: "app", Target: app,
414 Detail: fmt.Sprintf(updatewords.Text("settings.console.audit-policy"), app, strings.Join(changes, ", "))})
415 // Box-consumed: reconcile reads the manifest's policy and channel on its
416 // next pass, so announcing the write is what makes it prompt.
417 s.bumpSerial(ctx)
418 writeJSON(w, http.StatusOK, policyView(nextChannel, next))
419}
420
421// controlValue reads instance_control from a JSON value: an array of setting
422// names, each once, sets it; null or [] clears it (nil); anything else,
423// including an array holding a non-string, is refused.
424func controlValue(v any) ([]string, bool) {
425 switch list := v.(type) {
426 case nil:
427 return nil, true
428 case []any:
429 var out []string
430 for _, x := range list {
431 str, ok := x.(string)
432 if !ok {
433 return nil, false
434 }
435 out = append(out, str)
436 }
437 return out, release.ValidControl(out)
438 default:
439 return nil, false
440 }
441}
442
443// policyChanges names what differs between the policy before and after, in
444// the activity line's words: "instance_control updates,pin", "default
445// updates cleared", "default major 2", "default channel beta".
446func policyChanges(before, after provision.UpdatePolicy, channelBefore, channelAfter string) []string {
447 var out []string
448 if !slices.Equal(before.InstanceControl, after.InstanceControl) {
449 out = append(out, "instance_control "+orCleared(strings.Join(after.InstanceControl, ",")))
450 }
451 if before.DefaultUpdates != after.DefaultUpdates {
452 out = append(out, "default updates "+orCleared(after.DefaultUpdates))
453 }
454 if !sameMajor(before.DefaultMajor, after.DefaultMajor) {
455 if after.DefaultMajor == nil {
456 out = append(out, "default major cleared")
457 } else {
458 out = append(out, fmt.Sprintf("default major %d", *after.DefaultMajor))
459 }
460 }
461 if channelBefore != channelAfter {
462 out = append(out, "default channel "+channelAfter)
463 }
464 return out
465}
466
467// policyView is the policy door's answer. instance_control is always an
468// array, [] when no setting is the instance's own.
469func policyView(channel string, p provision.UpdatePolicy) map[string]any {
470 return map[string]any{
471 "instance_control": append([]string{}, p.InstanceControl...),
472 "default_updates": p.DefaultUpdates,
473 "default_channel": channel,
474 "default_major": p.DefaultMajor,
475 }
476}
477