package console // The four `carlos schedule` doors (spec §8): the member-facing way to say // "run this path every 6h", to take it back, and to make it fire now — with // no box shell, which is what the platform's own rule demands of every // command ("every carlos command works for a user with zero infra access"). // // What the console LEARNS by relaying this is schedule names and paths: // metadata of exactly the class it already holds for config var names. No // payload crosses these doors in either direction — a tick is the box's agent // calling an instance it already spawned, and this console never sees one. // // Why the objects these doors write are UNSIGNED, unlike a channel pointer: // nothing executes because schedules.json says so. The declaration only tells // a box WHEN to POST to a path the app itself already serves; the code that // runs is whatever the signed release put there. A bucket writer who could // forge a declaration could already promote an arbitrary release to the same // app, which is strictly more (spec §10). So these keys sit beside // restart-gen.json in the same trust class: additive, absent-is-legal-forever // app metadata that the release-signing key has no opinion about. // // The serial bump after every write is the delivery mechanism, not // decoration — restart's reasoning verbatim. Boxes do not poll declarations; // they read them when the serial moves. A declaration written without a bump // still converges on the hourly root pass, which is the floor, not the // promise `carlos schedule set` makes. import ( "context" "fmt" "log" "net/http" "time" "github.com/carlosframework/platform/internal/schedule" ) // apiScheduleList is the one wire shape three of the four doors answer with: // what the app has DECLARED, plus what each instance says it will actually do // next (spec §4.3). Both arrays are always present — see scheduleAnswer. type apiScheduleList struct { Account string `json:"account"` App string `json:"app"` Schedules []schedule.Schedule `json:"schedules"` Instances []schedule.Status `json:"instances"` UpdatedAt string `json:"updated_at"` } // scheduleAnswer builds that shape with `[]` where the store handed back nil. // An app that has declared nothing, and one whose boxes have not reported // yet, are the COMMON cases here — and `null` for either would make the CLI's // range over them a nil-deref-shaped bug at the far end of a release. The // empty array says the same thing and says it in a shape the caller can use. func scheduleAnswer(acct, app string, d schedule.Declaration, insts []schedule.Status) apiScheduleList { out := apiScheduleList{ Account: acct, App: app, Schedules: d.Schedules, Instances: insts, UpdatedAt: d.UpdatedAt, } if out.Schedules == nil { out.Schedules = []schedule.Schedule{} } if out.Instances == nil { out.Instances = []schedule.Status{} } return out } // scheduleWriteFailed is the 502 sentence handleAPIScheduleSet and // handleAPIScheduleRemove both give a bucket write failure — identical // shape, one verb apart — so a member reads the same "nothing changed" out // of either, and the wording lives in one place to say so. func scheduleWriteFailed(verb string) string { return "could not " + verb + " the schedule — nothing changed; try again" } // apiScheduleApp is the shared gate of all four doors, in one place so the // ORDER cannot drift between them: // // token — cliUser's one 401, before any name is resolved. // account+app 404 — apiAccountApp: an unknown account, someone else's // account and an app that isn't there are one answer, so // probing these doors teaches a stranger nothing. A // TRASHED app is unreachable for the same reason restart // is: the live-apps lookup simply does not answer for it. // rate limit — the shared `write` budget, keyed on the ADDRESS like // promote's: a budget belongs to the person, not the // terminal. Mutating doors only; a read is not rationed. // `run` needs it most — its whole effect is to make an // app do work now. // Releases nil — 503. Every one of these doors IS a bucket object (or a // read of one); a console with no bucket must not answer // as though a box would ever see it. // // cannot completes the 503's sentence, so the caller is told what this // console cannot do rather than what it is missing. func (s *Server) apiScheduleApp(w http.ResponseWriter, r *http.Request, mutating bool, cannot string) (string, *Account, string, bool) { addr, acct, app, ok := s.apiAccountApp(w, r) if !ok { return "", nil, "", false } if mutating { p, _ := principalFrom(r.Context()) if !s.allowWrite(p, time.Now()) { jsonError(w, http.StatusTooManyRequests, "too many requests — wait a minute and try again") return "", nil, "", false } } if s.cfg.Releases == nil { jsonError(w, http.StatusServiceUnavailable, "no deployment bucket on this host — "+cannot) return "", nil, "", false } return addr, acct, app, true } // declaredSchedule resolves the {name} segment of the two name-scoped doors. // // An invalid name and an undeclared one are the SAME answer, and it is // http.NotFound — byte for byte what apiAccountApp writes for an app that // isn't there. A name is not an account, but a probe is a probe: a member of // one account must not be able to tell "you declared nothing called that" // from "there is no such app" by the shape of the refusal, and neither must a // service credential. An invalid name never reaches the store at all. func (s *Server) declaredSchedule(ctx context.Context, w http.ResponseWriter, r *http.Request, acct, app string) (schedule.Schedule, bool) { name := r.PathValue("name") if !schedule.ValidName(name) { http.NotFound(w, r) return schedule.Schedule{}, false } d, _, err := schedule.ReadDeclaration(ctx, s.cfg.Releases, acct, app) if err != nil { // Logged, not returned: a store error carries bucket names, endpoints // and request ids. log.Printf("console: api schedule read %s/%s: %v", acct, app, err) jsonError(w, http.StatusBadGateway, "could not read the schedules — nothing changed; try again") return schedule.Schedule{}, false } sc, found := d.Find(name) if !found { http.NotFound(w, r) return schedule.Schedule{}, false } return sc, true } // handleAPIScheduleList answers `carlos schedule ls`: the declaration, plus // every box's report for the app. // // The two halves answer different questions and both are needed. The // declaration says what was ASKED for; the status objects say what each // instance is actually going to do next and what happened last time — which // is the only honest answer available to a member who cannot log into a box. // // A report that will not parse does NOT fail the door. ListStatus returns the // readable ones plus a joined error, and one box writing rubbish must not // blank the list for every other instance of the app; the error is logged so // it is findable, and the caller gets what is knowable. The DECLARATION is // the other way round: it is the answer, not a contribution to it, so a store // error there is a 502. // // The line between those two is whether ListStatus could LIST at all. A // failed listing comes back as (nil, err) and means nothing is knowable — // answering 200 with `instances: []` there would print "no instance has // reported yet" for a bucket the console could not reach, and a member would // read a broken console as a fleet that has not caught up. That is a 502. // Some reports plus a joined error is the partial case above. // // No write budget and no audit line: it is a read. func (s *Server) handleAPIScheduleList(w http.ResponseWriter, r *http.Request) { _, acct, app, ok := s.apiScheduleApp(w, r, false, "this console cannot read schedules") if !ok { return } ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second) defer cancel() d, _, err := schedule.ReadDeclaration(ctx, s.cfg.Releases, acct.Sqid, app) if err != nil { log.Printf("console: api schedule list %s/%s: %v", acct.Sqid, app, err) jsonError(w, http.StatusBadGateway, "could not read the schedules — try again") return } insts, err := schedule.ListStatus(ctx, s.cfg.Releases, acct.Sqid, app) if err != nil { log.Printf("console: api schedule status %s/%s: %v", acct.Sqid, app, err) if insts == nil { jsonError(w, http.StatusBadGateway, "could not read instance reports — try again") return } } writeJSON(w, http.StatusOK, scheduleAnswer(acct.Sqid, app, d, insts)) } // handleAPIScheduleSet answers `carlos schedule set`: validate, CAS the // schedule into schedules.json by name, announce. // // Validation runs HERE, before anything is read or written, and answers with // the validator's own words. A fixed sentence would make the member guess // which of name/every/cron/path was wrong, and the CLI runs the same // functions client-side so most typos never reach this door at all. // // The per-app ceiling is checked against the declaration we are about to // modify, so it answers as the member's problem (400, naming the number) // rather than as the bucket failure it would look like coming back out of // UpsertSchedule. That call enforces the cap again under CAS — this check is // for the ANSWER, not for the guarantee. // // The response carries no instance reports (`instances: []`). Nothing has // been delivered yet at the moment this returns: the boxes have been told to // look, and `carlos schedule ls` a second later is what shows them looking. func (s *Server) handleAPIScheduleSet(w http.ResponseWriter, r *http.Request) { addr, acct, app, ok := s.apiScheduleApp(w, r, true, "this console cannot record a schedule") if !ok { return } var sc schedule.Schedule if !readJSON(w, r, &sc) { return } if err := sc.Validate(); err != nil { jsonError(w, http.StatusBadRequest, err.Error()) return } // Plans 3b: a schedule may not run more often than the plan allows. if err := s.checkSchedule(acct, app, sc); err != nil { if isPlanCap(err) { writeJSON(w, http.StatusConflict, map[string]any{"error": err.Error(), "code": "cap"}) return } log.Printf("console: api schedule set %s/%s: plan: %v", acct.Sqid, app, err) jsonError(w, http.StatusBadGateway, planCheckFailedCopy) return } ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second) defer cancel() // ReadDeclaration already folds a genuine not-found into an empty, // nil-error Declaration (spec §4.1: absence is legal forever) — so a // non-nil err here is a real read failure (bucket unreachable, bad // JSON), not "this app has no schedules yet". Silently skipping the // cap check in that case would let a request past the door's own // answer and rely entirely on UpsertSchedule's CAS-guarded re-check, // which is there for the GUARANTEE, not to cover for a 502 this door // should have surfaced itself. cur, _, err := schedule.ReadDeclaration(ctx, s.cfg.Releases, acct.Sqid, app) if err != nil { log.Printf("console: api schedule set %s/%s: %v", acct.Sqid, app, err) jsonError(w, http.StatusBadGateway, scheduleWriteFailed("record")) return } if _, replacing := cur.Find(sc.Name); !replacing && len(cur.Schedules) >= schedule.MaxSchedulesPerApp { jsonError(w, http.StatusBadRequest, fmt.Sprintf( "an app may declare at most %d schedules — remove one first", schedule.MaxSchedulesPerApp)) return } d, err := schedule.UpsertSchedule(ctx, s.cfg.Releases, acct.Sqid, app, sc, time.Now()) if err != nil { log.Printf("console: api schedule set %s/%s %s: %v", acct.Sqid, app, sc.Name, err) jsonError(w, http.StatusBadGateway, scheduleWriteFailed("record")) return } s.audit(acct, app, addr, "schedule set", "recorded schedule "+sc.Name+" (cli)") // The declaration is in the bucket; this is what makes boxes LOOK at it. s.bumpSerial(ctx) writeJSON(w, http.StatusOK, scheduleAnswer(acct.Sqid, app, d, nil)) } // handleAPIScheduleRemove answers `carlos schedule rm`: drop the named // schedule and announce, so the boxes stop firing it within seconds rather // than at the next hourly pass. // // The name is resolved BEFORE the removal, so an undeclared one is the // byte-identical 404 with nothing written — removing a name that was never // there would otherwise rewrite updated_at and bump the serial for a change // that isn't one. func (s *Server) handleAPIScheduleRemove(w http.ResponseWriter, r *http.Request) { addr, acct, app, ok := s.apiScheduleApp(w, r, true, "this console cannot remove a schedule") if !ok { return } ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second) defer cancel() sc, ok := s.declaredSchedule(ctx, w, r, acct.Sqid, app) if !ok { return } // present (RemoveSchedule's second return) is ignored: declaredSchedule // just confirmed sc.Name was declared, and RemoveSchedule is idempotent // by design (its own doc comment — removing an absent name still // rewrites updated_at). A concurrent remove landing between that read // and this call is the same successful outcome either way, not // something for this door to distinguish. d, _, err := schedule.RemoveSchedule(ctx, s.cfg.Releases, acct.Sqid, app, sc.Name, time.Now()) if err != nil { log.Printf("console: api schedule rm %s/%s %s: %v", acct.Sqid, app, sc.Name, err) jsonError(w, http.StatusBadGateway, scheduleWriteFailed("remove")) return } s.audit(acct, app, addr, "schedule rm", "removed schedule "+sc.Name+" (cli)") s.bumpSerial(ctx) writeJSON(w, http.StatusOK, scheduleAnswer(acct.Sqid, app, d, nil)) } // handleAPIScheduleRun answers `carlos schedule run`: touch // schedule-run.json, whose ETAG MOVEMENT is the signal (never its content), // then announce — the same mechanism and the same rules as restart-gen. // // It answers "requested", not "fired", for restart's reason: what lands here // is a request in a bucket. This write wakes nothing itself; each box's // converger marks the row due when it sees the serial move, and the box's // runner delivers on its next tick — waking a hibernating instance for it // the way a visitor would (spec §6.3) — so a fire follows within ~15s, and // `carlos logs` is where it shows. func (s *Server) handleAPIScheduleRun(w http.ResponseWriter, r *http.Request) { addr, acct, app, ok := s.apiScheduleApp(w, r, true, "this console cannot request a run") if !ok { return } ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second) defer cancel() sc, ok := s.declaredSchedule(ctx, w, r, acct.Sqid, app) if !ok { return } if err := schedule.TouchRun(ctx, s.cfg.Releases, acct.Sqid, app, sc.Name, time.Now()); err != nil { log.Printf("console: api schedule run %s/%s %s: %v", acct.Sqid, app, sc.Name, err) jsonError(w, http.StatusBadGateway, "could not request the run — nothing was fired; try again") return } s.audit(acct, app, addr, "schedule run", "requested a run of schedule "+sc.Name+" (cli)") s.bumpSerial(ctx) writeJSON(w, http.StatusOK, map[string]any{ "requested": true, "account": acct.Sqid, "app": app, "name": sc.Name, }) }