carlos / platform Public

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

Plain git — no account needed to clone.

Download

Download this file

1// The instance update settings core (settings plan 5 Task 5): what plan 3's
2// JSON doors (apiupdates.go) and the instance page's two forms
3// (browserupdates.go) both do once their gates have passed. Neither front
4// reads, validates, gates or writes a setting itself, so the page and the API
5// cannot disagree about what is allowed.
6//
7// Nothing here writes a response. A refusal comes back as a status and the
8// reviewed message; the JSON doors answer it with jsonError (answerUpdates),
9// the page as an error flash. A 404 with no message is the family's plain
10// http.NotFound, so its body stays byte-identical to an unknown host's.
11package console
12
13import (
14 "context"
15 "encoding/json"
16 "errors"
17 "fmt"
18 "log"
19 "net/http"
20 "os"
21 "strings"
22 "time"
23
24 "github.com/carlosframework/platform/internal/provision"
25 "github.com/carlosframework/platform/internal/release"
26 "github.com/carlosframework/platform/internal/updatewords"
27)
28
29// answerUpdates writes a core refusal as the JSON doors always have: the
30// family's plain 404 for a host with no record, else the reviewed message.
31func answerUpdates(w http.ResponseWriter, r *http.Request, status int, msg string) {
32 if status == http.StatusNotFound && msg == "" {
33 http.NotFound(w, r)
34 return
35 }
36 jsonError(w, status, msg)
37}
38
39// readUpdatesTarget reads the record and the app policy for a settings door.
40// status is 0 on success; a host with no record is 404 with no message.
41func (s *Server) readUpdatesTarget(ctx context.Context, acct *Account, app, host string) (*provision.Record, string, *provision.UpdatePolicy, int, string) {
42 rec, etag, err := provision.GetRecordETag(ctx, s.cfg.Releases, acct.Sqid, app, host)
43 switch {
44 case errors.Is(err, os.ErrNotExist):
45 return nil, "", nil, http.StatusNotFound, ""
46 case err != nil:
47 log.Printf("console: api updates %s/%s/%s: %v", acct.Sqid, app, host, err)
48 return nil, "", nil, http.StatusBadGateway, updatewords.Text("settings.console.read-failed")
49 }
50 m, err := provision.GetManifest(ctx, s.cfg.Releases, acct.Sqid, app)
51 if err != nil && !errors.Is(err, os.ErrNotExist) {
52 log.Printf("console: api updates %s/%s: manifest: %v", acct.Sqid, app, err)
53 return nil, "", nil, http.StatusBadGateway, updatewords.Text("settings.console.policy-read-failed")
54 }
55 var policy *provision.UpdatePolicy
56 if m != nil {
57 policy = m.Updates
58 }
59 return rec, etag, policy, 0, ""
60}
61
62// shapeRefusal is §4.5's refusals: a named-unit route keeps today's
63// adoption, and only instances have update settings. status 0 means neither.
64func shapeRefusal(rec *provision.Record) (int, string) {
65 switch {
66 case rec.Unit != "":
67 return http.StatusConflict, fmt.Sprintf(updatewords.Text("settings.console.named-unit"), rec.Host)
68 case rec.KindOrDefault() != "instance":
69 return http.StatusConflict, fmt.Sprintf(updatewords.Text("settings.console.not-instance"), rec.Host, rec.KindOrDefault())
70 }
71 return 0, ""
72}
73
74// enableRefusal is the §4.4 gate in front of a change that turns a setting
75// ON: status 0 when every box applies update settings. A member is told how
76// many boxes are not ready (R3); which boxes is operator detail, logged with
77// what (the target the log line names) and never shown.
78func (s *Server) enableRefusal(ctx context.Context, what string) (int, string) {
79 ok, blocking, err := s.updatesEnforced(ctx)
80 switch {
81 case err != nil:
82 log.Printf("console: api updates %s: enable refused, the box inventory could not be read: %v", what, err)
83 return http.StatusBadGateway, updatewords.Text("settings.console.enforcement-unknown")
84 case !ok:
85 log.Printf("console: api updates %s: enable refused, not every box applies update settings: %s", what, strings.Join(blocking, "; "))
86 return http.StatusConflict, fmt.Sprintf(updatewords.Text("settings.console.not-enforced"), len(blocking))
87 }
88 return 0, ""
89}
90
91// applyUpdates sets any of updates, major and pin on rec, read under etag: a
92// key present with "" or null clears it, and any other value of the wrong
93// type is refused rather than read as a clear. It answers the view and 200,
94// or a status and the reviewed refusal. An empty req writes nothing. It
95// writes preferences only: the console never chooses a build (spec §4).
96func (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) {
97 if st, msg := shapeRefusal(rec); st != 0 {
98 return updatesView{}, st, msg
99 }
100 next := *rec
101 // majorSet: this request sets major to a number the record does not
102 // already hold. A major re-sent unchanged (a form that sends its full
103 // state) is not a new major, so it is not refused off stable either.
104 majorSet := false
105 if v, has := req["updates"]; has {
106 str, ok := updatesValue(v)
107 if !ok {
108 return updatesView{}, http.StatusBadRequest, updatewords.Text("settings.console.bad-updates")
109 }
110 next.Updates = str
111 }
112 if v, has := req["major"]; has {
113 major, ok := majorValue(v)
114 if !ok {
115 return updatesView{}, http.StatusBadRequest, updatewords.Text("settings.console.bad-major")
116 }
117 next.Major, majorSet = major, major != nil && !sameMajor(rec.Major, major)
118 }
119 if v, has := req["pin"]; has {
120 var str string
121 switch p := v.(type) {
122 case nil:
123 case string:
124 str = p
125 default:
126 raw, _ := json.Marshal(v)
127 return updatesView{}, http.StatusBadRequest, fmt.Sprintf(updatewords.Text("settings.console.bad-pin"), shownValue(string(raw)))
128 }
129 if str != "" && (!release.ValidVersion(str) || str == "dev") {
130 return updatesView{}, http.StatusBadRequest, fmt.Sprintf(updatewords.Text("settings.console.bad-pin"), shownValue(str))
131 }
132 next.Pin = str
133 }
134 // Only a major THIS request sets or changes is refused off stable. A
135 // major left by a channel move is ignored by reconcile (and shown as
136 // major_ignored); it neither blocks an unrelated change, nor a clear sent
137 // beside it unchanged, nor is it silently cleared by one (R4). Validation
138 // below runs with it masked.
139 if majorSet && next.Channel != "stable" {
140 return updatesView{}, http.StatusBadRequest, fmt.Sprintf(updatewords.Text("settings.console.major-off-stable"), rec.Host, next.Channel)
141 }
142 check := next
143 if !majorSet && check.Channel != "stable" {
144 check.Major = nil
145 }
146 if err := provision.ValidateUpdates(check); err != nil {
147 // Every shape ValidateUpdates refuses is refused above with reviewed
148 // words; reaching this is a new shape, and its plain text is API detail.
149 return updatesView{}, http.StatusBadRequest, err.Error()
150 }
151 changes := updatesChanges(*rec, next)
152 if len(changes) == 0 {
153 // Nothing changed: no write, no serial bump, no audit line, and
154 // nothing to refuse.
155 return s.updatesViewOf(ctx, *rec, policy), http.StatusOK, ""
156 }
157 if next.Pin != "" && next.Pin != rec.Pin {
158 if _, err := release.ReadPromotionRecord(ctx, s.cfg.Releases, s.cfg.ReleasePub, acct.Sqid, app, next.Pin); err != nil {
159 if errors.Is(err, release.ErrNoPromotionRecord) || errors.Is(err, os.ErrNotExist) {
160 return updatesView{}, http.StatusConflict, fmt.Sprintf(updatewords.Text("settings.console.pin-unpromoted"), next.Pin)
161 }
162 log.Printf("console: api updates %s/%s/%s: promotion record %s: %v", acct.Sqid, app, rec.Host, next.Pin, err)
163 return updatesView{}, http.StatusBadGateway, fmt.Sprintf(updatewords.Text("settings.console.pin-check-failed"), next.Pin)
164 }
165 }
166 // Only a change that turns a setting ON waits for the fleet: manual newly
167 // set, a different pin, or a new major. A value re-sent unchanged beside
168 // a clear (a form that sends its full state) is not an enable, so the
169 // clear is never refused (§4.4).
170 enabling := (next.Updates == release.UpdatesManual && rec.Updates != release.UpdatesManual) ||
171 (next.Pin != "" && next.Pin != rec.Pin) ||
172 (next.Major != nil && !sameMajor(rec.Major, next.Major))
173 if enabling {
174 if st, msg := s.enableRefusal(ctx, acct.Sqid+"/"+app+"/"+rec.Host); st != 0 {
175 return updatesView{}, st, msg
176 }
177 }
178 if err := provision.UpdateRecord(ctx, s.cfg.Releases, next, etag); err != nil {
179 if errors.Is(err, release.ErrCASConflict) {
180 return updatesView{}, http.StatusConflict, updatewords.Text("settings.console.changed")
181 }
182 log.Printf("console: api updates %s/%s/%s: %v", acct.Sqid, app, rec.Host, err)
183 return updatesView{}, http.StatusBadGateway, updatewords.Text("settings.console.store-failed")
184 }
185 s.auditActivity(acct, app, principal, Activity{Action: "instance-updates", TargetType: "instance", Target: rec.Host,
186 Detail: fmt.Sprintf(updatewords.Text("settings.console.audit-set"), rec.Host, strings.Join(changes, ", "))})
187 // A box-consumed write: reconcile resolves the settings into the
188 // registry from this record, so announcing it is what makes it prompt.
189 s.bumpSerial(ctx)
190 return s.updatesViewOf(ctx, next, policy), http.StatusOK, ""
191}
192
193// requestInstall is "Update now" (spec §4.2, §4.3 rule 2) once the caller's
194// gates, the fast-track owner check among them, have passed. It raises the
195// record's install_request id by one under the record's etag, retrying a
196// lost race, so ids only ever increase and no request is lost. It answers
197// the request id and 200, or a status and the reviewed refusal (404 with no
198// message for a host with no record). Root resolves the request to a build;
199// the console never names one.
200func (s *Server) requestInstall(ctx context.Context, acct *Account, app, principal, host string, fast bool) (uint64, int, string) {
201 for attempt := 0; attempt < 5; attempt++ {
202 rec, etag, _, st, msg := s.readUpdatesTarget(ctx, acct, app, host)
203 if st != 0 {
204 return 0, st, msg
205 }
206 if st, msg := shapeRefusal(rec); st != 0 {
207 return 0, st, msg
208 }
209 if rec.Pin != "" {
210 return 0, http.StatusConflict, fmt.Sprintf(updatewords.Text("settings.console.pinned"), rec.Host, rec.Pin)
211 }
212 var id uint64 = 1
213 if rec.InstallRequest != nil {
214 id = rec.InstallRequest.ID + 1
215 }
216 next := *rec
217 next.InstallRequest = &provision.InstallRequest{ID: id, FastTrack: fast, At: time.Now().UTC().Truncate(time.Second), By: principal}
218 err := provision.UpdateRecord(ctx, s.cfg.Releases, next, etag)
219 if errors.Is(err, release.ErrCASConflict) {
220 continue // another writer moved the record: read it again
221 }
222 if err != nil {
223 log.Printf("console: api updates install %s/%s/%s: %v", acct.Sqid, app, rec.Host, err)
224 return 0, http.StatusBadGateway, updatewords.Text("settings.console.store-failed")
225 }
226 s.auditActivity(acct, app, principal, Activity{Action: "instance-update-now", TargetType: "instance", Target: rec.Host,
227 Detail: fmt.Sprintf(updatewords.Text("settings.console.audit-install"), rec.Host, id)})
228 // Box-consumed: root resolves the request on its next pass.
229 s.bumpSerial(ctx)
230 return id, http.StatusOK, ""
231 }
232 return 0, http.StatusConflict, updatewords.Text("settings.console.changed")
233}
234