// The instance page's Updates section (instance update settings spec §5, // settings plan 5 Task 5): the instance's update settings, Eleven-style, with // two browser forms. Save is plan 3's PUT …/updates and "Update now" its POST // …/updates/install; both go through the same core (updatescore.go), so the // page and the API cannot disagree. // // Settings the app keeps for itself (outside its instance_control) are shown // read-only (ruling R10); members may still change them through the API and // the CLI (§3.3), so the doors here filter nothing. No control is ever // disabled (Astra I3 #8): plan 3's gate refuses only turning manual, a major // hold or a pin ON while some box runs an old binary, and clearing must stay // expressible. A disabled input posts nothing, so the form says what it // rendered instead: form=updates marks a submission, and each live control // carries a hidden baseline of the value it showed (was_auto, was_major, // was_pin), so only what the member changed is sent. package console import ( "context" "crypto/subtle" "fmt" "net/http" "net/url" "slices" "strconv" "strings" "time" "github.com/carlosframework/platform/internal/provision" "github.com/carlosframework/platform/internal/release" "github.com/carlosframework/platform/internal/updatewords" ) // UpdatesText is a reviewed update settings string, for the templates. func (p page) UpdatesText(id string) string { return updatewords.Text(id) } // updatesPageView is the instance page's Updates section. type updatesPageView struct { View updatesView Report *updatesJoinView // NamedUnit is plan 3's named-unit words for a route run by its own // systemd unit (R12): the section shows them and no form. NamedUnit string // NotEnforced: the box reports it ignores settings, and this instance // has a non-default one (R11). NotEnforced bool StatusLine string // Refusing: the console refuses turning manual, major and pin on right // now (plan 3's fleet gate). The controls stay live regardless. Refusing bool Owner bool // HasForm: at least one setting is the instance's own, so Save shows. HasForm bool UpdatesWord string // the effective "auto" or "manual" AutoLive bool Channel string KeptChannel bool ShowMajor bool // only stable cuts majors MajorLive bool MajorInput, MajorShown string // MajorInherited is the note beside a live major box when the instance // has no hold of its own and the app's default holds it (Astra r1, R20). // The box stays the instance's own override: empty means inherit. MajorInherited string PinLive bool PinInput, PinShown string } // updatesPage builds the section for host, or nil when there is none to show: // no record, a record that is not an instance, or a read that failed (logged // by readUpdatesTarget; the page soft-degrades, never a 500). A record run by // its own systemd unit shows plan 3's named-unit words whatever its kind // (Astra r1, R21), as the API answers them before its kind refusal. func (s *Server) updatesPage(ctx context.Context, acct *Account, app, host string, d *instanceDetail, report *updatesJoinView, owner bool) *updatesPageView { ctx, cancel := context.WithTimeout(ctx, instanceStoreTimeout) defer cancel() rec, _, policy, st, _ := s.readUpdatesTarget(ctx, acct, app, host) if st != 0 { return nil } if rec.Unit != "" { return &updatesPageView{NamedUnit: fmt.Sprintf(updatewords.Text("settings.console.named-unit"), host)} } if rec.KindOrDefault() != "instance" { return nil } v := s.updatesViewOf(ctx, *rec, policy) none := updatewords.Text("settings.cli.none") live := func(name string) bool { return slices.Contains(v.Control, name) } pv := &updatesPageView{ View: v, Report: report, Refusing: !v.Enforced, Owner: owner, NotEnforced: report != nil && !report.Enforced && !v.Effective.Default(), UpdatesWord: v.Settings.Updates, AutoLive: live(release.ControlUpdates), Channel: v.Settings.Channel, KeptChannel: !live(release.ControlChannel), ShowMajor: v.Settings.Channel == "stable", MajorLive: live(release.ControlMajor), MajorShown: none, PinLive: live(release.ControlPin), PinInput: v.Explicit.Pin, PinShown: none, } if v.Explicit.Major != nil { pv.MajorInput = strconv.Itoa(*v.Explicit.Major) } if v.Settings.Major != nil { pv.MajorShown = strconv.Itoa(*v.Settings.Major) if v.Explicit.Major == nil { pv.MajorInherited = fmt.Sprintf(updatewords.Text("settings.page.major-inherited"), *v.Settings.Major) } } if v.Settings.Pin != "" { pv.PinShown = v.Settings.Pin } pv.HasForm = pv.AutoLive || (pv.ShowMajor && pv.MajorLive) || pv.PinLive if report != nil && report.Enforced && report.Desired != "" { installed, serving := d.Row.Version, none if r := d.Rollout; r != nil { if r.Installed != "" { installed = r.Installed } if r.Serving != "" { serving = r.Serving } } if installed == "" { installed = none } pv.StatusLine = fmt.Sprintf(updatewords.Text("settings.page.status"), installed, serving, report.Desired, reasonSentence(report)) } return pv } // reasonSentence is why the box's root chose the build it wants, in the // settings.box.reason.* words `carlos instances list` uses. "adopt" (and no // word at all) is following the channel. A pending Update now that waits on // a bake is named beside the reason, never instead of it. A word with no // reviewed sentence renders nothing. func reasonSentence(u *updatesJoinView) string { var r string switch u.Reason { case "", string(release.ReasonAdopt): r = updatewords.Text("settings.box.reason.adopt") case string(release.ReasonMajor): n := 0 if u.Major != nil { n = *u.Major } r = fmt.Sprintf(updatewords.Text("settings.box.reason.major"), n, u.Detail) case string(release.ReasonUnknown), string(release.ReasonNew): r = updatewords.Text("settings.box.reason." + u.Reason) case string(release.ReasonPin), string(release.ReasonRequest), string(release.ReasonManual), string(release.ReasonFailed): r = fmt.Sprintf(updatewords.Text("settings.box.reason."+u.Reason), u.Detail) default: return "" } if u.Baking != "" { r += "; " + fmt.Sprintf(updatewords.Text("settings.box.reason.baking"), u.Baking) } return r } // updatesFormRequest is the browser form as plan 3's PUT body: only the // controls the member changed (Astra I3 #8). Re-sending an unchanged // "manual" would count as enabling at plan 3's gate and refuse a save that // only clears a pin. // // "Changed" is measured against what the member saw, not the record at post // time (review round 1, R15): each live control carries a hidden baseline of // the value it was rendered with (was_auto, was_major, was_pin), and a // control is sent only when its posted value differs from its own baseline. // Otherwise a stale page would silently undo another member's change: a pin // cleared through the CLI comes back when the page that still showed it // saves an unrelated untick. A control whose baseline is missing sends // nothing, and a post without form=updates sends nothing at all. was_auto // also says the checkbox was rendered ("1" ticked, "0" not), so an omitted // checkbox is never read as manual (R10). func updatesFormRequest(f url.Values) map[string]any { req := map[string]any{} if f.Get("form") != "updates" { return req } if was := f.Get("was_auto"); was == "1" || was == "0" { if now := f.Get("auto") == "1"; now != (was == "1") { req["updates"] = release.UpdatesManual if now { req["updates"] = release.UpdatesAuto } } } if f.Has("major") && f.Has("was_major") { m, was := strings.TrimSpace(f.Get("major")), strings.TrimSpace(f.Get("was_major")) n, err := strconv.Atoi(m) w, werr := strconv.Atoi(was) if sameNumber := err == nil && werr == nil && n == w; m != was && !sameNumber { if err == nil { req["major"] = float64(n) // a JSON number, as plan 3's door reads one } else { req["major"] = m // "" clears; anything else gets plan 3's bad-major refusal } } } if f.Has("pin") && f.Has("was_pin") { if p := strings.TrimSpace(f.Get("pin")); p != strings.TrimSpace(f.Get("was_pin")) { req["pin"] = p } } return req } // updatesFormTarget is both forms' gate, in the console's form security // order (AGENTS.md; handleInstanceRetry's shape): session, CSRF, the app's // byte-identical 404, the record's 404, for Update now the fast-track // owner's 404, then the shared write budget. The owner check comes before the // budget (Astra r1, R19): a member asking to skip the safety delay gets the // byte-identical 404 whatever the budget's state. ok=false means a response // has been written. back is the instance page. type updatesFormTarget struct { addr, raw, role, app, host, back string acct *Account rec *provision.Record etag string policy *provision.UpdatePolicy } func (s *Server) updatesFormGate(w http.ResponseWriter, r *http.Request, ctx context.Context, updateNow bool) (updatesFormTarget, bool) { addr, raw, acct, role, ok := s.account(w, r) if !ok { return updatesFormTarget{}, false } if subtle.ConstantTimeCompare([]byte(r.FormValue("csrf")), []byte(raw)) != 1 { s.renderMessage(w, r, http.StatusForbidden, "Refused", "Cross-site request blocked.") return updatesFormTarget{}, false } app := r.PathValue("app") host := strings.TrimSpace(r.PathValue("host")) has, err := s.store.HasAppIn(acct.ID, app) if err != nil { s.renderMessage(w, r, http.StatusInternalServerError, "Error", "Could not read your apps. Please try again.") return updatesFormTarget{}, false } if !has || !provision.ValidHostShape(host) || s.cfg.Releases == nil { http.NotFound(w, r) return updatesFormTarget{}, false } t := updatesFormTarget{addr: addr, raw: raw, role: role, app: app, host: host, acct: acct, back: "/a/" + url.PathEscape(acct.Sqid) + "/apps/" + url.PathEscape(app) + "/instances/" + url.PathEscape(host)} rec, etag, policy, st, msg := s.readUpdatesTarget(ctx, acct, app, host) switch { case st == http.StatusNotFound && msg == "": http.NotFound(w, r) return updatesFormTarget{}, false case st != 0: s.updatesFlash(w, r, t.back, "err", msg) return updatesFormTarget{}, false } t.rec, t.etag, t.policy = rec, etag, policy if updateNow && r.PostFormValue("fast_track") == "1" && role != RoleOwner { http.NotFound(w, r) return updatesFormTarget{}, false } if !s.allowWrite(Principal{Kind: PrincipalUser, Address: addr}, time.Now()) { s.updatesFlash(w, r, t.back, "err", updatewords.Text("settings.page.rate-limited")) return updatesFormTarget{}, false } return t, true } func (s *Server) updatesFlash(w http.ResponseWriter, r *http.Request, back, kind, msg string) { s.setFlash(w, kind, msg) http.Redirect(w, r, back, http.StatusSeeOther) } // handleBrowserUpdates is the Updates section's Save: POST // /a/{sqid}/apps/{app}/instances/{host}/updates. func (s *Server) handleBrowserUpdates(w http.ResponseWriter, r *http.Request) { ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second) defer cancel() t, ok := s.updatesFormGate(w, r, ctx, false) if !ok { return } // An empty request is a no-op save: applyUpdates writes nothing for it. req := updatesFormRequest(r.PostForm) _, st, msg := s.applyUpdates(ctx, t.acct, t.app, t.addr, t.rec, t.etag, t.policy, req) if st != http.StatusOK { s.updatesFlash(w, r, t.back, "err", msg) return } s.updatesFlash(w, r, t.back, "ok", updatewords.Text("settings.page.saved")) } // handleBrowserUpdateNow is the Updates section's "Update now": POST // /a/{sqid}/apps/{app}/instances/{host}/updates/install. Skipping the safety // delay is an account owner's, as at the console's fast-track door: anyone // else gets that door's byte-identical 404 (in the gate, before the write // budget), and an owner proves presence (requireFreshAuth, last). func (s *Server) handleBrowserUpdateNow(w http.ResponseWriter, r *http.Request) { ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second) defer cancel() t, ok := s.updatesFormGate(w, r, ctx, true) if !ok { return } fast := r.PostFormValue("fast_track") == "1" if fast && !s.requireFreshAuth(w, r, t.addr, t.raw) { return } _, st, msg := s.requestInstall(ctx, t.acct, t.app, t.addr, t.host, fast) switch { case st == http.StatusNotFound && msg == "": http.NotFound(w, r) case st != http.StatusOK: s.updatesFlash(w, r, t.back, "err", msg) default: s.updatesFlash(w, r, t.back, "ok", updatewords.Text("settings.page.requested")) } }