carlos / platform Public

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

Plain git — no account needed to clone.

Download

Download this file

1package console
2
3import (
4 "context"
5 "errors"
6 "fmt"
7 "log"
8 "net/http"
9 "slices"
10 "time"
11
12 "github.com/carlosframework/platform/internal/release"
13)
14
15// Copy Paul approved for pointer digests (#253), recorded in
16// docs/superpowers/plans/2026-10-07-instance-settings-1-copy.json and pinned
17// by approved_copy_digests_test.go.
18const (
19 textNoReleaseKey = "this console has no release key"
20 textPointerWrite = "could not write the pointer"
21 textMarkerWrite = "could not write the marker for %s"
22 textActivityResigned = "re-signed %s at %s with manifest digests (cli)"
23)
24
25// handleAPIChannelResign answers `carlos channels resign` (#253, spec §4.6).
26// A body naming only the channel gets the plan; a body that also carries
27// the digests the operator confirmed signs them, if a plan made now is
28// exactly that plan. The console signs with its root key for an app member,
29// as it does for a promotion.
30//
31// Member-level, like rollback, and on the same write budget, drawn before
32// any store work. It is closed to service credentials (the zero Set): the
33// confirmation is a person's.
34//
35// The plan is any member's to read. Signing it takes the channel's release
36// authority, the rollback door's (Astra r1 #2): resign binds bytes to a
37// version name, so a member who could not roll stable back cannot resign it
38// either. Stable is refused before the bucket is read, with the rollback
39// door's refusal; a pipelined channel's rules are asked inside the admitted
40// mutation (ResignAuthorized). A channel demanding approvals refuses as one
41// demanding a passkey does, since a resign has no proposal to make.
42func (s *Server) handleAPIChannelResign(w http.ResponseWriter, r *http.Request) {
43 addr, acct, app, ok := s.apiAccountApp(w, r)
44 if !ok {
45 return
46 }
47 p, _ := principalFrom(r.Context())
48 if !s.allowWrite(p, time.Now()) {
49 jsonError(w, http.StatusTooManyRequests, textTooManyRequests)
50 return
51 }
52 var req struct {
53 Channel string `json:"channel"`
54 Digest string `json:"digest"`
55 HistoryDigests []string `json:"history_digests"`
56 }
57 if !readJSON(w, r, &req) {
58 return
59 }
60 if !release.ValidChannel(req.Channel) {
61 jsonError(w, http.StatusBadRequest, "invalid channel name")
62 return
63 }
64 if len(s.cfg.Key) == 0 {
65 jsonError(w, http.StatusServiceUnavailable, textNoReleaseKey)
66 return
67 }
68 if req.Digest != "" && s.refuseStableOverAPI(w, p, acct, app, req.Channel) {
69 return
70 }
71 ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second)
72 defer cancel()
73 sg := release.RootSigner(s.cfg.Key)
74 plan, err := sg.PlanResign(ctx, s.cfg.Releases, acct.Sqid, app, req.Channel)
75 if err != nil {
76 jsonError(w, http.StatusConflict, err.Error())
77 return
78 }
79 if req.Digest == "" {
80 writeJSON(w, http.StatusOK, map[string]any{
81 "channel": plan.Channel, "version": plan.Version, "digest": plan.Digest,
82 "history": plan.History, "history_digests": plan.HistoryDigests, "computed": plan.Computed,
83 })
84 return
85 }
86 if req.Digest != plan.Digest || !slices.Equal(req.HistoryDigests, plan.HistoryDigests) {
87 jsonError(w, http.StatusConflict, fmtResignChanged(req.Channel))
88 return
89 }
90 if err := sg.ResignAuthorized(ctx, s.cfg.Releases, plan, addr, resignAuthorize(p, req.Channel)); err != nil {
91 if errors.Is(err, release.ErrResignChanged) {
92 jsonError(w, http.StatusConflict, fmtResignChanged(req.Channel))
93 return
94 }
95 var ae *release.AuthorizationError
96 if errors.As(err, &ae) {
97 var pr *passkeyRequired
98 if errors.As(ae.Err, &pr) {
99 s.writePasskeyRefusal(w, acct, app, pr.channel)
100 return
101 }
102 s.writeDeployRefusal(w, ae.Err)
103 return
104 }
105 log.Printf("console: api resign %s/%s %s: %v", acct.Sqid, app, req.Channel, err)
106 jsonError(w, http.StatusBadGateway, textPointerWrite)
107 return
108 }
109 s.auditActivity(acct, app, addr, Activity{
110 Action: "resign", TargetType: "channel", Target: req.Channel,
111 Detail: fmt.Sprintf(textActivityResigned, req.Channel, plan.Version),
112 })
113 s.bumpSerial(ctx)
114 writeJSON(w, http.StatusOK, map[string]string{"result": "resigned"})
115}
116
117// passkeyRequired is resignAuthorize's refusal: the channel's release
118// authority is a passkey in the browser. The door answers it with
119// writePasskeyRefusal; its Error text is never shown.
120type passkeyRequired struct{ channel string }
121
122func (e *passkeyRequired) Error() string { return e.channel + ": passkey required" }
123
124// resignAuthorize is the rollback door's channel authority, asked inside the
125// resign's admitted mutation of the pipeline read there: deploy scope as
126// rollback asks it, stable unconditionally, and a pipelined channel whose
127// rules demand a passkey or approvals.
128func resignAuthorize(p Principal, channel string) func(*release.Pipeline) error {
129 scope := deployScopeAuthorize(p, channel)
130 return func(pp *release.Pipeline) error {
131 if err := scope(pp); err != nil {
132 return err
133 }
134 if rung, _ := release.Rung(channel); rung == release.RungStable {
135 return &passkeyRequired{channel: release.RungStable}
136 }
137 if rules := channelRules(pp, channel); rules.Passkey || rules.PromoteApprovals >= 1 {
138 return &passkeyRequired{channel: channel}
139 }
140 return nil
141 }
142}
143
144func fmtResignChanged(channel string) string {
145 return fmt.Sprintf(release.TextResignChanged, channel)
146}
147
148// handleAPISystemPointerDigests is GET and POST /api/cli/system/pointer-digests:
149// the emission marker per account and the channels still without digests
150// (spec §4.6 "Watch for stragglers", "The fleet-wide switch"). A POST turns
151// the marker on or off for the accounts it names, or every account the
152// console knows, and answers the report as the GET does.
153func (s *Server) handleAPISystemPointerDigests(w http.ResponseWriter, r *http.Request) {
154 addr, _, ok := s.apiSystemRoller(w, r)
155 if !ok {
156 return
157 }
158 if !s.systemBucket(w) {
159 return
160 }
161 ctx, cancel := context.WithTimeout(r.Context(), 60*time.Second)
162 defer cancel()
163 if r.Method == http.MethodPost {
164 p, _ := principalFrom(r.Context())
165 if !s.allowWrite(p, time.Now()) {
166 jsonError(w, http.StatusTooManyRequests, textTooManyRequests)
167 return
168 }
169 var req struct {
170 On bool `json:"on"`
171 Accounts []string `json:"accounts"`
172 }
173 if !readJSON(w, r, &req) {
174 return
175 }
176 if len(s.cfg.Key) == 0 {
177 jsonError(w, http.StatusServiceUnavailable, textNoReleaseKey)
178 return
179 }
180 accts, err := s.store.Accounts()
181 if err != nil {
182 jsonError(w, http.StatusInternalServerError, "could not load accounts")
183 return
184 }
185 for _, a := range accts {
186 if len(req.Accounts) > 0 && !slices.Contains(req.Accounts, a.Sqid) {
187 continue
188 }
189 action := "pointer-digests-on"
190 if req.On {
191 err = release.WriteDigestMarker(ctx, s.cfg.Releases, release.RootSigner(s.cfg.Key), a.Sqid, addr)
192 } else {
193 action = "pointer-digests-off"
194 err = release.RemoveDigestMarker(ctx, s.cfg.Releases, a.Sqid)
195 }
196 if err != nil {
197 log.Printf("console: pointer digests %s: %v", a.Sqid, err)
198 jsonError(w, http.StatusBadGateway, fmt.Sprintf(textMarkerWrite, a.Sqid))
199 return
200 }
201 s.auditSystem(addr, action, "account "+a.Sqid+" (cli)")
202 }
203 }
204 accts, err := s.store.Accounts()
205 if err != nil {
206 jsonError(w, http.StatusInternalServerError, "could not load accounts")
207 return
208 }
209 type acctRow struct {
210 Account string `json:"account"`
211 On bool `json:"on"`
212 Since string `json:"since,omitempty"`
213 Error string `json:"error,omitempty"`
214 }
215 type chRow struct {
216 Account string `json:"account"`
217 App string `json:"app"`
218 Channel string `json:"channel"`
219 Version string `json:"version"`
220 Straggler bool `json:"straggler"`
221 }
222 acctRows, chRows := []acctRow{}, []chRow{}
223 for _, a := range accts {
224 row := acctRow{Account: a.Sqid}
225 m, err := release.ReadDigestMarker(ctx, s.cfg.Releases, s.cfg.ReleasePub, a.Sqid)
226 if err != nil {
227 row.Error = err.Error()
228 acctRows = append(acctRows, row)
229 continue
230 }
231 if m != nil {
232 row.On, row.Since = true, m.At.UTC().Format(time.RFC3339)
233 }
234 apps, err := s.store.AppsIn(a.ID)
235 if err != nil {
236 row.Error = "could not load apps"
237 acctRows = append(acctRows, row)
238 continue
239 }
240 for _, app := range apps {
241 states, _, err := release.DigestReport(ctx, s.cfg.Releases, s.cfg.ReleasePub, a.Sqid, app)
242 if err != nil {
243 row.Error = err.Error()
244 break
245 }
246 for _, st := range states {
247 switch {
248 case st.Err != "":
249 // Unknown, not "has digests": the account's report is
250 // incomplete, so the CLI never calls the fleet ready.
251 if row.Error == "" {
252 row.Error = st.Err
253 }
254 case !st.HeadDigest:
255 chRows = append(chRows, chRow{Account: a.Sqid, App: app, Channel: st.Channel, Version: st.Version, Straggler: st.Straggler})
256 }
257 }
258 }
259 acctRows = append(acctRows, row)
260 }
261 writeJSON(w, http.StatusOK, map[string]any{"accounts": acctRows, "channels": chRows})
262}
263