// The instance update settings core (settings plan 5 Task 5): what plan 3's // JSON doors (apiupdates.go) and the instance page's two forms // (browserupdates.go) both do once their gates have passed. Neither front // reads, validates, gates or writes a setting itself, so the page and the API // cannot disagree about what is allowed. // // Nothing here writes a response. A refusal comes back as a status and the // reviewed message; the JSON doors answer it with jsonError (answerUpdates), // the page as an error flash. A 404 with no message is the family's plain // http.NotFound, so its body stays byte-identical to an unknown host's. package console import ( "context" "encoding/json" "errors" "fmt" "log" "net/http" "os" "strings" "time" "github.com/carlosframework/platform/internal/provision" "github.com/carlosframework/platform/internal/release" "github.com/carlosframework/platform/internal/updatewords" ) // answerUpdates writes a core refusal as the JSON doors always have: the // family's plain 404 for a host with no record, else the reviewed message. func answerUpdates(w http.ResponseWriter, r *http.Request, status int, msg string) { if status == http.StatusNotFound && msg == "" { http.NotFound(w, r) return } jsonError(w, status, msg) } // readUpdatesTarget reads the record and the app policy for a settings door. // status is 0 on success; a host with no record is 404 with no message. func (s *Server) readUpdatesTarget(ctx context.Context, acct *Account, app, host string) (*provision.Record, string, *provision.UpdatePolicy, int, string) { rec, etag, err := provision.GetRecordETag(ctx, s.cfg.Releases, acct.Sqid, app, host) switch { case errors.Is(err, os.ErrNotExist): return nil, "", nil, http.StatusNotFound, "" case err != nil: log.Printf("console: api updates %s/%s/%s: %v", acct.Sqid, app, host, err) return nil, "", nil, http.StatusBadGateway, updatewords.Text("settings.console.read-failed") } m, err := provision.GetManifest(ctx, s.cfg.Releases, acct.Sqid, app) if err != nil && !errors.Is(err, os.ErrNotExist) { log.Printf("console: api updates %s/%s: manifest: %v", acct.Sqid, app, err) return nil, "", nil, http.StatusBadGateway, updatewords.Text("settings.console.policy-read-failed") } var policy *provision.UpdatePolicy if m != nil { policy = m.Updates } return rec, etag, policy, 0, "" } // shapeRefusal is §4.5's refusals: a named-unit route keeps today's // adoption, and only instances have update settings. status 0 means neither. func shapeRefusal(rec *provision.Record) (int, string) { switch { case rec.Unit != "": return http.StatusConflict, fmt.Sprintf(updatewords.Text("settings.console.named-unit"), rec.Host) case rec.KindOrDefault() != "instance": return http.StatusConflict, fmt.Sprintf(updatewords.Text("settings.console.not-instance"), rec.Host, rec.KindOrDefault()) } return 0, "" } // enableRefusal is the §4.4 gate in front of a change that turns a setting // ON: status 0 when every box applies update settings. 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) enableRefusal(ctx context.Context, what string) (int, string) { ok, blocking, err := s.updatesEnforced(ctx) switch { case err != nil: log.Printf("console: api updates %s: enable refused, the box inventory could not be read: %v", what, err) return http.StatusBadGateway, updatewords.Text("settings.console.enforcement-unknown") case !ok: log.Printf("console: api updates %s: enable refused, not every box applies update settings: %s", what, strings.Join(blocking, "; ")) return http.StatusConflict, fmt.Sprintf(updatewords.Text("settings.console.not-enforced"), len(blocking)) } return 0, "" } // applyUpdates sets any of updates, major and pin on rec, read under etag: 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 answers the view and 200, // or a status and the reviewed refusal. An empty req writes nothing. It // writes preferences only: the console never chooses a build (spec §4). func (s *Server) applyUpdates(ctx context.Context, acct *Account, app, principal string, rec *provision.Record, etag string, policy *provision.UpdatePolicy, req map[string]any) (updatesView, int, string) { if st, msg := shapeRefusal(rec); st != 0 { return updatesView{}, st, msg } next := *rec // majorSet: this request sets major to a number the record does not // already hold. A major re-sent unchanged (a form that sends its full // state) is not a new major, so it is not refused off stable either. majorSet := false if v, has := req["updates"]; has { str, ok := updatesValue(v) if !ok { return updatesView{}, http.StatusBadRequest, updatewords.Text("settings.console.bad-updates") } next.Updates = str } if v, has := req["major"]; has { major, ok := majorValue(v) if !ok { return updatesView{}, http.StatusBadRequest, updatewords.Text("settings.console.bad-major") } next.Major, majorSet = major, major != nil && !sameMajor(rec.Major, major) } if v, has := req["pin"]; has { var str string switch p := v.(type) { case nil: case string: str = p default: raw, _ := json.Marshal(v) return updatesView{}, http.StatusBadRequest, fmt.Sprintf(updatewords.Text("settings.console.bad-pin"), shownValue(string(raw))) } if str != "" && (!release.ValidVersion(str) || str == "dev") { return updatesView{}, http.StatusBadRequest, fmt.Sprintf(updatewords.Text("settings.console.bad-pin"), shownValue(str)) } next.Pin = str } // Only a major THIS request sets or changes is refused off stable. A // major left by a channel move is ignored by reconcile (and shown as // major_ignored); it neither blocks an unrelated change, nor a clear sent // beside it unchanged, nor is it silently cleared by one (R4). Validation // below runs with it masked. if majorSet && next.Channel != "stable" { return updatesView{}, http.StatusBadRequest, fmt.Sprintf(updatewords.Text("settings.console.major-off-stable"), rec.Host, next.Channel) } check := next if !majorSet && check.Channel != "stable" { check.Major = nil } if err := provision.ValidateUpdates(check); err != nil { // Every shape ValidateUpdates refuses is refused above with reviewed // words; reaching this is a new shape, and its plain text is API detail. return updatesView{}, http.StatusBadRequest, err.Error() } changes := updatesChanges(*rec, next) if len(changes) == 0 { // Nothing changed: no write, no serial bump, no audit line, and // nothing to refuse. return s.updatesViewOf(ctx, *rec, policy), http.StatusOK, "" } if next.Pin != "" && next.Pin != rec.Pin { if _, err := release.ReadPromotionRecord(ctx, s.cfg.Releases, s.cfg.ReleasePub, acct.Sqid, app, next.Pin); err != nil { if errors.Is(err, release.ErrNoPromotionRecord) || errors.Is(err, os.ErrNotExist) { return updatesView{}, http.StatusConflict, fmt.Sprintf(updatewords.Text("settings.console.pin-unpromoted"), next.Pin) } log.Printf("console: api updates %s/%s/%s: promotion record %s: %v", acct.Sqid, app, rec.Host, next.Pin, err) return updatesView{}, http.StatusBadGateway, fmt.Sprintf(updatewords.Text("settings.console.pin-check-failed"), next.Pin) } } // Only a change that turns a setting ON waits for the fleet: manual newly // set, a different pin, or a new major. A value re-sent unchanged beside // a clear (a form that sends its full state) is not an enable, so the // clear is never refused (§4.4). enabling := (next.Updates == release.UpdatesManual && rec.Updates != release.UpdatesManual) || (next.Pin != "" && next.Pin != rec.Pin) || (next.Major != nil && !sameMajor(rec.Major, next.Major)) if enabling { if st, msg := s.enableRefusal(ctx, acct.Sqid+"/"+app+"/"+rec.Host); st != 0 { return updatesView{}, st, msg } } if err := provision.UpdateRecord(ctx, s.cfg.Releases, next, etag); err != nil { if errors.Is(err, release.ErrCASConflict) { return updatesView{}, http.StatusConflict, updatewords.Text("settings.console.changed") } log.Printf("console: api updates %s/%s/%s: %v", acct.Sqid, app, rec.Host, err) return updatesView{}, http.StatusBadGateway, updatewords.Text("settings.console.store-failed") } s.auditActivity(acct, app, principal, Activity{Action: "instance-updates", TargetType: "instance", Target: rec.Host, Detail: fmt.Sprintf(updatewords.Text("settings.console.audit-set"), rec.Host, strings.Join(changes, ", "))}) // A box-consumed write: reconcile resolves the settings into the // registry from this record, so announcing it is what makes it prompt. s.bumpSerial(ctx) return s.updatesViewOf(ctx, next, policy), http.StatusOK, "" } // requestInstall is "Update now" (spec §4.2, §4.3 rule 2) once the caller's // gates, the fast-track owner check among them, have passed. 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. It answers // the request id and 200, or a status and the reviewed refusal (404 with no // message for a host with no record). Root resolves the request to a build; // the console never names one. func (s *Server) requestInstall(ctx context.Context, acct *Account, app, principal, host string, fast bool) (uint64, int, string) { for attempt := 0; attempt < 5; attempt++ { rec, etag, _, st, msg := s.readUpdatesTarget(ctx, acct, app, host) if st != 0 { return 0, st, msg } if st, msg := shapeRefusal(rec); st != 0 { return 0, st, msg } if rec.Pin != "" { return 0, http.StatusConflict, fmt.Sprintf(updatewords.Text("settings.console.pinned"), rec.Host, rec.Pin) } var id uint64 = 1 if rec.InstallRequest != nil { id = rec.InstallRequest.ID + 1 } next := *rec next.InstallRequest = &provision.InstallRequest{ID: id, FastTrack: fast, At: time.Now().UTC().Truncate(time.Second), By: principal} err := provision.UpdateRecord(ctx, s.cfg.Releases, next, etag) if errors.Is(err, release.ErrCASConflict) { continue // another writer moved the record: read it again } if err != nil { log.Printf("console: api updates install %s/%s/%s: %v", acct.Sqid, app, rec.Host, err) return 0, http.StatusBadGateway, updatewords.Text("settings.console.store-failed") } s.auditActivity(acct, app, principal, Activity{Action: "instance-update-now", TargetType: "instance", Target: rec.Host, Detail: fmt.Sprintf(updatewords.Text("settings.console.audit-install"), rec.Host, id)}) // Box-consumed: root resolves the request on its next pass. s.bumpSerial(ctx) return id, http.StatusOK, "" } return 0, http.StatusConflict, updatewords.Text("settings.console.changed") }