| 1 | package console |
| 2 | |
| 3 | import ( |
| 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. |
| 18 | const ( |
| 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. |
| 42 | func (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. |
| 120 | type passkeyRequired struct{ channel string } |
| 121 | |
| 122 | func (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. |
| 128 | func 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 | |
| 144 | func 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. |
| 153 | func (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 | |