settings-5 task 5: the instance page's Updates section
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
9 files changed,
+1138
−175
internal/console/apiupdates.go+22 −175internal/console/browserupdates.go+295 −0internal/console/browserupdates_test.go+505 −0internal/console/claim_invariant_fixtures_test.go+48 −0internal/console/instancepage.go+6 −0internal/console/server.go+2 −0internal/console/templates/app_instance.html+25 −0internal/console/testdata/claim-routes.txt+2 −0internal/console/updatescore.go+233 −0
diff --git a/internal/console/apiupdates.go b/internal/console/apiupdates.go| index 1a9fc9389..d483067d0 100644 |
| --- a/internal/console/apiupdates.go |
| +++ b/internal/console/apiupdates.go |
| @@ -22,7 +22,6 @@ package console |
| import ( |
| "context" |
| - "encoding/json" |
| "errors" |
| "fmt" |
| "log" |
| @@ -32,7 +31,6 @@ import ( |
| "slices" |
| "sort" |
| "strings" |
| - "time" |
| "unicode" |
| "github.com/carlosframework/platform/internal/provision" |
| @@ -62,8 +60,15 @@ type updatesView struct { |
| } |
| func (s *Server) updatesViewOf(ctx context.Context, rec provision.Record, policy *provision.UpdatePolicy) updatesView { |
| + v := updatesViewFor(rec, policy) |
| + v.Enforced, _, _ = s.updatesEnforced(ctx) |
| + return v |
| +} |
| + |
| +// updatesViewFor is updatesViewOf without Enforced, which reads every box's |
| +// heartbeat: for a caller that needs only the settings (the page's Save). |
| +func updatesViewFor(rec provision.Record, policy *provision.UpdatePolicy) updatesView { |
| eff := provision.EffectiveUpdates(rec, policy) |
| - ok, _, _ := s.updatesEnforced(ctx) |
| return updatesView{ |
| Host: rec.Host, |
| Settings: updatesSettings{Updates: eff.Updates, Channel: eff.Channel, Major: eff.Major, Pin: eff.Pin}, |
| @@ -71,7 +76,6 @@ func (s *Server) updatesViewOf(ctx context.Context, rec provision.Record, policy |
| Effective: eff, |
| Control: append([]string{}, eff.Control...), |
| InstallRequest: rec.InstallRequest, |
| - Enforced: ok, |
| MajorIgnored: rec.Major != nil && eff.Major == nil, |
| } |
| } |
| @@ -80,44 +84,14 @@ func (s *Server) updatesViewOf(ctx context.Context, rec provision.Record, policy |
| // answering the family's 404 for a host with no record. ok=false means a |
| // response has been written. |
| func (s *Server) updatesTarget(w http.ResponseWriter, r *http.Request, ctx context.Context, acct *Account, app string) (*provision.Record, string, *provision.UpdatePolicy, bool) { |
| - host := r.PathValue("host") |
| - rec, etag, err := provision.GetRecordETag(ctx, s.cfg.Releases, acct.Sqid, app, host) |
| - switch { |
| - case errors.Is(err, os.ErrNotExist): |
| - http.NotFound(w, r) |
| - return nil, "", nil, false |
| - case err != nil: |
| - log.Printf("console: api updates %s/%s/%s: %v", acct.Sqid, app, host, err) |
| - jsonError(w, http.StatusBadGateway, updatewords.Text("settings.console.read-failed")) |
| + rec, etag, policy, st, msg := s.readUpdatesTarget(ctx, acct, app, r.PathValue("host")) |
| + if st != 0 { |
| + answerUpdates(w, r, st, msg) |
| return nil, "", nil, false |
| } |
| - 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) |
| - jsonError(w, http.StatusBadGateway, updatewords.Text("settings.console.policy-read-failed")) |
| - return nil, "", nil, false |
| - } |
| - var policy *provision.UpdatePolicy |
| - if m != nil { |
| - policy = m.Updates |
| - } |
| return rec, etag, policy, true |
| } |
| -// refuseShape answers §4.5's refusals: a named-unit route keeps today's |
| -// adoption, and only instances have update settings. |
| -func refuseShape(w http.ResponseWriter, rec *provision.Record) bool { |
| - switch { |
| - case rec.Unit != "": |
| - jsonError(w, http.StatusConflict, fmt.Sprintf(updatewords.Text("settings.console.named-unit"), rec.Host)) |
| - return true |
| - case rec.KindOrDefault() != "instance": |
| - jsonError(w, http.StatusConflict, fmt.Sprintf(updatewords.Text("settings.console.not-instance"), rec.Host, rec.KindOrDefault())) |
| - return true |
| - } |
| - return false |
| -} |
| - |
| // handleAPIUpdatesGet is GET …/instances/{host}/updates (spec §5.1). |
| func (s *Server) handleAPIUpdatesGet(w http.ResponseWriter, r *http.Request) { |
| _, acct, app, ok := s.instanceGate(w, r) |
| @@ -153,109 +127,15 @@ func (s *Server) handleAPIUpdatesPut(w http.ResponseWriter, r *http.Request) { |
| ctx, cancel := context.WithTimeout(r.Context(), instanceStoreTimeout) |
| defer cancel() |
| rec, etag, policy, ok := s.updatesTarget(w, r, ctx, acct, app) |
| - if !ok || refuseShape(w, rec) { |
| - return |
| - } |
| - 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 { |
| - jsonError(w, http.StatusBadRequest, updatewords.Text("settings.console.bad-updates")) |
| - return |
| - } |
| - next.Updates = str |
| - } |
| - if v, has := req["major"]; has { |
| - major, ok := majorValue(v) |
| - if !ok { |
| - jsonError(w, http.StatusBadRequest, updatewords.Text("settings.console.bad-major")) |
| - return |
| - } |
| - 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) |
| - jsonError(w, http.StatusBadRequest, fmt.Sprintf(updatewords.Text("settings.console.bad-pin"), shownValue(string(raw)))) |
| - return |
| - } |
| - if str != "" && (!release.ValidVersion(str) || str == "dev") { |
| - jsonError(w, http.StatusBadRequest, fmt.Sprintf(updatewords.Text("settings.console.bad-pin"), shownValue(str))) |
| - return |
| - } |
| - 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" { |
| - jsonError(w, http.StatusBadRequest, fmt.Sprintf(updatewords.Text("settings.console.major-off-stable"), rec.Host, next.Channel)) |
| - return |
| - } |
| - 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. |
| - jsonError(w, http.StatusBadRequest, err.Error()) |
| - return |
| - } |
| - changes := updatesChanges(*rec, next) |
| - if len(changes) == 0 { |
| - // Nothing changed: no write, no serial bump, no audit line, and |
| - // nothing to refuse. |
| - writeJSON(w, http.StatusOK, s.updatesViewOf(ctx, *rec, policy)) |
| - return |
| - } |
| - 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) { |
| - jsonError(w, http.StatusConflict, fmt.Sprintf(updatewords.Text("settings.console.pin-unpromoted"), next.Pin)) |
| - return |
| - } |
| - log.Printf("console: api updates %s/%s/%s: promotion record %s: %v", acct.Sqid, app, rec.Host, next.Pin, err) |
| - jsonError(w, http.StatusBadGateway, fmt.Sprintf(updatewords.Text("settings.console.pin-check-failed"), next.Pin)) |
| - return |
| - } |
| - } |
| - // 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 && !s.enableAllowed(ctx, w, acct.Sqid+"/"+app+"/"+rec.Host) { |
| + if !ok { |
| return |
| } |
| - if err := provision.UpdateRecord(ctx, s.cfg.Releases, next, etag); err != nil { |
| - if errors.Is(err, release.ErrCASConflict) { |
| - jsonError(w, http.StatusConflict, updatewords.Text("settings.console.changed")) |
| - return |
| - } |
| - log.Printf("console: api updates %s/%s/%s: %v", acct.Sqid, app, rec.Host, err) |
| - jsonError(w, http.StatusBadGateway, updatewords.Text("settings.console.store-failed")) |
| + view, st, msg := s.applyUpdates(ctx, acct, app, principal, rec, etag, policy, req) |
| + if st != http.StatusOK { |
| + answerUpdates(w, r, st, msg) |
| return |
| } |
| - 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) |
| - writeJSON(w, http.StatusOK, s.updatesViewOf(ctx, next, policy)) |
| + writeJSON(w, http.StatusOK, view) |
| } |
| // enableAllowed is the §4.4 gate both settings doors put in front of a change |
| @@ -264,15 +144,8 @@ func (s *Server) handleAPIUpdatesPut(w http.ResponseWriter, r *http.Request) { |
| // 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) enableAllowed(ctx context.Context, w http.ResponseWriter, what string) bool { |
| - 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) |
| - jsonError(w, http.StatusBadGateway, updatewords.Text("settings.console.enforcement-unknown")) |
| - return false |
| - case !ok: |
| - log.Printf("console: api updates %s: enable refused, not every box applies update settings: %s", what, strings.Join(blocking, "; ")) |
| - jsonError(w, http.StatusConflict, fmt.Sprintf(updatewords.Text("settings.console.not-enforced"), len(blocking))) |
| + if st, msg := s.enableRefusal(ctx, what); st != 0 { |
| + jsonError(w, st, msg) |
| return false |
| } |
| return true |
| @@ -393,38 +266,12 @@ func (s *Server) handleAPIUpdatesInstall(w http.ResponseWriter, r *http.Request) |
| } |
| ctx, cancel := context.WithTimeout(r.Context(), instanceStoreTimeout) |
| defer cancel() |
| - for attempt := 0; attempt < 5; attempt++ { |
| - rec, etag, _, ok := s.updatesTarget(w, r, ctx, acct, app) |
| - if !ok || refuseShape(w, rec) { |
| - return |
| - } |
| - if rec.Pin != "" { |
| - jsonError(w, http.StatusConflict, fmt.Sprintf(updatewords.Text("settings.console.pinned"), rec.Host, rec.Pin)) |
| - return |
| - } |
| - var id uint64 = 1 |
| - if rec.InstallRequest != nil { |
| - id = rec.InstallRequest.ID + 1 |
| - } |
| - next := *rec |
| - next.InstallRequest = &provision.InstallRequest{ID: id, FastTrack: req.FastTrack, 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) |
| - jsonError(w, http.StatusBadGateway, updatewords.Text("settings.console.store-failed")) |
| - return |
| - } |
| - 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) |
| - writeJSON(w, http.StatusOK, map[string]uint64{"request": id}) |
| + id, st, msg := s.requestInstall(ctx, acct, app, principal, r.PathValue("host"), req.FastTrack) |
| + if st != http.StatusOK { |
| + answerUpdates(w, r, st, msg) |
| return |
| } |
| - jsonError(w, http.StatusConflict, updatewords.Text("settings.console.changed")) |
| + writeJSON(w, http.StatusOK, map[string]uint64{"request": id}) |
| } |
| // handleAPIUpdatesPolicy is PUT /api/cli/apps/{account}/{app}/updates: the |
diff --git a/internal/console/browserupdates.go b/internal/console/browserupdates.go| new file mode 100644 |
| index 000000000..9a2fe25ba |
| --- /dev/null |
| +++ b/internal/console/browserupdates.go |
| @@ -0,0 +1,295 @@ |
| +// 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 auto_shown=1 marks a |
| +// checkbox that was there to be unticked. |
| +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 |
| + 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). |
| +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 || rec.KindOrDefault() != "instance" { |
| + return nil |
| + } |
| + if rec.Unit != "" { |
| + return &updatesPageView{NamedUnit: fmt.Sprintf(updatewords.Text("settings.console.named-unit"), host)} |
| + } |
| + 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.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. A post without form=updates sends nothing, and the |
| +// checkbox is read only when auto_shown says it was rendered, so an omitted |
| +// checkbox is never read as manual (R10). |
| +func updatesFormRequest(f url.Values, v updatesView) map[string]any { |
| + req := map[string]any{} |
| + if f.Get("form") != "updates" { |
| + return req |
| + } |
| + if f.Get("auto_shown") == "1" { |
| + want := release.UpdatesManual |
| + if f.Get("auto") == "1" { |
| + want = release.UpdatesAuto |
| + } |
| + if want != v.Settings.Updates { |
| + req["updates"] = want |
| + } |
| + } |
| + if f.Has("major") { |
| + cur := "" |
| + if v.Explicit.Major != nil { |
| + cur = strconv.Itoa(*v.Explicit.Major) |
| + } |
| + if m := strings.TrimSpace(f.Get("major")); m != cur { |
| + if n, err := strconv.Atoi(m); 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") { |
| + if p := strings.TrimSpace(f.Get("pin")); p != v.Explicit.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, then the shared write budget. 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) (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 !s.allowWrite(Principal{Kind: PrincipalUser, Address: addr}, time.Now()) { |
| + // The words the settings API doors answer their own budget with: no |
| + // page draft exists for this refusal yet. |
| + s.updatesFlash(w, r, t.back, "err", mainGateCopy().tooMany("instance")) |
| + 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) |
| + if !ok { |
| + return |
| + } |
| + // An empty request is a no-op save: applyUpdates writes nothing for it. |
| + req := updatesFormRequest(r.PostForm, updatesViewFor(*t.rec, t.policy)) |
| + _, 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, 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) |
| + if !ok { |
| + return |
| + } |
| + fast := r.PostFormValue("fast_track") == "1" |
| + if fast { |
| + if t.role != RoleOwner { |
| + http.NotFound(w, r) |
| + return |
| + } |
| + if !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")) |
| + } |
| +} |
diff --git a/internal/console/browserupdates_test.go b/internal/console/browserupdates_test.go| new file mode 100644 |
| index 000000000..32c316bbc |
| --- /dev/null |
| +++ b/internal/console/browserupdates_test.go |
| @@ -0,0 +1,505 @@ |
| +package console |
| + |
| +import ( |
| + "context" |
| + "fmt" |
| + "html/template" |
| + "net/http" |
| + "net/url" |
| + "os" |
| + "strings" |
| + "testing" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/provision" |
| + "github.com/carlosframework/platform/internal/release" |
| + "github.com/carlosframework/platform/internal/status" |
| + "github.com/carlosframework/platform/internal/updatewords" |
| +) |
| + |
| +// pageFx is zdd-6 Task 12's heldFixture (kass's web instance, on stable, with |
| +// box1 reporting it held: installed v2, serving v1) plus the store helpers the |
| +// instance page's Updates section tests use. Every helper writes the store |
| +// directly, bypassing the settings doors. |
| +type pageFx struct { |
| + *consoleFixture |
| + t *testing.T |
| + // report is box1's update report for heldHost, written by boxesEnforce. |
| + report *status.HostUpdates |
| +} |
| + |
| +func updatesPageFixture(t *testing.T) *pageFx { |
| + t.Helper() |
| + return &pageFx{consoleFixture: heldFixture(t), t: t} |
| +} |
| + |
| +// esc is a text as the instance page's html/template prints it. |
| +func esc(s string) string { return template.HTMLEscapeString(s) } |
| + |
| +// getPage GETs u, a full URL or a redirect's path. |
| +func (fx *pageFx) getPage(c *http.Client, u string) (int, string) { |
| + fx.t.Helper() |
| + if strings.HasPrefix(u, "/") { |
| + u = fx.ts.URL + u |
| + } |
| + code, _, body := get(fx.t, c, u) |
| + return code, body |
| +} |
| + |
| +// updatesSection is the instance page's Updates section, "" when none. |
| +func updatesSection(body string) string { |
| + i := strings.Index(body, `<section class="updates">`) |
| + if i < 0 { |
| + return "" |
| + } |
| + j := strings.Index(body[i:], "</section>") |
| + if j < 0 { |
| + return body[i:] |
| + } |
| + return body[i : i+j] |
| +} |
| + |
| +// boxesEnforce rewrites box1, the deployment's only box, keeping its held |
| +// rollout report and adding heldHost to its routes, so the box join has an |
| +// update view for the host. on says whether box1's binary applies update |
| +// settings, which also opens or closes the console's fleet gate. |
| +func (fx *pageFx) boxesEnforce(on bool) { |
| + fx.t.Helper() |
| + box := status.CollectBox(nil, status.Probes{MachineID: "box1"}, time.Now()) |
| + m := &status.BoxMetrics{ |
| + Rollout: &status.RolloutReport{Hosts: map[string]status.HostRollout{ |
| + heldHost: {Account: fx.sqid, App: "kass", Installed: "v2", Serving: "v1", Good: "v1", Awake: true, HeldBy: "v2", HeldEpoch: heldEpoch, HeldBook: heldBook, Reason: status.ReasonHealth}, |
| + }}, |
| + RouteVersions: map[string]string{heldHost: "v2"}, |
| + } |
| + if on { |
| + m.UpdateSettings = status.UpdateSettingsProtocol |
| + } |
| + if fx.report != nil { |
| + m.Updates = map[string]status.HostUpdates{heldHost: *fx.report} |
| + } |
| + box.Metrics = m |
| + if err := status.WriteBox(context.Background(), fx.rel, box); err != nil { |
| + fx.t.Fatal(err) |
| + } |
| +} |
| + |
| +func (fx *pageFx) record(host string) provision.Record { |
| + fx.t.Helper() |
| + rec, err := provision.GetRecord(context.Background(), fx.rel, fx.sqid, "kass", host) |
| + if err != nil { |
| + fx.t.Fatal(err) |
| + } |
| + return *rec |
| +} |
| + |
| +// setRecordSettings is a read-modify-write of host's record that bypasses |
| +// the settings doors (and so their fleet gate). |
| +func (fx *pageFx) setRecordSettings(host string, edit func(*provision.Record)) { |
| + fx.t.Helper() |
| + ctx := context.Background() |
| + rec, etag, err := provision.GetRecordETag(ctx, fx.rel, fx.sqid, "kass", host) |
| + if err != nil { |
| + fx.t.Fatal(err) |
| + } |
| + edit(rec) |
| + if err := provision.UpdateRecord(ctx, fx.rel, *rec, etag); err != nil { |
| + fx.t.Fatal(err) |
| + } |
| +} |
| + |
| +func (fx *pageFx) setRecordUpdates(host, updates string) { |
| + fx.setRecordSettings(host, func(r *provision.Record) { r.Updates = updates }) |
| +} |
| + |
| +func (fx *pageFx) pin(host, build string) { |
| + fx.setRecordSettings(host, func(r *provision.Record) { r.Pin = build }) |
| +} |
| + |
| +func (fx *pageFx) setChannel(host, channel string) { |
| + fx.setRecordSettings(host, func(r *provision.Record) { r.Channel = channel }) |
| +} |
| + |
| +// makeNamedUnit gives host's record a systemd unit of its own, as plan 3's |
| +// TestUpdatesDoorsRefuseNamedUnitsAndOtherKinds does. |
| +func (fx *pageFx) makeNamedUnit(host string) { |
| + fx.setRecordSettings(host, func(r *provision.Record) { r.Unit, r.Addr = "console.service", "127.0.0.1:8080" }) |
| +} |
| + |
| +// grant writes kass's instances manifest with an update policy whose |
| +// instance_control is names. |
| +func (fx *pageFx) grant(names ...string) { |
| + fx.t.Helper() |
| + enableInstances(fx.t, fx.consoleFixture, "kass", provision.Manifest{Schema: 1, App: "kass", Domain: "kass.carlosapp.dev", Channel: "stable", |
| + Updates: &provision.UpdatePolicy{InstanceControl: names}}) |
| +} |
| + |
| +// ageSession leaves c's session with no proof of presence, as the step-up |
| +// tests find a fresh sign-in: the owner has to confirm with a passkey. |
| +func (fx *pageFx) ageSession(c *http.Client) { |
| + fx.t.Helper() |
| + if _, err := fx.st.db.Exec(`DELETE FROM step_ups WHERE session_hash = ?`, hashToken(sessionToken(fx.t, fx.consoleFixture, c))); err != nil { |
| + fx.t.Fatal(err) |
| + } |
| +} |
| + |
| +func TestUpdatesSectionShowsDefaults(t *testing.T) { |
| + fx := updatesPageFixture(t) |
| + c := signInPaul(t, fx.consoleFixture) |
| + code, body := fx.getPage(c, fx.u("/apps/kass/instances/"+heldHost)) |
| + sec := updatesSection(body) |
| + if code != http.StatusOK || !strings.Contains(sec, esc(updatewords.Text("settings.page.heading"))) || |
| + !strings.Contains(sec, esc(updatewords.Text("settings.page.update-now"))) { |
| + t.Fatalf("page: %d\n%s", code, body) |
| + } |
| +} |
| + |
| +func TestPageSaveGoesThroughTheGate(t *testing.T) { |
| + fx := updatesPageFixture(t) |
| + fx.boxesEnforce(true) |
| + c := signInPaul(t, fx.consoleFixture) |
| + manual := url.Values{"form": {"updates"}, "auto_shown": {"1"}} // the checkbox unticked |
| + bad := url.Values{"csrf": {"wrong"}} |
| + for k, v := range manual { |
| + bad[k] = v |
| + } |
| + if code, _, _ := postForm(t, c, fx.u("/apps/kass/instances/"+heldHost+"/updates"), bad); code != http.StatusForbidden { |
| + t.Fatalf("bad csrf = %d", code) |
| + } |
| + csrf := sessionToken(t, fx.consoleFixture, c) |
| + with := func(v url.Values) url.Values { |
| + out := url.Values{"csrf": {csrf}} |
| + for k, x := range v { |
| + out[k] = x |
| + } |
| + return out |
| + } |
| + code, _, body := postForm(t, c, fx.u("/apps/kass/instances/nope.kass.carlosapp.dev/updates"), with(manual)) |
| + if code != http.StatusNotFound || body != "404 page not found\n" { |
| + t.Fatalf("unknown host = %d %q", code, body) |
| + } |
| + code, loc, _ := postForm(t, c, fx.u("/apps/kass/instances/"+heldHost+"/updates"), with(manual)) |
| + if code != http.StatusSeeOther || loc != fx.path("/apps/kass/instances/"+heldHost) || fx.record(heldHost).Updates != release.UpdatesManual { |
| + t.Fatalf("save = %d -> %q, record %+v", code, loc, fx.record(heldHost)) |
| + } |
| + if _, page := fx.getPage(c, loc); !strings.Contains(page, esc(updatewords.Text("settings.page.saved"))) { |
| + t.Fatalf("no saved notice:\n%s", page) |
| + } |
| + // An enabling change the fleet gate refuses comes back as plan 3's |
| + // reviewed refusal, verbatim, in the error flash; nothing is written. |
| + fx.setRecordUpdates(heldHost, "") |
| + fx.boxesEnforce(false) |
| + code, loc, _ = postForm(t, c, fx.u("/apps/kass/instances/"+heldHost+"/updates"), with(manual)) |
| + if code != http.StatusSeeOther || fx.record(heldHost).Updates != "" { |
| + t.Fatalf("refused save = %d, record %+v", code, fx.record(heldHost)) |
| + } |
| + refusal := fmt.Sprintf(updatewords.Text("settings.console.not-enforced"), 1) |
| + if _, page := fx.getPage(c, loc); !strings.Contains(page, `<p class="banner error">`+esc(refusal)+`</p>`) { |
| + t.Fatalf("refusal flash lacks %q:\n%s", refusal, page) |
| + } |
| +} |
| + |
| +// Review Focus 5. |
| +func TestPageUpdateNowRefusedWhenPinned(t *testing.T) { |
| + fx := updatesPageFixture(t) |
| + fx.pin(heldHost, "v1") |
| + c := signInPaul(t, fx.consoleFixture) |
| + csrf := sessionToken(t, fx.consoleFixture, c) |
| + code, loc, _ := postForm(t, c, fx.u("/apps/kass/instances/"+heldHost+"/updates/install"), url.Values{"csrf": {csrf}}) |
| + if code != http.StatusSeeOther { |
| + t.Fatalf("Update now = %d", code) |
| + } |
| + _, body := fx.getPage(c, loc) |
| + refusal := fmt.Sprintf(updatewords.Text("settings.console.pinned"), heldHost, "v1") |
| + if !strings.Contains(body, `<p class="banner error">`+esc(refusal)+`</p>`) || fx.record(heldHost).InstallRequest != nil { |
| + t.Fatalf("pinned Update now: %s / %+v", body, fx.record(heldHost).InstallRequest) |
| + } |
| +} |
| + |
| +func TestPageUpdateNowRequestsAnInstall(t *testing.T) { |
| + fx := updatesPageFixture(t) |
| + c := signInPaul(t, fx.consoleFixture) |
| + csrf := sessionToken(t, fx.consoleFixture, c) |
| + code, loc, _ := postForm(t, c, fx.u("/apps/kass/instances/"+heldHost+"/updates/install"), url.Values{"csrf": {csrf}}) |
| + rec := fx.record(heldHost) |
| + if code != http.StatusSeeOther || rec.InstallRequest == nil || rec.InstallRequest.ID != 1 || rec.InstallRequest.FastTrack || |
| + rec.InstallRequest.By != "paul@keymail.dev" { |
| + t.Fatalf("Update now = %d, record %+v", code, rec.InstallRequest) |
| + } |
| + if _, page := fx.getPage(c, loc); !strings.Contains(page, esc(updatewords.Text("settings.page.requested"))) { |
| + t.Fatalf("no requested notice:\n%s", page) |
| + } |
| +} |
| + |
| +// Review Focus 4, ruling R12: plan 3's approved named-unit words, no forms. |
| +func TestUpdatesSectionForANamedUnit(t *testing.T) { |
| + fx := updatesPageFixture(t) |
| + fx.grant("updates", "major", "pin") |
| + fx.makeNamedUnit(heldHost) |
| + c := signInPaul(t, fx.consoleFixture) |
| + _, body := fx.getPage(c, fx.u("/apps/kass/instances/"+heldHost)) |
| + sec := updatesSection(body) |
| + if !strings.Contains(sec, esc(fmt.Sprintf(updatewords.Text("settings.console.named-unit"), heldHost))) || |
| + strings.Contains(sec, "<form") || strings.Contains(sec, esc(updatewords.Text("settings.page.update-now"))) { |
| + t.Fatalf("named unit section:\n%s", sec) |
| + } |
| +} |
| + |
| +// R12: a record of another kind shows no Updates section at all. |
| +func TestUpdatesSectionIsAbsentForOtherKinds(t *testing.T) { |
| + fx := updatesPageFixture(t) |
| + fx.setRecordSettings(heldHost, func(r *provision.Record) { r.Kind, r.Addr = "service", "127.0.0.1:8080" }) |
| + c := signInPaul(t, fx.consoleFixture) |
| + code, body := fx.getPage(c, fx.u("/apps/kass/instances/"+heldHost)) |
| + if code != http.StatusOK || updatesSection(body) != "" { |
| + t.Fatalf("service route page = %d:\n%s", code, body) |
| + } |
| +} |
| + |
| +func TestNotEnforcedIsShown(t *testing.T) { |
| + fx := updatesPageFixture(t) |
| + fx.boxesEnforce(false) |
| + fx.setRecordUpdates(heldHost, "manual") // written directly: the door would refuse it |
| + c := signInPaul(t, fx.consoleFixture) |
| + _, body := fx.getPage(c, fx.u("/apps/kass/instances/"+heldHost)) |
| + if !strings.Contains(updatesSection(body), esc(updatewords.Text("settings.page.not-enforced"))) { |
| + t.Fatalf("page:\n%s", body) |
| + } |
| +} |
| + |
| +// R11: default settings on an old box are not worth a warning. |
| +func TestNotEnforcedIsHiddenForDefaultSettings(t *testing.T) { |
| + fx := updatesPageFixture(t) |
| + fx.boxesEnforce(false) |
| + c := signInPaul(t, fx.consoleFixture) |
| + _, body := fx.getPage(c, fx.u("/apps/kass/instances/"+heldHost)) |
| + if sec := updatesSection(body); sec == "" || strings.Contains(sec, esc(updatewords.Text("settings.page.not-enforced"))) { |
| + t.Fatalf("default settings section:\n%s", sec) |
| + } |
| +} |
| + |
| +func TestUpdatesSectionStatusLine(t *testing.T) { |
| + fx := updatesPageFixture(t) |
| + two := 2 |
| + fx.report = &status.HostUpdates{Desired: "v1", Reason: "major", Detail: "v3", Baking: "v4", Major: &two} |
| + fx.boxesEnforce(true) |
| + c := signInPaul(t, fx.consoleFixture) |
| + _, body := fx.getPage(c, fx.u("/apps/kass/instances/"+heldHost)) |
| + reason := fmt.Sprintf(updatewords.Text("settings.box.reason.major"), 2, "v3") + "; " + fmt.Sprintf(updatewords.Text("settings.box.reason.baking"), "v4") |
| + want := fmt.Sprintf(updatewords.Text("settings.page.status"), "v2", "v1", "v1", reason) |
| + if !strings.Contains(updatesSection(body), esc(want)) { |
| + t.Fatalf("status line %q not in:\n%s", want, updatesSection(body)) |
| + } |
| +} |
| + |
| +func TestReasonSentence(t *testing.T) { |
| + adopt := updatewords.Text("settings.box.reason.adopt") |
| + for _, tc := range []struct { |
| + u updatesJoinView |
| + want string |
| + }{ |
| + {updatesJoinView{}, adopt}, |
| + {updatesJoinView{Reason: "adopt"}, adopt}, |
| + {updatesJoinView{Reason: "adopt", Baking: "v4"}, adopt + "; " + fmt.Sprintf(updatewords.Text("settings.box.reason.baking"), "v4")}, |
| + {updatesJoinView{Reason: "pin", Detail: "v1"}, fmt.Sprintf(updatewords.Text("settings.box.reason.pin"), "v1")}, |
| + {updatesJoinView{Reason: "request", Detail: "v5"}, fmt.Sprintf(updatewords.Text("settings.box.reason.request"), "v5")}, |
| + {updatesJoinView{Reason: "manual", Detail: "v2"}, fmt.Sprintf(updatewords.Text("settings.box.reason.manual"), "v2")}, |
| + {updatesJoinView{Reason: "failed", Detail: "v2"}, fmt.Sprintf(updatewords.Text("settings.box.reason.failed"), "v2")}, |
| + {updatesJoinView{Reason: "unknown"}, updatewords.Text("settings.box.reason.unknown")}, |
| + {updatesJoinView{Reason: "new"}, updatewords.Text("settings.box.reason.new")}, |
| + {updatesJoinView{Reason: "unauthorized", Baking: "v4"}, ""}, // a word with no draft is never looked up |
| + } { |
| + if got := reasonSentence(&tc.u); got != tc.want { |
| + t.Errorf("reasonSentence(%+v) = %q, want %q", tc.u, got, tc.want) |
| + } |
| + } |
| +} |
| + |
| +func TestFastTrackNeedsFreshAuth(t *testing.T) { |
| + fx := updatesPageFixture(t) |
| + c := signInPaul(t, fx.consoleFixture) // an owner, with no recent proof of presence |
| + fx.ageSession(c) |
| + csrf := sessionToken(t, fx.consoleFixture, c) |
| + code, loc, _ := postForm(t, c, fx.u("/apps/kass/instances/"+heldHost+"/updates/install"), url.Values{"csrf": {csrf}, "fast_track": {"1"}}) |
| + if code != http.StatusSeeOther || !strings.HasPrefix(loc, fx.fk.srv.URL) || !strings.Contains(loc, "stepup") || fx.record(heldHost).InstallRequest != nil { |
| + t.Fatalf("fast-track without fresh auth = %d %s", code, loc) |
| + } |
| + // The passkey round trip resumes the held request, fast track and all. |
| + code, _, _ = completeStepUp(t, fx.consoleFixture, c, loc) |
| + if rec := fx.record(heldHost); code != http.StatusSeeOther || rec.InstallRequest == nil || !rec.InstallRequest.FastTrack { |
| + t.Fatalf("resumed fast track = %d, record %+v", code, rec.InstallRequest) |
| + } |
| +} |
| + |
| +// A plain member asking to skip the safety delay gets the fast-track door's |
| +// answer, the byte-identical 404, before any passkey is asked for. |
| +func TestFastTrackIsAnOwnersFromThePage(t *testing.T) { |
| + fx := updatesPageFixture(t) |
| + if err := fx.st.AddMember(OpsAccountID, "kim@keymail.dev", RoleMember, time.Now()); err != nil { |
| + t.Fatal(err) |
| + } |
| + fx.setUser("kim@keymail.dev") |
| + c := newBrowser(t) |
| + signUp(t, fx.consoleFixture, c, "code-kim") |
| + csrf := sessionToken(t, fx.consoleFixture, c) |
| + _, body := fx.getPage(c, fx.u("/apps/kass/instances/"+heldHost)) |
| + if sec := updatesSection(body); sec == "" || strings.Contains(sec, `name="fast_track"`) { |
| + t.Fatalf("a member's section:\n%s", sec) |
| + } |
| + code, _, out := postForm(t, c, fx.u("/apps/kass/instances/"+heldHost+"/updates/install"), url.Values{"csrf": {csrf}, "fast_track": {"1"}}) |
| + if code != http.StatusNotFound || out != "404 page not found\n" || fx.record(heldHost).InstallRequest != nil { |
| + t.Fatalf("member fast track = %d %q", code, out) |
| + } |
| +} |
| + |
| +// Astra I3 #8: with the fleet gate closed, a member can still switch auto |
| +// back on, clear a major hold and clear a pin from the page, each posted |
| +// exactly as the browser posts the whole form. |
| +func TestClearingWorksFromThePageWhileTheGateIsClosed(t *testing.T) { |
| + fx := updatesPageFixture(t) |
| + fx.grant("updates", "major", "pin") |
| + fx.boxesEnforce(false) |
| + fx.setChannel(heldHost, "stable") |
| + two := 2 |
| + fx.setRecordSettings(heldHost, func(r *provision.Record) { r.Updates, r.Major, r.Pin = "manual", &two, "v1" }) |
| + c := signInPaul(t, fx.consoleFixture) |
| + _, page := fx.getPage(c, fx.u("/apps/kass/instances/"+heldHost)) |
| + sec := updatesSection(page) |
| + if strings.Contains(sec, "disabled") { |
| + t.Fatal("a settings control is disabled while the gate is closed") |
| + } |
| + for _, want := range []string{`name="auto"`, `name="auto_shown"`, `name="major" value="2"`, `name="pin" value="v1"`, esc(updatewords.Text("settings.page.refused-until-boxes"))} { |
| + if !strings.Contains(sec, want) { |
| + t.Fatalf("section lacks %q:\n%s", want, sec) |
| + } |
| + } |
| + csrf := sessionToken(t, fx.consoleFixture, c) |
| + post := func(v url.Values) { |
| + t.Helper() |
| + v.Set("csrf", csrf) |
| + v.Set("form", "updates") |
| + v.Set("auto_shown", "1") |
| + code, loc, _ := postForm(t, c, fx.u("/apps/kass/instances/"+heldHost+"/updates"), v) |
| + if code != http.StatusSeeOther { |
| + t.Fatalf("save = %d", code) |
| + } |
| + if _, page := fx.getPage(c, loc); strings.Contains(page, `class="banner error"`) { |
| + t.Fatalf("save refused:\n%s", page) |
| + } |
| + } |
| + post(url.Values{"major": {"2"}, "pin": {""}}) // clear the pin; still manual (box unchecked), major unchanged |
| + if r := fx.record(heldHost); r.Pin != "" || r.Updates != "manual" || r.Major == nil || *r.Major != 2 { |
| + t.Fatalf("clearing the pin: %+v", r) |
| + } |
| + post(url.Values{"major": {""}, "pin": {""}}) // clear the major hold |
| + if r := fx.record(heldHost); r.Major != nil || r.Updates != "manual" { |
| + t.Fatalf("clearing major: %+v", r) |
| + } |
| + post(url.Values{"auto": {"1"}, "major": {""}, "pin": {""}}) // auto back on |
| + if r := fx.record(heldHost); r.Updates != "auto" { |
| + t.Fatalf("switching auto on: %+v", r) |
| + } |
| +} |
| + |
| +func TestAnOmittedCheckboxIsNotManual(t *testing.T) { |
| + v := updatesView{} |
| + v.Settings.Updates = "auto" |
| + if req := updatesFormRequest(url.Values{"csrf": {"x"}}, v); len(req) != 0 { |
| + t.Fatalf("a post without the form marker sent %v", req) |
| + } |
| + if req := updatesFormRequest(url.Values{"form": {"updates"}, "auto_shown": {"1"}, "auto": {"1"}, "pin": {""}}, v); len(req) != 0 { |
| + t.Fatalf("an unchanged form sent %v", req) |
| + } |
| + if req := updatesFormRequest(url.Values{"form": {"updates"}}, v); len(req) != 0 { |
| + t.Fatalf("a form with no checkbox shown sent %v", req) |
| + } |
| + if req := updatesFormRequest(url.Values{"form": {"updates"}, "auto": {"1"}}, v); len(req) != 0 { |
| + t.Fatalf("an auto with no checkbox shown sent %v", req) |
| + } |
| + if req := updatesFormRequest(url.Values{"form": {"updates"}, "auto_shown": {"1"}}, v); req["updates"] != "manual" || len(req) != 1 { |
| + t.Fatalf("unchecking auto sent %v", req) |
| + } |
| + two := 2 |
| + v.Explicit.Major, v.Explicit.Pin = &two, "v1" |
| + f := url.Values{"form": {"updates"}, "major": {" 2 "}, "pin": {" v1 "}} |
| + if req := updatesFormRequest(f, v); len(req) != 0 { |
| + t.Fatalf("an unchanged major and pin sent %v", req) |
| + } |
| + f = url.Values{"form": {"updates"}, "major": {"3"}, "pin": {""}} |
| + if req := updatesFormRequest(f, v); req["major"] != float64(3) || req["pin"] != "" || len(req) != 2 { |
| + t.Fatalf("a new major and a cleared pin sent %v", req) |
| + } |
| + f = url.Values{"form": {"updates"}, "major": {""}} |
| + if req := updatesFormRequest(f, v); req["major"] != "" || len(req) != 1 { |
| + t.Fatalf("a cleared major sent %v", req) |
| + } |
| + f = url.Values{"form": {"updates"}, "major": {"two"}} |
| + if req := updatesFormRequest(f, v); req["major"] != "two" { |
| + t.Fatalf("a bad major sent %v", req) |
| + } |
| +} |
| + |
| +// R10: a form without auto_shown (the checkbox was not rendered, because the |
| +// app keeps updates) never reads the absent checkbox as manual. |
| +func TestAnUnshownCheckboxIsNeverReadAsManual(t *testing.T) { |
| + fx := updatesPageFixture(t) |
| + fx.boxesEnforce(true) |
| + c := signInPaul(t, fx.consoleFixture) |
| + csrf := sessionToken(t, fx.consoleFixture, c) |
| + code, _, _ := postForm(t, c, fx.u("/apps/kass/instances/"+heldHost+"/updates"), url.Values{"csrf": {csrf}, "form": {"updates"}, "pin": {""}}) |
| + if r := fx.record(heldHost); code != http.StatusSeeOther || r.Updates != "" { |
| + t.Fatalf("save = %d, record %+v", code, r) |
| + } |
| +} |
| + |
| +// R10: with the default policy every setting is the app's: shown read-only, |
| +// no input for any of them, and no Save. |
| +func TestKeptSettingsAreReadOnly(t *testing.T) { |
| + fx := updatesPageFixture(t) |
| + two := 2 |
| + fx.setRecordSettings(heldHost, func(r *provision.Record) { r.Major, r.Pin = &two, "v1" }) |
| + c := signInPaul(t, fx.consoleFixture) |
| + _, body := fx.getPage(c, fx.u("/apps/kass/instances/"+heldHost)) |
| + sec := updatesSection(body) |
| + for _, bad := range []string{`name="auto"`, `name="auto_shown"`, `name="major"`, `name="pin"`, `name="form"`, esc(updatewords.Text("settings.page.save"))} { |
| + if strings.Contains(sec, bad) { |
| + t.Errorf("kept settings section has %q", bad) |
| + } |
| + } |
| + for _, want := range []string{esc(updatewords.Text("settings.page.kept")), "<code>auto</code>", "<code>stable</code>", "<code>2</code>", "<code>v1</code>", |
| + esc(updatewords.Text("settings.page.channel-hint")), esc(updatewords.Text("settings.page.update-now"))} { |
| + if !strings.Contains(sec, want) { |
| + t.Errorf("kept settings section lacks %q", want) |
| + } |
| + } |
| + if t.Failed() { |
| + t.Logf("section:\n%s", sec) |
| + } |
| +} |
| + |
| +// TestUpdatesSectionPreview writes the instance page, Updates section and |
| +// all, to $UPDATEWORDS_PREVIEW for Paul's copy review: an owner's view of a |
| +// pinned instance on stable whose app grants updates, major and pin, with a |
| +// box report naming the build it wants and the fleet gate closed. |
| +func TestUpdatesSectionPreview(t *testing.T) { |
| + out := os.Getenv("UPDATEWORDS_PREVIEW") |
| + if out == "" { |
| + t.Skip("UPDATEWORDS_PREVIEW is not set") |
| + } |
| + fx := updatesPageFixture(t) |
| + fx.grant("updates", "major", "pin") |
| + two := 2 |
| + fx.setRecordSettings(heldHost, func(r *provision.Record) { r.Channel, r.Major, r.Pin = "stable", &two, "v1" }) |
| + fx.report = &status.HostUpdates{Desired: "v1", Reason: "pin", Detail: "v1"} |
| + fx.boxesEnforce(true) |
| + seedEdgesRecord(t, fx.consoleFixture, "uk-1") // a box with no heartbeat closes the gate |
| + c := signInPaul(t, fx.consoleFixture) |
| + code, body := fx.getPage(c, fx.u("/apps/kass/instances/"+heldHost)) |
| + if code != http.StatusOK || !strings.Contains(updatesSection(body), esc(updatewords.Text("settings.page.refused-until-boxes"))) { |
| + t.Fatalf("preview page = %d:\n%s", code, updatesSection(body)) |
| + } |
| + if err := os.WriteFile(out, []byte(body), 0o644); err != nil { |
| + t.Fatal(err) |
| + } |
| + t.Logf("wrote %s", out) |
| +} |
diff --git a/internal/console/claim_invariant_fixtures_test.go b/internal/console/claim_invariant_fixtures_test.go| index 4d4d29378..7d26b5f2d 100644 |
| --- a/internal/console/claim_invariant_fixtures_test.go |
| +++ b/internal/console/claim_invariant_fixtures_test.go |
| @@ -958,6 +958,54 @@ var claimFixtures = map[string]claimFixture{ |
| return "" |
| }, |
| }}, |
| + // The instance page's Updates section (settings plan 5 Task 5): Save and |
| + // Update now, the browser twins of plan 3's PUT …/updates and POST |
| + // …/updates/install. Save sends only what changed, so the world has a box |
| + // that applies update settings and the form switches auto off (manual). |
| + "route POST /a/{sqid}/apps/{app}/instances/{host}/updates": {door: &claimDoor{ |
| + apps: claimRefs("bab/titogo"), |
| + setup: func(t *testing.T, cx *claimCase) { |
| + t.Helper() |
| + claimWorldInstance(t, cx) |
| + box := status.CollectBox(nil, status.Probes{MachineID: "claim-box"}, time.Now()) |
| + box.Metrics = &status.BoxMetrics{UpdateSettings: status.UpdateSettingsProtocol} |
| + if err := status.WriteBox(context.Background(), cx.rel, box); err != nil { |
| + t.Fatal(err) |
| + } |
| + }, |
| + do: claimForm("/a/bab/apps/titogo/instances/acme.go.tito.io/updates", "form", "updates", "auto_shown", "1"), |
| + check: func(t *testing.T, cx *claimCase, open bool) string { |
| + rec, err := provision.GetRecord(context.Background(), cx.rel, "bab", "titogo", "acme.go.tito.io") |
| + if err != nil { |
| + return err.Error() |
| + } |
| + switch manual := rec.Updates == release.UpdatesManual; { |
| + case open && !manual: |
| + return "the settings were not written: the handler never reached the narrowed log" |
| + case !open && manual: |
| + return "settings were written while the app was closed under a foreign exclusive operation" |
| + } |
| + return "" |
| + }, |
| + }}, |
| + "route POST /a/{sqid}/apps/{app}/instances/{host}/updates/install": {door: &claimDoor{ |
| + apps: claimRefs("bab/titogo"), |
| + setup: claimWorldInstance, |
| + do: claimForm("/a/bab/apps/titogo/instances/acme.go.tito.io/updates/install"), |
| + check: func(t *testing.T, cx *claimCase, open bool) string { |
| + rec, err := provision.GetRecord(context.Background(), cx.rel, "bab", "titogo", "acme.go.tito.io") |
| + if err != nil { |
| + return err.Error() |
| + } |
| + switch asked := rec.InstallRequest != nil; { |
| + case open && !asked: |
| + return "the install request was not written: the handler never reached the narrowed log" |
| + case !open && asked: |
| + return "an install request was written while the app was closed under a foreign exclusive operation" |
| + } |
| + return "" |
| + }, |
| + }}, |
| "route POST /a/{sqid}/apps/{app}/keep": {door: &claimDoor{ |
| apps: claimRefs("bab/tmp-claim-keep"), |
| setup: func(t *testing.T, cx *claimCase) { cx.temporary(t, "tmp-claim-keep") }, |
diff --git a/internal/console/instancepage.go b/internal/console/instancepage.go| index 387b6bd9d..7065cacad 100644 |
| --- a/internal/console/instancepage.go |
| +++ b/internal/console/instancepage.go |
| @@ -29,6 +29,9 @@ type instanceDetail struct { |
| // App and Channel name the `promote --retry` the hold suggests. |
| Rollout *rolloutView |
| App, Channel string |
| + // Updates is the instance's update settings section (settings spec §5); |
| + // nil when the host has no instance record or it could not be read. |
| + Updates *updatesPageView |
| } |
| // HoldText is the Failure hold row (spec §9.2), or "" when nothing holds |
| @@ -243,6 +246,9 @@ func (s *Server) handleAppInstance(w http.ResponseWriter, r *http.Request) { |
| // not from systemBoxes, which reads every account's fleets. |
| join, _ := s.loadRolloutJoin(r.Context(), acct.Sqid, time.Now(), nil) |
| detail.Rollout = join.rollout(host, acct.Sqid, p.AppName) |
| + // The Updates section reads the same join, so its status line and |
| + // the Installed and Serving facts above come from one reading. |
| + detail.Updates = s.updatesPage(r.Context(), acct, p.AppName, host, detail, join.update(host, acct.Sqid, p.AppName), p.Role == RoleOwner) |
| } |
| if reg, err := openRegistry(s.cfg.Registry); err == nil { |
| if aliases, err := reg.ListAliases(); err == nil { |
diff --git a/internal/console/server.go b/internal/console/server.go| index da8ae45bd..2986eecf3 100644 |
| --- a/internal/console/server.go |
| +++ b/internal/console/server.go |
| @@ -1364,6 +1364,8 @@ func NewServer(cfg Config, st *Store) (*Server, error) { |
| s.registerRoute("POST /a/{sqid}/apps/{app}/restart", s.handleAppRestart) |
| s.registerRoute("POST /a/{sqid}/apps/{app}/instances/{host}/restart", s.handleInstanceRestart) |
| s.registerRoute("POST /a/{sqid}/apps/{app}/instances/{host}/retry", s.handleInstanceRetry) |
| + s.registerRoute("POST /a/{sqid}/apps/{app}/instances/{host}/updates", s.handleBrowserUpdates) |
| + s.registerRoute("POST /a/{sqid}/apps/{app}/instances/{host}/updates/install", s.handleBrowserUpdateNow) |
| s.registerRoute("GET /a/{sqid}/apps/{app}/logs", s.handleAppLogs) |
| s.registerRoute("GET /a/{sqid}/apps/{app}/logs/data", s.handleAppLogsData) |
| // The Errors tab and its two setting doors (errorsview.go): the tab is |
diff --git a/internal/console/templates/app_instance.html b/internal/console/templates/app_instance.html| index bb68c62bb..6ba7a8f65 100644 |
| --- a/internal/console/templates/app_instance.html |
| +++ b/internal/console/templates/app_instance.html |
| @@ -33,5 +33,30 @@ |
| {{if eq .Row.HealthWord "No readings"}}<p class="small">{{$.InstanceCopy.NoReadings}}</p>{{end}} |
| {{if eq .Row.HealthWord "Asleep"}}<p class="small">wakes on the next request</p>{{end}} |
| {{if and .Row.Version .TargetVersion (not .TargetErr) (ne .Row.Version .TargetVersion)}}<p class="small">{{$.InstanceCopy.PendingVersion}}</p>{{end}} |
| +{{with .Updates}} |
| +<section class="updates"> |
| + <h3>{{$.UpdatesText "settings.page.heading"}}</h3> |
| + {{if .NamedUnit}}<p class="small">{{.NamedUnit}}</p>{{else}} |
| + {{if .NotEnforced}}<p class="banner warn">{{$.UpdatesText "settings.page.not-enforced"}}</p>{{end}} |
| + {{with .StatusLine}}<p class="small">{{.}}</p>{{end}} |
| + {{if .HasForm}}<form method="post" action="{{$.Base}}/apps/{{$.AppName}}/instances/{{$.InstanceDetail.Row.Host}}/updates"> |
| + <input type="hidden" name="csrf" value="{{$.CSRF}}"> |
| + <input type="hidden" name="form" value="updates">{{end}} |
| + <div>{{if .AutoLive}}<label><input type="checkbox" name="auto" value="1"{{if eq .UpdatesWord "auto"}} checked{{end}}> {{$.UpdatesText "settings.page.auto-label"}}</label><input type="hidden" name="auto_shown" value="1">{{else}}{{$.UpdatesText "settings.page.auto-label"}}: <code>{{.UpdatesWord}}</code> <span class="small">{{$.UpdatesText "settings.page.kept"}}</span>{{end}}</div> |
| + <div>{{$.UpdatesText "settings.page.channel-label"}}: <code>{{.Channel}}</code>{{if .KeptChannel}} <span class="small">{{$.UpdatesText "settings.page.kept"}}</span>{{end}} |
| + <div class="small">{{$.UpdatesText "settings.page.channel-hint"}}</div></div> |
| + {{if .ShowMajor}}<div>{{if .MajorLive}}<label>{{$.UpdatesText "settings.page.major-label"}} <input type="number" min="0" name="major" value="{{.MajorInput}}"></label>{{else}}{{$.UpdatesText "settings.page.major-label"}}: <code>{{.MajorShown}}</code> <span class="small">{{$.UpdatesText "settings.page.kept"}}</span>{{end}}</div>{{end}} |
| + <div>{{if .PinLive}}<label>{{$.UpdatesText "settings.page.pin-label"}} <input type="text" name="pin" value="{{.PinInput}}"></label>{{else}}{{$.UpdatesText "settings.page.pin-label"}}: <code>{{.PinShown}}</code> <span class="small">{{$.UpdatesText "settings.page.kept"}}</span>{{end}}</div> |
| + {{if .HasForm}}<button type="submit" class="btn-sm">{{$.UpdatesText "settings.page.save"}}</button> |
| + </form> |
| + {{if .Refusing}}<p class="small">{{$.UpdatesText "settings.page.refused-until-boxes"}}</p>{{end}}{{end}} |
| + <form method="post" action="{{$.Base}}/apps/{{$.AppName}}/instances/{{$.InstanceDetail.Row.Host}}/updates/install"> |
| + <input type="hidden" name="csrf" value="{{$.CSRF}}"> |
| + {{if .Owner}}<label><input type="checkbox" name="fast_track" value="1"> {{$.UpdatesText "settings.page.fast-track"}}</label>{{end}} |
| + <button type="submit" class="btn-sm">{{$.UpdatesText "settings.page.update-now"}}</button> |
| + </form> |
| + {{end}} |
| +</section> |
| +{{end}} |
| {{end}} |
| {{template "bottom" .}}{{end}} |
diff --git a/internal/console/testdata/claim-routes.txt b/internal/console/testdata/claim-routes.txt| index c4b9695ea..d358e46d8 100644 |
| --- a/internal/console/testdata/claim-routes.txt |
| +++ b/internal/console/testdata/claim-routes.txt |
| @@ -122,6 +122,8 @@ route POST /a/{sqid}/apps/{app}/fasttrack |
| route POST /a/{sqid}/apps/{app}/gate |
| route POST /a/{sqid}/apps/{app}/instances/{host}/restart |
| route POST /a/{sqid}/apps/{app}/instances/{host}/retry |
| +route POST /a/{sqid}/apps/{app}/instances/{host}/updates |
| +route POST /a/{sqid}/apps/{app}/instances/{host}/updates/install |
| route POST /a/{sqid}/apps/{app}/keep |
| route POST /a/{sqid}/apps/{app}/move |
| route POST /a/{sqid}/apps/{app}/pending/{id}/confirm |
diff --git a/internal/console/updatescore.go b/internal/console/updatescore.go| new file mode 100644 |
| index 000000000..79cba76ff |
| --- /dev/null |
| +++ b/internal/console/updatescore.go |
| @@ -0,0 +1,233 @@ |
| +// 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") |
| +} |