// The instance update settings doors (instance update settings spec §3.3, // §4.4, §5.1): GET and PUT /api/cli/apps/{account}/{app}/instances/{host}/updates, // and POST …/updates/install ("Update now", handleAPIUpdatesInstall); and the // app's policy door, PUT /api/cli/apps/{account}/{app}/updates (§3.2, // handleAPIUpdatesPolicy), which writes the instances manifest. // // A member of the app sets an instance's preferences: updates (auto or // manual), a major hold on stable, and a pin to one authorized build. They // are preferences among builds §4.1 already authorizes, not a security // boundary (§2 decision 5), and the console never chooses a build: it writes // the record, and root decides on its next pass. // // Who: people only. Every service credential is refused at the role gate // (svcrole.Set{}): svcrole.Instance is app-wide, and the instance-bound // credential §3.3 limits to instance_control is #155 step 6. // // When: pin, manual and major are turned ON only while every box runs a // binary that applies them (§4.4, updatesEnforced); turning them off is // never refused. The member is told how many boxes are not ready; which // boxes is operator detail, logged here and never shown. package console import ( "context" "errors" "fmt" "log" "math" "net/http" "os" "slices" "sort" "strings" "unicode" "github.com/carlosframework/platform/internal/provision" "github.com/carlosframework/platform/internal/release" "github.com/carlosframework/platform/internal/updatewords" ) type updatesSettings struct { Updates string `json:"updates"` Channel string `json:"channel"` Major *int `json:"major"` Pin string `json:"pin"` } // updatesView is what both doors answer with. Settings are the effective // values, Explicit the record's own, and Enforced whether every box applies // them (plan 5 renders its absence as "settings not enforced"). type updatesView struct { Host string `json:"host"` Settings updatesSettings `json:"settings"` Explicit updatesSettings `json:"explicit"` Effective release.UpdateSettings `json:"effective"` Control []string `json:"control"` InstallRequest *provision.InstallRequest `json:"install_request"` Enforced bool `json:"enforced"` MajorIgnored bool `json:"major_ignored"` } func (s *Server) updatesViewOf(ctx context.Context, rec provision.Record, policy *provision.UpdatePolicy) updatesView { v := updatesViewFor(rec, policy) v.Enforced, _, _ = s.updatesEnforced(ctx) return v } // updatesViewFor is updatesViewOf without Enforced, which reads every box's // heartbeat: for a caller that needs only the settings (the page's Save). func updatesViewFor(rec provision.Record, policy *provision.UpdatePolicy) updatesView { eff := provision.EffectiveUpdates(rec, policy) return updatesView{ Host: rec.Host, Settings: updatesSettings{Updates: eff.Updates, Channel: eff.Channel, Major: eff.Major, Pin: eff.Pin}, Explicit: updatesSettings{Updates: rec.Updates, Channel: rec.Channel, Major: rec.Major, Pin: rec.Pin}, Effective: eff, Control: append([]string{}, eff.Control...), InstallRequest: rec.InstallRequest, MajorIgnored: rec.Major != nil && eff.Major == nil, } } // updatesTarget reads the record and the app policy for a settings door, // answering the family's 404 for a host with no record. ok=false means a // response has been written. func (s *Server) updatesTarget(w http.ResponseWriter, r *http.Request, ctx context.Context, acct *Account, app string) (*provision.Record, string, *provision.UpdatePolicy, bool) { rec, etag, policy, st, msg := s.readUpdatesTarget(ctx, acct, app, r.PathValue("host")) if st != 0 { answerUpdates(w, r, st, msg) return nil, "", nil, false } return rec, etag, policy, true } // handleAPIUpdatesGet is GET …/instances/{host}/updates (spec §5.1). func (s *Server) handleAPIUpdatesGet(w http.ResponseWriter, r *http.Request) { _, acct, app, ok := s.instanceGate(w, r) if !ok { return } ctx, cancel := context.WithTimeout(r.Context(), instanceStoreTimeout) defer cancel() rec, _, policy, ok := s.updatesTarget(w, r, ctx, acct, app) if !ok { return } writeJSON(w, http.StatusOK, s.updatesViewOf(ctx, *rec, policy)) } // handleAPIUpdatesPut is PUT …/instances/{host}/updates: a member sets any of // updates, major and pin; a key present with "" or null clears it, and any // other value of the wrong type is refused rather than read as a clear. It // writes preferences only: the console never chooses a build (spec §4). func (s *Server) handleAPIUpdatesPut(w http.ResponseWriter, r *http.Request) { principal, acct, app, ok := s.instanceGate(w, r) if !ok { return } var req map[string]any if !readJSON(w, r, &req) { return } if _, has := req["channel"]; has { jsonError(w, http.StatusBadRequest, updatewords.Text("settings.console.channel-elsewhere")) return } ctx, cancel := context.WithTimeout(r.Context(), instanceStoreTimeout) defer cancel() rec, etag, policy, ok := s.updatesTarget(w, r, ctx, acct, app) if !ok { return } view, st, msg := s.applyUpdates(ctx, acct, app, principal, rec, etag, policy, req) if st != http.StatusOK { answerUpdates(w, r, st, msg) return } writeJSON(w, http.StatusOK, view) } // enableAllowed is the §4.4 gate both settings doors put in front of a change // that turns a setting ON. It answers true when every box applies update // settings; otherwise it writes the refusal and answers false. A member is // told how many boxes are not ready (R3); which boxes is operator detail, // logged with what (the target the log line names) and never shown. func (s *Server) enableAllowed(ctx context.Context, w http.ResponseWriter, what string) bool { if st, msg := s.enableRefusal(ctx, what); st != 0 { jsonError(w, st, msg) return false } return true } // updatesValue reads an updates value from a JSON value: "auto" or "manual" // sets it, null or "" clears it (""), and anything else is refused. func updatesValue(v any) (string, bool) { str, isStr := v.(string) if (v != nil && !isStr) || (str != "" && str != release.UpdatesAuto && str != release.UpdatesManual) { return "", false } return str, true } // majorValue reads a major hold from a JSON value: a whole number from 0 to // MaxInt32 sets it, null or "" clears it (nil), and anything else is refused. func majorValue(v any) (*int, bool) { switch n := v.(type) { case nil: return nil, true case float64: if n < 0 || n > math.MaxInt32 || n != math.Trunc(n) { return nil, false } i := int(n) return &i, true case string: return nil, n == "" default: return nil, false } } // updatesChanges names what differs between the record before and after, in // the activity line's words: "updates manual", "major 2", "pin cleared". func updatesChanges(before, after provision.Record) []string { var out []string if before.Updates != after.Updates { out = append(out, "updates "+orCleared(after.Updates)) } if !sameMajor(before.Major, after.Major) { if after.Major == nil { out = append(out, "major cleared") } else { out = append(out, fmt.Sprintf("major %d", *after.Major)) } } if before.Pin != after.Pin { out = append(out, "pin "+orCleared(after.Pin)) } return out } func sameMajor(a, b *int) bool { if a == nil || b == nil { return a == nil && b == nil } return *a == *b } func orCleared(v string) string { if v == "" { return "cleared" } return v } // shownValue is a refused value as a member may be shown it: printable // characters only, at most 64 of them, and "that" when nothing is left. func shownValue(v string) string { v = strings.Map(func(r rune) rune { if unicode.IsPrint(r) { return r } return -1 }, v) if rs := []rune(v); len(rs) > 64 { v = string(rs[:64]) + "..." } if v == "" { return "that" } return v } // handleAPIUpdatesInstall is POST …/instances/{host}/updates/install: "Update // now" (spec §4.2, §4.3 rule 2). It raises the record's install_request id by // one under the record's etag, retrying a lost race, so ids only ever // increase and no request is lost. Root resolves it to a build; the console // never names one. // // It is not one of the settings §4.4 holds until every box applies them: a // request a box does not yet act on asks for nothing it could not already get. // fast_track (skip the bake window) needs an owner of the account, the // authority the console's own fast-track door requires (index Q7). The API // cannot run that door's passkey ceremony, so it asks for the role alone. func (s *Server) handleAPIUpdatesInstall(w http.ResponseWriter, r *http.Request) { principal, acct, app, ok := s.instanceGate(w, r) if !ok { return } var req struct { FastTrack bool `json:"fast_track"` } if !readJSON(w, r, &req) { return } if req.FastTrack { role, err := s.store.Role(acct.ID, principal) if err != nil { log.Printf("console: api updates install %s/%s: fast track refused, the role could not be read: %v", acct.Sqid, app, err) } if err != nil || role != RoleOwner { jsonError(w, http.StatusForbidden, updatewords.Text("settings.console.fast-track-owner")) return } } ctx, cancel := context.WithTimeout(r.Context(), instanceStoreTimeout) defer cancel() id, st, msg := s.requestInstall(ctx, acct, app, principal, r.PathValue("host"), req.FastTrack) if st != http.StatusOK { answerUpdates(w, r, st, msg) return } writeJSON(w, http.StatusOK, map[string]uint64{"request": id}) } // handleAPIUpdatesPolicy is PUT /api/cli/apps/{account}/{app}/updates: the // app's instance update policy (spec §3.2), on its instances manifest. The // policy is four keys: instance_control (which settings an instance may // change itself), default_updates, default_major, and default_channel, which // is the manifest's own Channel, the one new instances are created on. A key // present with null or "" clears it, except default_channel, which can be // changed but never cleared, as at the instances-manifest door. Any other // value of the wrong type is refused rather than read as a clear. // // Who: app members, people only. It writes the manifest, standing policy // about the bounds the app's instances run inside, so it is closed to every // service credential for the instances-manifest door's reason (svcrole.Set{} // at the registration, apiPerson here as the second wall). // // When: a default manual or a default major is turned ON only while every box // applies update settings (§4.4, enableAllowed); clearing is never refused, // and instance_control and default_channel never wait. func (s *Server) handleAPIUpdatesPolicy(w http.ResponseWriter, r *http.Request) { if !s.apiPerson(w, r) { return } principal, acct, app, ok := s.instanceGate(w, r) if !ok { return } var req map[string]any if !readJSON(w, r, &req) { return } // The policy has exactly these four keys (spec rev 11 §3.2): there is no // default pin, and an unknown key is refused rather than ignored, so a // caller who sends one learns it did nothing. Checked before the store is // read, and in key order so the answer names the same key every time. keys := make([]string, 0, len(req)) for k := range req { keys = append(keys, k) } sort.Strings(keys) for _, k := range keys { switch k { case "instance_control", "default_updates", "default_channel", "default_major": default: jsonError(w, http.StatusBadRequest, fmt.Sprintf(updatewords.Text("settings.console.unknown-policy-key"), shownValue(k))) return } } ctx, cancel := context.WithTimeout(r.Context(), instanceStoreTimeout) defer cancel() m, etag, err := provision.GetManifestETag(ctx, s.cfg.Releases, acct.Sqid, app) switch { case errors.Is(err, os.ErrNotExist): jsonError(w, http.StatusConflict, updatewords.Text("settings.console.no-manifest")) return case err != nil: log.Printf("console: api updates policy %s/%s: manifest: %v", acct.Sqid, app, err) jsonError(w, http.StatusBadGateway, updatewords.Text("settings.console.policy-read-failed")) return } var before provision.UpdatePolicy if m.Updates != nil { before = *m.Updates } next := before nextChannel := m.Channel if v, has := req["instance_control"]; has { control, ok := controlValue(v) if !ok { jsonError(w, http.StatusBadRequest, updatewords.Text("settings.console.bad-control")) return } next.InstanceControl = control } if v, has := req["default_updates"]; has { str, ok := updatesValue(v) if !ok { jsonError(w, http.StatusBadRequest, updatewords.Text("settings.console.bad-updates")) return } next.DefaultUpdates = str } if v, has := req["default_major"]; has { major, ok := majorValue(v) if !ok { jsonError(w, http.StatusBadRequest, updatewords.Text("settings.console.bad-major")) return } next.DefaultMajor = major } if v, has := req["default_channel"]; has { // The instances-manifest door's check and its words: a manifest's // channel is what new instances are created on, and "clearing" it is // not a policy act this door offers. str, isStr := v.(string) if !isStr || !release.ValidChannel(str) { jsonError(w, http.StatusBadRequest, "invalid channel name") return } nextChannel = str } if err := provision.ValidatePolicy(&next); err != nil { // Every shape ValidatePolicy refuses is refused above with reviewed // words; reaching this is a new shape, and its plain text is API detail. jsonError(w, http.StatusBadRequest, err.Error()) return } changes := policyChanges(before, next, m.Channel, nextChannel) if len(changes) == 0 { // Nothing changed: no write, no serial bump, no audit line, and // nothing to refuse. writeJSON(w, http.StatusOK, policyView(m.Channel, before)) return } // Only a change that turns a default ON waits for the fleet: manual newly // set, or a new major. A value re-sent unchanged beside a clear is not an // enable, so the clear is never refused (§4.4). enabling := (next.DefaultUpdates == release.UpdatesManual && before.DefaultUpdates != release.UpdatesManual) || (next.DefaultMajor != nil && !sameMajor(before.DefaultMajor, next.DefaultMajor)) if enabling && !s.enableAllowed(ctx, w, acct.Sqid+"/"+app+" policy") { return } out := *m out.Channel = nextChannel out.Updates = nil if next.InstanceControl != nil || next.DefaultUpdates != "" || next.DefaultMajor != nil { p := next out.Updates = &p } if err := provision.PutManifestIfMatch(ctx, s.cfg.Releases, acct.Sqid, app, out, etag); err != nil { if errors.Is(err, release.ErrCASConflict) { jsonError(w, http.StatusConflict, updatewords.Text("settings.console.changed")) return } log.Printf("console: api updates policy %s/%s: %v", acct.Sqid, app, err) jsonError(w, http.StatusBadGateway, updatewords.Text("settings.console.store-failed")) return } s.auditActivity(acct, app, principal, Activity{Action: "app-update-policy", TargetType: "app", Target: app, Detail: fmt.Sprintf(updatewords.Text("settings.console.audit-policy"), app, strings.Join(changes, ", "))}) // Box-consumed: reconcile reads the manifest's policy and channel on its // next pass, so announcing the write is what makes it prompt. s.bumpSerial(ctx) writeJSON(w, http.StatusOK, policyView(nextChannel, next)) } // controlValue reads instance_control from a JSON value: an array of setting // names, each once, sets it; null or [] clears it (nil); anything else, // including an array holding a non-string, is refused. func controlValue(v any) ([]string, bool) { switch list := v.(type) { case nil: return nil, true case []any: var out []string for _, x := range list { str, ok := x.(string) if !ok { return nil, false } out = append(out, str) } return out, release.ValidControl(out) default: return nil, false } } // policyChanges names what differs between the policy before and after, in // the activity line's words: "instance_control updates,pin", "default // updates cleared", "default major 2", "default channel beta". func policyChanges(before, after provision.UpdatePolicy, channelBefore, channelAfter string) []string { var out []string if !slices.Equal(before.InstanceControl, after.InstanceControl) { out = append(out, "instance_control "+orCleared(strings.Join(after.InstanceControl, ","))) } if before.DefaultUpdates != after.DefaultUpdates { out = append(out, "default updates "+orCleared(after.DefaultUpdates)) } if !sameMajor(before.DefaultMajor, after.DefaultMajor) { if after.DefaultMajor == nil { out = append(out, "default major cleared") } else { out = append(out, fmt.Sprintf("default major %d", *after.DefaultMajor)) } } if channelBefore != channelAfter { out = append(out, "default channel "+channelAfter) } return out } // policyView is the policy door's answer. instance_control is always an // array, [] when no setting is the instance's own. func policyView(channel string, p provision.UpdatePolicy) map[string]any { return map[string]any{ "instance_control": append([]string{}, p.InstanceControl...), "default_updates": p.DefaultUpdates, "default_channel": channel, "default_major": p.DefaultMajor, } }