carlos / platform Public

settings-5 task 5: the instance page's Updates section

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev 9900efbaf5eb431a954e00f0a42f3d2965abf97d parent 80a18a6
9 files changed, +1138 −175
  • internal/console/apiupdates.go +22 −175
  • internal/console/browserupdates.go +295 −0
  • internal/console/browserupdates_test.go +505 −0
  • internal/console/claim_invariant_fixtures_test.go +48 −0
  • internal/console/instancepage.go +6 −0
  • internal/console/server.go +2 −0
  • internal/console/templates/app_instance.html +25 −0
  • internal/console/testdata/claim-routes.txt +2 −0
  • internal/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")
+}