| 1 | // The instance update settings core (settings plan 5 Task 5): what plan 3's |
| 2 | // JSON doors (apiupdates.go) and the instance page's two forms |
| 3 | // (browserupdates.go) both do once their gates have passed. Neither front |
| 4 | // reads, validates, gates or writes a setting itself, so the page and the API |
| 5 | // cannot disagree about what is allowed. |
| 6 | // |
| 7 | // Nothing here writes a response. A refusal comes back as a status and the |
| 8 | // reviewed message; the JSON doors answer it with jsonError (answerUpdates), |
| 9 | // the page as an error flash. A 404 with no message is the family's plain |
| 10 | // http.NotFound, so its body stays byte-identical to an unknown host's. |
| 11 | package console |
| 12 | |
| 13 | import ( |
| 14 | "context" |
| 15 | "encoding/json" |
| 16 | "errors" |
| 17 | "fmt" |
| 18 | "log" |
| 19 | "net/http" |
| 20 | "os" |
| 21 | "strings" |
| 22 | "time" |
| 23 | |
| 24 | "github.com/carlosframework/platform/internal/provision" |
| 25 | "github.com/carlosframework/platform/internal/release" |
| 26 | "github.com/carlosframework/platform/internal/updatewords" |
| 27 | ) |
| 28 | |
| 29 | // answerUpdates writes a core refusal as the JSON doors always have: the |
| 30 | // family's plain 404 for a host with no record, else the reviewed message. |
| 31 | func answerUpdates(w http.ResponseWriter, r *http.Request, status int, msg string) { |
| 32 | if status == http.StatusNotFound && msg == "" { |
| 33 | http.NotFound(w, r) |
| 34 | return |
| 35 | } |
| 36 | jsonError(w, status, msg) |
| 37 | } |
| 38 | |
| 39 | // readUpdatesTarget reads the record and the app policy for a settings door. |
| 40 | // status is 0 on success; a host with no record is 404 with no message. |
| 41 | func (s *Server) readUpdatesTarget(ctx context.Context, acct *Account, app, host string) (*provision.Record, string, *provision.UpdatePolicy, int, string) { |
| 42 | rec, etag, err := provision.GetRecordETag(ctx, s.cfg.Releases, acct.Sqid, app, host) |
| 43 | switch { |
| 44 | case errors.Is(err, os.ErrNotExist): |
| 45 | return nil, "", nil, http.StatusNotFound, "" |
| 46 | case err != nil: |
| 47 | log.Printf("console: api updates %s/%s/%s: %v", acct.Sqid, app, host, err) |
| 48 | return nil, "", nil, http.StatusBadGateway, updatewords.Text("settings.console.read-failed") |
| 49 | } |
| 50 | m, err := provision.GetManifest(ctx, s.cfg.Releases, acct.Sqid, app) |
| 51 | if err != nil && !errors.Is(err, os.ErrNotExist) { |
| 52 | log.Printf("console: api updates %s/%s: manifest: %v", acct.Sqid, app, err) |
| 53 | return nil, "", nil, http.StatusBadGateway, updatewords.Text("settings.console.policy-read-failed") |
| 54 | } |
| 55 | var policy *provision.UpdatePolicy |
| 56 | if m != nil { |
| 57 | policy = m.Updates |
| 58 | } |
| 59 | return rec, etag, policy, 0, "" |
| 60 | } |
| 61 | |
| 62 | // shapeRefusal is §4.5's refusals: a named-unit route keeps today's |
| 63 | // adoption, and only instances have update settings. status 0 means neither. |
| 64 | func shapeRefusal(rec *provision.Record) (int, string) { |
| 65 | switch { |
| 66 | case rec.Unit != "": |
| 67 | return http.StatusConflict, fmt.Sprintf(updatewords.Text("settings.console.named-unit"), rec.Host) |
| 68 | case rec.KindOrDefault() != "instance": |
| 69 | return http.StatusConflict, fmt.Sprintf(updatewords.Text("settings.console.not-instance"), rec.Host, rec.KindOrDefault()) |
| 70 | } |
| 71 | return 0, "" |
| 72 | } |
| 73 | |
| 74 | // enableRefusal is the §4.4 gate in front of a change that turns a setting |
| 75 | // ON: status 0 when every box applies update settings. A member is told how |
| 76 | // many boxes are not ready (R3); which boxes is operator detail, logged with |
| 77 | // what (the target the log line names) and never shown. |
| 78 | func (s *Server) enableRefusal(ctx context.Context, what string) (int, string) { |
| 79 | ok, blocking, err := s.updatesEnforced(ctx) |
| 80 | switch { |
| 81 | case err != nil: |
| 82 | log.Printf("console: api updates %s: enable refused, the box inventory could not be read: %v", what, err) |
| 83 | return http.StatusBadGateway, updatewords.Text("settings.console.enforcement-unknown") |
| 84 | case !ok: |
| 85 | log.Printf("console: api updates %s: enable refused, not every box applies update settings: %s", what, strings.Join(blocking, "; ")) |
| 86 | return http.StatusConflict, fmt.Sprintf(updatewords.Text("settings.console.not-enforced"), len(blocking)) |
| 87 | } |
| 88 | return 0, "" |
| 89 | } |
| 90 | |
| 91 | // applyUpdates sets any of updates, major and pin on rec, read under etag: a |
| 92 | // key present with "" or null clears it, and any other value of the wrong |
| 93 | // type is refused rather than read as a clear. It answers the view and 200, |
| 94 | // or a status and the reviewed refusal. An empty req writes nothing. It |
| 95 | // writes preferences only: the console never chooses a build (spec §4). |
| 96 | 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) { |
| 97 | if st, msg := shapeRefusal(rec); st != 0 { |
| 98 | return updatesView{}, st, msg |
| 99 | } |
| 100 | next := *rec |
| 101 | // majorSet: this request sets major to a number the record does not |
| 102 | // already hold. A major re-sent unchanged (a form that sends its full |
| 103 | // state) is not a new major, so it is not refused off stable either. |
| 104 | majorSet := false |
| 105 | if v, has := req["updates"]; has { |
| 106 | str, ok := updatesValue(v) |
| 107 | if !ok { |
| 108 | return updatesView{}, http.StatusBadRequest, updatewords.Text("settings.console.bad-updates") |
| 109 | } |
| 110 | next.Updates = str |
| 111 | } |
| 112 | if v, has := req["major"]; has { |
| 113 | major, ok := majorValue(v) |
| 114 | if !ok { |
| 115 | return updatesView{}, http.StatusBadRequest, updatewords.Text("settings.console.bad-major") |
| 116 | } |
| 117 | next.Major, majorSet = major, major != nil && !sameMajor(rec.Major, major) |
| 118 | } |
| 119 | if v, has := req["pin"]; has { |
| 120 | var str string |
| 121 | switch p := v.(type) { |
| 122 | case nil: |
| 123 | case string: |
| 124 | str = p |
| 125 | default: |
| 126 | raw, _ := json.Marshal(v) |
| 127 | return updatesView{}, http.StatusBadRequest, fmt.Sprintf(updatewords.Text("settings.console.bad-pin"), shownValue(string(raw))) |
| 128 | } |
| 129 | if str != "" && (!release.ValidVersion(str) || str == "dev") { |
| 130 | return updatesView{}, http.StatusBadRequest, fmt.Sprintf(updatewords.Text("settings.console.bad-pin"), shownValue(str)) |
| 131 | } |
| 132 | next.Pin = str |
| 133 | } |
| 134 | // Only a major THIS request sets or changes is refused off stable. A |
| 135 | // major left by a channel move is ignored by reconcile (and shown as |
| 136 | // major_ignored); it neither blocks an unrelated change, nor a clear sent |
| 137 | // beside it unchanged, nor is it silently cleared by one (R4). Validation |
| 138 | // below runs with it masked. |
| 139 | if majorSet && next.Channel != "stable" { |
| 140 | return updatesView{}, http.StatusBadRequest, fmt.Sprintf(updatewords.Text("settings.console.major-off-stable"), rec.Host, next.Channel) |
| 141 | } |
| 142 | check := next |
| 143 | if !majorSet && check.Channel != "stable" { |
| 144 | check.Major = nil |
| 145 | } |
| 146 | if err := provision.ValidateUpdates(check); err != nil { |
| 147 | // Every shape ValidateUpdates refuses is refused above with reviewed |
| 148 | // words; reaching this is a new shape, and its plain text is API detail. |
| 149 | return updatesView{}, http.StatusBadRequest, err.Error() |
| 150 | } |
| 151 | changes := updatesChanges(*rec, next) |
| 152 | if len(changes) == 0 { |
| 153 | // Nothing changed: no write, no serial bump, no audit line, and |
| 154 | // nothing to refuse. |
| 155 | return s.updatesViewOf(ctx, *rec, policy), http.StatusOK, "" |
| 156 | } |
| 157 | if next.Pin != "" && next.Pin != rec.Pin { |
| 158 | if _, err := release.ReadPromotionRecord(ctx, s.cfg.Releases, s.cfg.ReleasePub, acct.Sqid, app, next.Pin); err != nil { |
| 159 | if errors.Is(err, release.ErrNoPromotionRecord) || errors.Is(err, os.ErrNotExist) { |
| 160 | return updatesView{}, http.StatusConflict, fmt.Sprintf(updatewords.Text("settings.console.pin-unpromoted"), next.Pin) |
| 161 | } |
| 162 | log.Printf("console: api updates %s/%s/%s: promotion record %s: %v", acct.Sqid, app, rec.Host, next.Pin, err) |
| 163 | return updatesView{}, http.StatusBadGateway, fmt.Sprintf(updatewords.Text("settings.console.pin-check-failed"), next.Pin) |
| 164 | } |
| 165 | } |
| 166 | // Only a change that turns a setting ON waits for the fleet: manual newly |
| 167 | // set, a different pin, or a new major. A value re-sent unchanged beside |
| 168 | // a clear (a form that sends its full state) is not an enable, so the |
| 169 | // clear is never refused (§4.4). |
| 170 | enabling := (next.Updates == release.UpdatesManual && rec.Updates != release.UpdatesManual) || |
| 171 | (next.Pin != "" && next.Pin != rec.Pin) || |
| 172 | (next.Major != nil && !sameMajor(rec.Major, next.Major)) |
| 173 | if enabling { |
| 174 | if st, msg := s.enableRefusal(ctx, acct.Sqid+"/"+app+"/"+rec.Host); st != 0 { |
| 175 | return updatesView{}, st, msg |
| 176 | } |
| 177 | } |
| 178 | if err := provision.UpdateRecord(ctx, s.cfg.Releases, next, etag); err != nil { |
| 179 | if errors.Is(err, release.ErrCASConflict) { |
| 180 | return updatesView{}, http.StatusConflict, updatewords.Text("settings.console.changed") |
| 181 | } |
| 182 | log.Printf("console: api updates %s/%s/%s: %v", acct.Sqid, app, rec.Host, err) |
| 183 | return updatesView{}, http.StatusBadGateway, updatewords.Text("settings.console.store-failed") |
| 184 | } |
| 185 | s.auditActivity(acct, app, principal, Activity{Action: "instance-updates", TargetType: "instance", Target: rec.Host, |
| 186 | Detail: fmt.Sprintf(updatewords.Text("settings.console.audit-set"), rec.Host, strings.Join(changes, ", "))}) |
| 187 | // A box-consumed write: reconcile resolves the settings into the |
| 188 | // registry from this record, so announcing it is what makes it prompt. |
| 189 | s.bumpSerial(ctx) |
| 190 | return s.updatesViewOf(ctx, next, policy), http.StatusOK, "" |
| 191 | } |
| 192 | |
| 193 | // requestInstall is "Update now" (spec §4.2, §4.3 rule 2) once the caller's |
| 194 | // gates, the fast-track owner check among them, have passed. It raises the |
| 195 | // record's install_request id by one under the record's etag, retrying a |
| 196 | // lost race, so ids only ever increase and no request is lost. It answers |
| 197 | // the request id and 200, or a status and the reviewed refusal (404 with no |
| 198 | // message for a host with no record). Root resolves the request to a build; |
| 199 | // the console never names one. |
| 200 | func (s *Server) requestInstall(ctx context.Context, acct *Account, app, principal, host string, fast bool) (uint64, int, string) { |
| 201 | for attempt := 0; attempt < 5; attempt++ { |
| 202 | rec, etag, _, st, msg := s.readUpdatesTarget(ctx, acct, app, host) |
| 203 | if st != 0 { |
| 204 | return 0, st, msg |
| 205 | } |
| 206 | if st, msg := shapeRefusal(rec); st != 0 { |
| 207 | return 0, st, msg |
| 208 | } |
| 209 | if rec.Pin != "" { |
| 210 | return 0, http.StatusConflict, fmt.Sprintf(updatewords.Text("settings.console.pinned"), rec.Host, rec.Pin) |
| 211 | } |
| 212 | var id uint64 = 1 |
| 213 | if rec.InstallRequest != nil { |
| 214 | id = rec.InstallRequest.ID + 1 |
| 215 | } |
| 216 | next := *rec |
| 217 | next.InstallRequest = &provision.InstallRequest{ID: id, FastTrack: fast, At: time.Now().UTC().Truncate(time.Second), By: principal} |
| 218 | err := provision.UpdateRecord(ctx, s.cfg.Releases, next, etag) |
| 219 | if errors.Is(err, release.ErrCASConflict) { |
| 220 | continue // another writer moved the record: read it again |
| 221 | } |
| 222 | if err != nil { |
| 223 | log.Printf("console: api updates install %s/%s/%s: %v", acct.Sqid, app, rec.Host, err) |
| 224 | return 0, http.StatusBadGateway, updatewords.Text("settings.console.store-failed") |
| 225 | } |
| 226 | s.auditActivity(acct, app, principal, Activity{Action: "instance-update-now", TargetType: "instance", Target: rec.Host, |
| 227 | Detail: fmt.Sprintf(updatewords.Text("settings.console.audit-install"), rec.Host, id)}) |
| 228 | // Box-consumed: root resolves the request on its next pass. |
| 229 | s.bumpSerial(ctx) |
| 230 | return id, http.StatusOK, "" |
| 231 | } |
| 232 | return 0, http.StatusConflict, updatewords.Text("settings.console.changed") |
| 233 | } |
| 234 | |