carlos / platform Public

Clone
git clone https://amadan.net/carlos/platform

Plain git — no account needed to clone.

Download

Download this file

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.
16package console
17
18import (
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.
35func (p page) UpdatesText(id string) string { return updatewords.Text(id) }
36
37// updatesPageView is the instance page's Updates section.
38type 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.
75func (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.
135func 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).
174func 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.
214type 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
222func (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
265func (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.
272func (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).
294func (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