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
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
28import (
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.
41type 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.
54func 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.
72func 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.
96func (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.
123func (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.
168func (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.
209func (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.
263func (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.
301func (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