| 1 | package console |
| 2 | |
| 3 | // The four `carlos schedule` doors (spec §8): the member-facing way to say |
| 4 | // "run this path every 6h", to take it back, and to make it fire now — with |
| 5 | // no box shell, which is what the platform's own rule demands of every |
| 6 | // command ("every carlos command works for a user with zero infra access"). |
| 7 | // |
| 8 | // What the console LEARNS by relaying this is schedule names and paths: |
| 9 | // metadata of exactly the class it already holds for config var names. No |
| 10 | // payload crosses these doors in either direction — a tick is the box's agent |
| 11 | // calling an instance it already spawned, and this console never sees one. |
| 12 | // |
| 13 | // Why the objects these doors write are UNSIGNED, unlike a channel pointer: |
| 14 | // nothing executes because schedules.json says so. The declaration only tells |
| 15 | // a box WHEN to POST to a path the app itself already serves; the code that |
| 16 | // runs is whatever the signed release put there. A bucket writer who could |
| 17 | // forge a declaration could already promote an arbitrary release to the same |
| 18 | // app, which is strictly more (spec §10). So these keys sit beside |
| 19 | // restart-gen.json in the same trust class: additive, absent-is-legal-forever |
| 20 | // app metadata that the release-signing key has no opinion about. |
| 21 | // |
| 22 | // The serial bump after every write is the delivery mechanism, not |
| 23 | // decoration — restart's reasoning verbatim. Boxes do not poll declarations; |
| 24 | // they read them when the serial moves. A declaration written without a bump |
| 25 | // still converges on the hourly root pass, which is the floor, not the |
| 26 | // promise `carlos schedule set` makes. |
| 27 | |
| 28 | import ( |
| 29 | "context" |
| 30 | "fmt" |
| 31 | "log" |
| 32 | "net/http" |
| 33 | "time" |
| 34 | |
| 35 | "github.com/carlosframework/platform/internal/schedule" |
| 36 | ) |
| 37 | |
| 38 | // apiScheduleList is the one wire shape three of the four doors answer with: |
| 39 | // what the app has DECLARED, plus what each instance says it will actually do |
| 40 | // next (spec §4.3). Both arrays are always present — see scheduleAnswer. |
| 41 | type apiScheduleList struct { |
| 42 | Account string `json:"account"` |
| 43 | App string `json:"app"` |
| 44 | Schedules []schedule.Schedule `json:"schedules"` |
| 45 | Instances []schedule.Status `json:"instances"` |
| 46 | UpdatedAt string `json:"updated_at"` |
| 47 | } |
| 48 | |
| 49 | // scheduleAnswer builds that shape with `[]` where the store handed back nil. |
| 50 | // An app that has declared nothing, and one whose boxes have not reported |
| 51 | // yet, are the COMMON cases here — and `null` for either would make the CLI's |
| 52 | // range over them a nil-deref-shaped bug at the far end of a release. The |
| 53 | // empty array says the same thing and says it in a shape the caller can use. |
| 54 | func scheduleAnswer(acct, app string, d schedule.Declaration, insts []schedule.Status) apiScheduleList { |
| 55 | out := apiScheduleList{ |
| 56 | Account: acct, App: app, |
| 57 | Schedules: d.Schedules, Instances: insts, UpdatedAt: d.UpdatedAt, |
| 58 | } |
| 59 | if out.Schedules == nil { |
| 60 | out.Schedules = []schedule.Schedule{} |
| 61 | } |
| 62 | if out.Instances == nil { |
| 63 | out.Instances = []schedule.Status{} |
| 64 | } |
| 65 | return out |
| 66 | } |
| 67 | |
| 68 | // scheduleWriteFailed is the 502 sentence handleAPIScheduleSet and |
| 69 | // handleAPIScheduleRemove both give a bucket write failure — identical |
| 70 | // shape, one verb apart — so a member reads the same "nothing changed" out |
| 71 | // of either, and the wording lives in one place to say so. |
| 72 | func scheduleWriteFailed(verb string) string { |
| 73 | return "could not " + verb + " the schedule — nothing changed; try again" |
| 74 | } |
| 75 | |
| 76 | // apiScheduleApp is the shared gate of all four doors, in one place so the |
| 77 | // ORDER cannot drift between them: |
| 78 | // |
| 79 | // token — cliUser's one 401, before any name is resolved. |
| 80 | // account+app 404 — apiAccountApp: an unknown account, someone else's |
| 81 | // account and an app that isn't there are one answer, so |
| 82 | // probing these doors teaches a stranger nothing. A |
| 83 | // TRASHED app is unreachable for the same reason restart |
| 84 | // is: the live-apps lookup simply does not answer for it. |
| 85 | // rate limit — the shared `write` budget, keyed on the ADDRESS like |
| 86 | // promote's: a budget belongs to the person, not the |
| 87 | // terminal. Mutating doors only; a read is not rationed. |
| 88 | // `run` needs it most — its whole effect is to make an |
| 89 | // app do work now. |
| 90 | // Releases nil — 503. Every one of these doors IS a bucket object (or a |
| 91 | // read of one); a console with no bucket must not answer |
| 92 | // as though a box would ever see it. |
| 93 | // |
| 94 | // cannot completes the 503's sentence, so the caller is told what this |
| 95 | // console cannot do rather than what it is missing. |
| 96 | func (s *Server) apiScheduleApp(w http.ResponseWriter, r *http.Request, mutating bool, cannot string) (string, *Account, string, bool) { |
| 97 | addr, acct, app, ok := s.apiAccountApp(w, r) |
| 98 | if !ok { |
| 99 | return "", nil, "", false |
| 100 | } |
| 101 | if mutating { |
| 102 | p, _ := principalFrom(r.Context()) |
| 103 | if !s.allowWrite(p, time.Now()) { |
| 104 | jsonError(w, http.StatusTooManyRequests, "too many requests — wait a minute and try again") |
| 105 | return "", nil, "", false |
| 106 | } |
| 107 | } |
| 108 | if s.cfg.Releases == nil { |
| 109 | jsonError(w, http.StatusServiceUnavailable, "no deployment bucket on this host — "+cannot) |
| 110 | return "", nil, "", false |
| 111 | } |
| 112 | return addr, acct, app, true |
| 113 | } |
| 114 | |
| 115 | // declaredSchedule resolves the {name} segment of the two name-scoped doors. |
| 116 | // |
| 117 | // An invalid name and an undeclared one are the SAME answer, and it is |
| 118 | // http.NotFound — byte for byte what apiAccountApp writes for an app that |
| 119 | // isn't there. A name is not an account, but a probe is a probe: a member of |
| 120 | // one account must not be able to tell "you declared nothing called that" |
| 121 | // from "there is no such app" by the shape of the refusal, and neither must a |
| 122 | // service credential. An invalid name never reaches the store at all. |
| 123 | func (s *Server) declaredSchedule(ctx context.Context, w http.ResponseWriter, r *http.Request, acct, app string) (schedule.Schedule, bool) { |
| 124 | name := r.PathValue("name") |
| 125 | if !schedule.ValidName(name) { |
| 126 | http.NotFound(w, r) |
| 127 | return schedule.Schedule{}, false |
| 128 | } |
| 129 | d, _, err := schedule.ReadDeclaration(ctx, s.cfg.Releases, acct, app) |
| 130 | if err != nil { |
| 131 | // Logged, not returned: a store error carries bucket names, endpoints |
| 132 | // and request ids. |
| 133 | log.Printf("console: api schedule read %s/%s: %v", acct, app, err) |
| 134 | jsonError(w, http.StatusBadGateway, "could not read the schedules — nothing changed; try again") |
| 135 | return schedule.Schedule{}, false |
| 136 | } |
| 137 | sc, found := d.Find(name) |
| 138 | if !found { |
| 139 | http.NotFound(w, r) |
| 140 | return schedule.Schedule{}, false |
| 141 | } |
| 142 | return sc, true |
| 143 | } |
| 144 | |
| 145 | // handleAPIScheduleList answers `carlos schedule ls`: the declaration, plus |
| 146 | // every box's report for the app. |
| 147 | // |
| 148 | // The two halves answer different questions and both are needed. The |
| 149 | // declaration says what was ASKED for; the status objects say what each |
| 150 | // instance is actually going to do next and what happened last time — which |
| 151 | // is the only honest answer available to a member who cannot log into a box. |
| 152 | // |
| 153 | // A report that will not parse does NOT fail the door. ListStatus returns the |
| 154 | // readable ones plus a joined error, and one box writing rubbish must not |
| 155 | // blank the list for every other instance of the app; the error is logged so |
| 156 | // it is findable, and the caller gets what is knowable. The DECLARATION is |
| 157 | // the other way round: it is the answer, not a contribution to it, so a store |
| 158 | // error there is a 502. |
| 159 | // |
| 160 | // The line between those two is whether ListStatus could LIST at all. A |
| 161 | // failed listing comes back as (nil, err) and means nothing is knowable — |
| 162 | // answering 200 with `instances: []` there would print "no instance has |
| 163 | // reported yet" for a bucket the console could not reach, and a member would |
| 164 | // read a broken console as a fleet that has not caught up. That is a 502. |
| 165 | // Some reports plus a joined error is the partial case above. |
| 166 | // |
| 167 | // No write budget and no audit line: it is a read. |
| 168 | func (s *Server) handleAPIScheduleList(w http.ResponseWriter, r *http.Request) { |
| 169 | _, acct, app, ok := s.apiScheduleApp(w, r, false, "this console cannot read schedules") |
| 170 | if !ok { |
| 171 | return |
| 172 | } |
| 173 | ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second) |
| 174 | defer cancel() |
| 175 | d, _, err := schedule.ReadDeclaration(ctx, s.cfg.Releases, acct.Sqid, app) |
| 176 | if err != nil { |
| 177 | log.Printf("console: api schedule list %s/%s: %v", acct.Sqid, app, err) |
| 178 | jsonError(w, http.StatusBadGateway, "could not read the schedules — try again") |
| 179 | return |
| 180 | } |
| 181 | insts, err := schedule.ListStatus(ctx, s.cfg.Releases, acct.Sqid, app) |
| 182 | if err != nil { |
| 183 | log.Printf("console: api schedule status %s/%s: %v", acct.Sqid, app, err) |
| 184 | if insts == nil { |
| 185 | jsonError(w, http.StatusBadGateway, "could not read instance reports — try again") |
| 186 | return |
| 187 | } |
| 188 | } |
| 189 | writeJSON(w, http.StatusOK, scheduleAnswer(acct.Sqid, app, d, insts)) |
| 190 | } |
| 191 | |
| 192 | // handleAPIScheduleSet answers `carlos schedule set`: validate, CAS the |
| 193 | // schedule into schedules.json by name, announce. |
| 194 | // |
| 195 | // Validation runs HERE, before anything is read or written, and answers with |
| 196 | // the validator's own words. A fixed sentence would make the member guess |
| 197 | // which of name/every/cron/path was wrong, and the CLI runs the same |
| 198 | // functions client-side so most typos never reach this door at all. |
| 199 | // |
| 200 | // The per-app ceiling is checked against the declaration we are about to |
| 201 | // modify, so it answers as the member's problem (400, naming the number) |
| 202 | // rather than as the bucket failure it would look like coming back out of |
| 203 | // UpsertSchedule. That call enforces the cap again under CAS — this check is |
| 204 | // for the ANSWER, not for the guarantee. |
| 205 | // |
| 206 | // The response carries no instance reports (`instances: []`). Nothing has |
| 207 | // been delivered yet at the moment this returns: the boxes have been told to |
| 208 | // look, and `carlos schedule ls` a second later is what shows them looking. |
| 209 | func (s *Server) handleAPIScheduleSet(w http.ResponseWriter, r *http.Request) { |
| 210 | addr, acct, app, ok := s.apiScheduleApp(w, r, true, "this console cannot record a schedule") |
| 211 | if !ok { |
| 212 | return |
| 213 | } |
| 214 | var sc schedule.Schedule |
| 215 | if !readJSON(w, r, &sc) { |
| 216 | return |
| 217 | } |
| 218 | if err := sc.Validate(); err != nil { |
| 219 | jsonError(w, http.StatusBadRequest, err.Error()) |
| 220 | return |
| 221 | } |
| 222 | ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second) |
| 223 | defer cancel() |
| 224 | // ReadDeclaration already folds a genuine not-found into an empty, |
| 225 | // nil-error Declaration (spec §4.1: absence is legal forever) — so a |
| 226 | // non-nil err here is a real read failure (bucket unreachable, bad |
| 227 | // JSON), not "this app has no schedules yet". Silently skipping the |
| 228 | // cap check in that case would let a request past the door's own |
| 229 | // answer and rely entirely on UpsertSchedule's CAS-guarded re-check, |
| 230 | // which is there for the GUARANTEE, not to cover for a 502 this door |
| 231 | // should have surfaced itself. |
| 232 | cur, _, err := schedule.ReadDeclaration(ctx, s.cfg.Releases, acct.Sqid, app) |
| 233 | if err != nil { |
| 234 | log.Printf("console: api schedule set %s/%s: %v", acct.Sqid, app, err) |
| 235 | jsonError(w, http.StatusBadGateway, scheduleWriteFailed("record")) |
| 236 | return |
| 237 | } |
| 238 | if _, replacing := cur.Find(sc.Name); !replacing && len(cur.Schedules) >= schedule.MaxSchedulesPerApp { |
| 239 | jsonError(w, http.StatusBadRequest, fmt.Sprintf( |
| 240 | "an app may declare at most %d schedules — remove one first", schedule.MaxSchedulesPerApp)) |
| 241 | return |
| 242 | } |
| 243 | d, err := schedule.UpsertSchedule(ctx, s.cfg.Releases, acct.Sqid, app, sc, time.Now()) |
| 244 | if err != nil { |
| 245 | log.Printf("console: api schedule set %s/%s %s: %v", acct.Sqid, app, sc.Name, err) |
| 246 | jsonError(w, http.StatusBadGateway, scheduleWriteFailed("record")) |
| 247 | return |
| 248 | } |
| 249 | s.audit(acct, app, addr, "schedule set", "recorded schedule "+sc.Name+" (cli)") |
| 250 | // The declaration is in the bucket; this is what makes boxes LOOK at it. |
| 251 | s.bumpSerial(ctx) |
| 252 | writeJSON(w, http.StatusOK, scheduleAnswer(acct.Sqid, app, d, nil)) |
| 253 | } |
| 254 | |
| 255 | // handleAPIScheduleRemove answers `carlos schedule rm`: drop the named |
| 256 | // schedule and announce, so the boxes stop firing it within seconds rather |
| 257 | // than at the next hourly pass. |
| 258 | // |
| 259 | // The name is resolved BEFORE the removal, so an undeclared one is the |
| 260 | // byte-identical 404 with nothing written — removing a name that was never |
| 261 | // there would otherwise rewrite updated_at and bump the serial for a change |
| 262 | // that isn't one. |
| 263 | func (s *Server) handleAPIScheduleRemove(w http.ResponseWriter, r *http.Request) { |
| 264 | addr, acct, app, ok := s.apiScheduleApp(w, r, true, "this console cannot remove a schedule") |
| 265 | if !ok { |
| 266 | return |
| 267 | } |
| 268 | ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second) |
| 269 | defer cancel() |
| 270 | sc, ok := s.declaredSchedule(ctx, w, r, acct.Sqid, app) |
| 271 | if !ok { |
| 272 | return |
| 273 | } |
| 274 | // present (RemoveSchedule's second return) is ignored: declaredSchedule |
| 275 | // just confirmed sc.Name was declared, and RemoveSchedule is idempotent |
| 276 | // by design (its own doc comment — removing an absent name still |
| 277 | // rewrites updated_at). A concurrent remove landing between that read |
| 278 | // and this call is the same successful outcome either way, not |
| 279 | // something for this door to distinguish. |
| 280 | d, _, err := schedule.RemoveSchedule(ctx, s.cfg.Releases, acct.Sqid, app, sc.Name, time.Now()) |
| 281 | if err != nil { |
| 282 | log.Printf("console: api schedule rm %s/%s %s: %v", acct.Sqid, app, sc.Name, err) |
| 283 | jsonError(w, http.StatusBadGateway, scheduleWriteFailed("remove")) |
| 284 | return |
| 285 | } |
| 286 | s.audit(acct, app, addr, "schedule rm", "removed schedule "+sc.Name+" (cli)") |
| 287 | s.bumpSerial(ctx) |
| 288 | writeJSON(w, http.StatusOK, scheduleAnswer(acct.Sqid, app, d, nil)) |
| 289 | } |
| 290 | |
| 291 | // handleAPIScheduleRun answers `carlos schedule run`: touch |
| 292 | // schedule-run.json, whose ETAG MOVEMENT is the signal (never its content), |
| 293 | // then announce — the same mechanism and the same rules as restart-gen. |
| 294 | // |
| 295 | // It answers "requested", not "fired", for restart's reason: what lands here |
| 296 | // is a request in a bucket. This write wakes nothing itself; each box's |
| 297 | // converger marks the row due when it sees the serial move, and the box's |
| 298 | // runner delivers on its next tick — waking a hibernating instance for it |
| 299 | // the way a visitor would (spec §6.3) — so a fire follows within ~15s, and |
| 300 | // `carlos logs` is where it shows. |
| 301 | func (s *Server) handleAPIScheduleRun(w http.ResponseWriter, r *http.Request) { |
| 302 | addr, acct, app, ok := s.apiScheduleApp(w, r, true, "this console cannot request a run") |
| 303 | if !ok { |
| 304 | return |
| 305 | } |
| 306 | ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second) |
| 307 | defer cancel() |
| 308 | sc, ok := s.declaredSchedule(ctx, w, r, acct.Sqid, app) |
| 309 | if !ok { |
| 310 | return |
| 311 | } |
| 312 | if err := schedule.TouchRun(ctx, s.cfg.Releases, acct.Sqid, app, sc.Name, time.Now()); err != nil { |
| 313 | log.Printf("console: api schedule run %s/%s %s: %v", acct.Sqid, app, sc.Name, err) |
| 314 | jsonError(w, http.StatusBadGateway, "could not request the run — nothing was fired; try again") |
| 315 | return |
| 316 | } |
| 317 | s.audit(acct, app, addr, "schedule run", "requested a run of schedule "+sc.Name+" (cli)") |
| 318 | s.bumpSerial(ctx) |
| 319 | writeJSON(w, http.StatusOK, map[string]any{ |
| 320 | "requested": true, "account": acct.Sqid, "app": app, "name": sc.Name, |
| 321 | }) |
| 322 | } |
| 323 | |