| 1 | // The instance page's Updates section (instance update settings spec §5, |
| 2 | // settings plan 5 Task 5): the instance's update settings, Eleven-style, with |
| 3 | // two browser forms. Save is plan 3's PUT …/updates and "Update now" its POST |
| 4 | // …/updates/install; both go through the same core (updatescore.go), so the |
| 5 | // page and the API cannot disagree. |
| 6 | // |
| 7 | // Settings the app keeps for itself (outside its instance_control) are shown |
| 8 | // read-only (ruling R10); members may still change them through the API and |
| 9 | // the CLI (§3.3), so the doors here filter nothing. No control is ever |
| 10 | // disabled (Astra I3 #8): plan 3's gate refuses only turning manual, a major |
| 11 | // hold or a pin ON while some box runs an old binary, and clearing must stay |
| 12 | // expressible. A disabled input posts nothing, so the form says what it |
| 13 | // rendered instead: form=updates marks a submission, and each live control |
| 14 | // carries a hidden baseline of the value it showed (was_auto, was_major, |
| 15 | // was_pin), so only what the member changed is sent. |
| 16 | package console |
| 17 | |
| 18 | import ( |
| 19 | "context" |
| 20 | "crypto/subtle" |
| 21 | "fmt" |
| 22 | "net/http" |
| 23 | "net/url" |
| 24 | "slices" |
| 25 | "strconv" |
| 26 | "strings" |
| 27 | "time" |
| 28 | |
| 29 | "github.com/carlosframework/platform/internal/provision" |
| 30 | "github.com/carlosframework/platform/internal/release" |
| 31 | "github.com/carlosframework/platform/internal/updatewords" |
| 32 | ) |
| 33 | |
| 34 | // UpdatesText is a reviewed update settings string, for the templates. |
| 35 | func (p page) UpdatesText(id string) string { return updatewords.Text(id) } |
| 36 | |
| 37 | // updatesPageView is the instance page's Updates section. |
| 38 | type updatesPageView struct { |
| 39 | View updatesView |
| 40 | Report *updatesJoinView |
| 41 | // NamedUnit is plan 3's named-unit words for a route run by its own |
| 42 | // systemd unit (R12): the section shows them and no form. |
| 43 | NamedUnit string |
| 44 | // NotEnforced: the box reports it ignores settings, and this instance |
| 45 | // has a non-default one (R11). |
| 46 | NotEnforced bool |
| 47 | StatusLine string |
| 48 | // Refusing: the console refuses turning manual, major and pin on right |
| 49 | // now (plan 3's fleet gate). The controls stay live regardless. |
| 50 | Refusing bool |
| 51 | Owner bool |
| 52 | // HasForm: at least one setting is the instance's own, so Save shows. |
| 53 | HasForm bool |
| 54 | |
| 55 | UpdatesWord string // the effective "auto" or "manual" |
| 56 | AutoLive bool |
| 57 | Channel string |
| 58 | KeptChannel bool |
| 59 | ShowMajor bool // only stable cuts majors |
| 60 | MajorLive bool |
| 61 | MajorInput, MajorShown string |
| 62 | // MajorInherited is the note beside a live major box when the instance |
| 63 | // has no hold of its own and the app's default holds it (Astra r1, R20). |
| 64 | // The box stays the instance's own override: empty means inherit. |
| 65 | MajorInherited string |
| 66 | PinLive bool |
| 67 | PinInput, PinShown string |
| 68 | } |
| 69 | |
| 70 | // updatesPage builds the section for host, or nil when there is none to show: |
| 71 | // no record, a record that is not an instance, or a read that failed (logged |
| 72 | // by readUpdatesTarget; the page soft-degrades, never a 500). A record run by |
| 73 | // its own systemd unit shows plan 3's named-unit words whatever its kind |
| 74 | // (Astra r1, R21), as the API answers them before its kind refusal. |
| 75 | func (s *Server) updatesPage(ctx context.Context, acct *Account, app, host string, d *instanceDetail, report *updatesJoinView, owner bool) *updatesPageView { |
| 76 | ctx, cancel := context.WithTimeout(ctx, instanceStoreTimeout) |
| 77 | defer cancel() |
| 78 | rec, _, policy, st, _ := s.readUpdatesTarget(ctx, acct, app, host) |
| 79 | if st != 0 { |
| 80 | return nil |
| 81 | } |
| 82 | if rec.Unit != "" { |
| 83 | return &updatesPageView{NamedUnit: fmt.Sprintf(updatewords.Text("settings.console.named-unit"), host)} |
| 84 | } |
| 85 | if rec.KindOrDefault() != "instance" { |
| 86 | return nil |
| 87 | } |
| 88 | v := s.updatesViewOf(ctx, *rec, policy) |
| 89 | none := updatewords.Text("settings.cli.none") |
| 90 | live := func(name string) bool { return slices.Contains(v.Control, name) } |
| 91 | pv := &updatesPageView{ |
| 92 | View: v, Report: report, Refusing: !v.Enforced, Owner: owner, |
| 93 | NotEnforced: report != nil && !report.Enforced && !v.Effective.Default(), |
| 94 | UpdatesWord: v.Settings.Updates, AutoLive: live(release.ControlUpdates), |
| 95 | Channel: v.Settings.Channel, KeptChannel: !live(release.ControlChannel), |
| 96 | ShowMajor: v.Settings.Channel == "stable", MajorLive: live(release.ControlMajor), MajorShown: none, |
| 97 | PinLive: live(release.ControlPin), PinInput: v.Explicit.Pin, PinShown: none, |
| 98 | } |
| 99 | if v.Explicit.Major != nil { |
| 100 | pv.MajorInput = strconv.Itoa(*v.Explicit.Major) |
| 101 | } |
| 102 | if v.Settings.Major != nil { |
| 103 | pv.MajorShown = strconv.Itoa(*v.Settings.Major) |
| 104 | if v.Explicit.Major == nil { |
| 105 | pv.MajorInherited = fmt.Sprintf(updatewords.Text("settings.page.major-inherited"), *v.Settings.Major) |
| 106 | } |
| 107 | } |
| 108 | if v.Settings.Pin != "" { |
| 109 | pv.PinShown = v.Settings.Pin |
| 110 | } |
| 111 | pv.HasForm = pv.AutoLive || (pv.ShowMajor && pv.MajorLive) || pv.PinLive |
| 112 | if report != nil && report.Enforced && report.Desired != "" { |
| 113 | installed, serving := d.Row.Version, none |
| 114 | if r := d.Rollout; r != nil { |
| 115 | if r.Installed != "" { |
| 116 | installed = r.Installed |
| 117 | } |
| 118 | if r.Serving != "" { |
| 119 | serving = r.Serving |
| 120 | } |
| 121 | } |
| 122 | if installed == "" { |
| 123 | installed = none |
| 124 | } |
| 125 | pv.StatusLine = fmt.Sprintf(updatewords.Text("settings.page.status"), installed, serving, report.Desired, reasonSentence(report)) |
| 126 | } |
| 127 | return pv |
| 128 | } |
| 129 | |
| 130 | // reasonSentence is why the box's root chose the build it wants, in the |
| 131 | // settings.box.reason.* words `carlos instances list` uses. "adopt" (and no |
| 132 | // word at all) is following the channel. A pending Update now that waits on |
| 133 | // a bake is named beside the reason, never instead of it. A word with no |
| 134 | // reviewed sentence renders nothing. |
| 135 | func reasonSentence(u *updatesJoinView) string { |
| 136 | var r string |
| 137 | switch u.Reason { |
| 138 | case "", string(release.ReasonAdopt): |
| 139 | r = updatewords.Text("settings.box.reason.adopt") |
| 140 | case string(release.ReasonMajor): |
| 141 | n := 0 |
| 142 | if u.Major != nil { |
| 143 | n = *u.Major |
| 144 | } |
| 145 | r = fmt.Sprintf(updatewords.Text("settings.box.reason.major"), n, u.Detail) |
| 146 | case string(release.ReasonUnknown), string(release.ReasonNew): |
| 147 | r = updatewords.Text("settings.box.reason." + u.Reason) |
| 148 | case string(release.ReasonPin), string(release.ReasonRequest), string(release.ReasonManual), string(release.ReasonFailed): |
| 149 | r = fmt.Sprintf(updatewords.Text("settings.box.reason."+u.Reason), u.Detail) |
| 150 | default: |
| 151 | return "" |
| 152 | } |
| 153 | if u.Baking != "" { |
| 154 | r += "; " + fmt.Sprintf(updatewords.Text("settings.box.reason.baking"), u.Baking) |
| 155 | } |
| 156 | return r |
| 157 | } |
| 158 | |
| 159 | // updatesFormRequest is the browser form as plan 3's PUT body: only the |
| 160 | // controls the member changed (Astra I3 #8). Re-sending an unchanged |
| 161 | // "manual" would count as enabling at plan 3's gate and refuse a save that |
| 162 | // only clears a pin. |
| 163 | // |
| 164 | // "Changed" is measured against what the member saw, not the record at post |
| 165 | // time (review round 1, R15): each live control carries a hidden baseline of |
| 166 | // the value it was rendered with (was_auto, was_major, was_pin), and a |
| 167 | // control is sent only when its posted value differs from its own baseline. |
| 168 | // Otherwise a stale page would silently undo another member's change: a pin |
| 169 | // cleared through the CLI comes back when the page that still showed it |
| 170 | // saves an unrelated untick. A control whose baseline is missing sends |
| 171 | // nothing, and a post without form=updates sends nothing at all. was_auto |
| 172 | // also says the checkbox was rendered ("1" ticked, "0" not), so an omitted |
| 173 | // checkbox is never read as manual (R10). |
| 174 | func updatesFormRequest(f url.Values) map[string]any { |
| 175 | req := map[string]any{} |
| 176 | if f.Get("form") != "updates" { |
| 177 | return req |
| 178 | } |
| 179 | if was := f.Get("was_auto"); was == "1" || was == "0" { |
| 180 | if now := f.Get("auto") == "1"; now != (was == "1") { |
| 181 | req["updates"] = release.UpdatesManual |
| 182 | if now { |
| 183 | req["updates"] = release.UpdatesAuto |
| 184 | } |
| 185 | } |
| 186 | } |
| 187 | if f.Has("major") && f.Has("was_major") { |
| 188 | m, was := strings.TrimSpace(f.Get("major")), strings.TrimSpace(f.Get("was_major")) |
| 189 | n, err := strconv.Atoi(m) |
| 190 | w, werr := strconv.Atoi(was) |
| 191 | if sameNumber := err == nil && werr == nil && n == w; m != was && !sameNumber { |
| 192 | if err == nil { |
| 193 | req["major"] = float64(n) // a JSON number, as plan 3's door reads one |
| 194 | } else { |
| 195 | req["major"] = m // "" clears; anything else gets plan 3's bad-major refusal |
| 196 | } |
| 197 | } |
| 198 | } |
| 199 | if f.Has("pin") && f.Has("was_pin") { |
| 200 | if p := strings.TrimSpace(f.Get("pin")); p != strings.TrimSpace(f.Get("was_pin")) { |
| 201 | req["pin"] = p |
| 202 | } |
| 203 | } |
| 204 | return req |
| 205 | } |
| 206 | |
| 207 | // updatesFormTarget is both forms' gate, in the console's form security |
| 208 | // order (AGENTS.md; handleInstanceRetry's shape): session, CSRF, the app's |
| 209 | // byte-identical 404, the record's 404, for Update now the fast-track |
| 210 | // owner's 404, then the shared write budget. The owner check comes before the |
| 211 | // budget (Astra r1, R19): a member asking to skip the safety delay gets the |
| 212 | // byte-identical 404 whatever the budget's state. ok=false means a response |
| 213 | // has been written. back is the instance page. |
| 214 | type updatesFormTarget struct { |
| 215 | addr, raw, role, app, host, back string |
| 216 | acct *Account |
| 217 | rec *provision.Record |
| 218 | etag string |
| 219 | policy *provision.UpdatePolicy |
| 220 | } |
| 221 | |
| 222 | func (s *Server) updatesFormGate(w http.ResponseWriter, r *http.Request, ctx context.Context, updateNow bool) (updatesFormTarget, bool) { |
| 223 | addr, raw, acct, role, ok := s.account(w, r) |
| 224 | if !ok { |
| 225 | return updatesFormTarget{}, false |
| 226 | } |
| 227 | if subtle.ConstantTimeCompare([]byte(r.FormValue("csrf")), []byte(raw)) != 1 { |
| 228 | s.renderMessage(w, r, http.StatusForbidden, "Refused", "Cross-site request blocked.") |
| 229 | return updatesFormTarget{}, false |
| 230 | } |
| 231 | app := r.PathValue("app") |
| 232 | host := strings.TrimSpace(r.PathValue("host")) |
| 233 | has, err := s.store.HasAppIn(acct.ID, app) |
| 234 | if err != nil { |
| 235 | s.renderMessage(w, r, http.StatusInternalServerError, "Error", "Could not read your apps. Please try again.") |
| 236 | return updatesFormTarget{}, false |
| 237 | } |
| 238 | if !has || !provision.ValidHostShape(host) || s.cfg.Releases == nil { |
| 239 | http.NotFound(w, r) |
| 240 | return updatesFormTarget{}, false |
| 241 | } |
| 242 | t := updatesFormTarget{addr: addr, raw: raw, role: role, app: app, host: host, acct: acct, |
| 243 | back: "/a/" + url.PathEscape(acct.Sqid) + "/apps/" + url.PathEscape(app) + "/instances/" + url.PathEscape(host)} |
| 244 | rec, etag, policy, st, msg := s.readUpdatesTarget(ctx, acct, app, host) |
| 245 | switch { |
| 246 | case st == http.StatusNotFound && msg == "": |
| 247 | http.NotFound(w, r) |
| 248 | return updatesFormTarget{}, false |
| 249 | case st != 0: |
| 250 | s.updatesFlash(w, r, t.back, "err", msg) |
| 251 | return updatesFormTarget{}, false |
| 252 | } |
| 253 | t.rec, t.etag, t.policy = rec, etag, policy |
| 254 | if updateNow && r.PostFormValue("fast_track") == "1" && role != RoleOwner { |
| 255 | http.NotFound(w, r) |
| 256 | return updatesFormTarget{}, false |
| 257 | } |
| 258 | if !s.allowWrite(Principal{Kind: PrincipalUser, Address: addr}, time.Now()) { |
| 259 | s.updatesFlash(w, r, t.back, "err", updatewords.Text("settings.page.rate-limited")) |
| 260 | return updatesFormTarget{}, false |
| 261 | } |
| 262 | return t, true |
| 263 | } |
| 264 | |
| 265 | func (s *Server) updatesFlash(w http.ResponseWriter, r *http.Request, back, kind, msg string) { |
| 266 | s.setFlash(w, kind, msg) |
| 267 | http.Redirect(w, r, back, http.StatusSeeOther) |
| 268 | } |
| 269 | |
| 270 | // handleBrowserUpdates is the Updates section's Save: POST |
| 271 | // /a/{sqid}/apps/{app}/instances/{host}/updates. |
| 272 | func (s *Server) handleBrowserUpdates(w http.ResponseWriter, r *http.Request) { |
| 273 | ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second) |
| 274 | defer cancel() |
| 275 | t, ok := s.updatesFormGate(w, r, ctx, false) |
| 276 | if !ok { |
| 277 | return |
| 278 | } |
| 279 | // An empty request is a no-op save: applyUpdates writes nothing for it. |
| 280 | req := updatesFormRequest(r.PostForm) |
| 281 | _, st, msg := s.applyUpdates(ctx, t.acct, t.app, t.addr, t.rec, t.etag, t.policy, req) |
| 282 | if st != http.StatusOK { |
| 283 | s.updatesFlash(w, r, t.back, "err", msg) |
| 284 | return |
| 285 | } |
| 286 | s.updatesFlash(w, r, t.back, "ok", updatewords.Text("settings.page.saved")) |
| 287 | } |
| 288 | |
| 289 | // handleBrowserUpdateNow is the Updates section's "Update now": POST |
| 290 | // /a/{sqid}/apps/{app}/instances/{host}/updates/install. Skipping the safety |
| 291 | // delay is an account owner's, as at the console's fast-track door: anyone |
| 292 | // else gets that door's byte-identical 404 (in the gate, before the write |
| 293 | // budget), and an owner proves presence (requireFreshAuth, last). |
| 294 | func (s *Server) handleBrowserUpdateNow(w http.ResponseWriter, r *http.Request) { |
| 295 | ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second) |
| 296 | defer cancel() |
| 297 | t, ok := s.updatesFormGate(w, r, ctx, true) |
| 298 | if !ok { |
| 299 | return |
| 300 | } |
| 301 | fast := r.PostFormValue("fast_track") == "1" |
| 302 | if fast && !s.requireFreshAuth(w, r, t.addr, t.raw) { |
| 303 | return |
| 304 | } |
| 305 | _, st, msg := s.requestInstall(ctx, t.acct, t.app, t.addr, t.host, fast) |
| 306 | switch { |
| 307 | case st == http.StatusNotFound && msg == "": |
| 308 | http.NotFound(w, r) |
| 309 | case st != http.StatusOK: |
| 310 | s.updatesFlash(w, r, t.back, "err", msg) |
| 311 | default: |
| 312 | s.updatesFlash(w, r, t.back, "ok", updatewords.Text("settings.page.requested")) |
| 313 | } |
| 314 | } |
| 315 | |