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 "encoding/json"
6 "errors"
7 "fmt"
8 "log"
9 "net/http"
10 "os"
11 "strconv"
12 "time"
13
14 "github.com/carlosframework/platform/internal/provision"
15 "github.com/carlosframework/platform/internal/release"
16 "github.com/carlosframework/platform/internal/runs"
17)
18
19const runReadFailedMsg = "could not read the run. try again"
20
21// runCall is one follow route's resolved request.
22type runCall struct {
23 p Principal
24 actor string
25 acct *Account
26 app string
27 host string
28 id string
29}
30
31func noRunMsg(host, id string) string { return fmt.Sprintf("%s has no run %s", host, id) }
32
33// runGate is the four follow routes' shared gate, in the create door's
34// order: a person or a run service, a member (the byte-identical 404), the
35// host-claim refusal, the runs budget, then a run id of the right shape. A
36// service then reaches only a run its own credential started: any other is
37// answered exactly as an unknown id is, so a run's existence does not leak.
38func (s *Server) runGate(w http.ResponseWriter, r *http.Request) (runCall, bool) {
39 p, ok := principalFrom(r.Context())
40 if !ok || !runCaller(p) {
41 http.NotFound(w, r)
42 return runCall{}, false
43 }
44 actor, acct, app, ok := s.apiAccountApp(w, r)
45 if !ok {
46 return runCall{}, false
47 }
48 if s.cfg.HostClaim {
49 jsonErrorCode(w, http.StatusConflict, runCodeHostClaim, claimRunUnsupportedMsg)
50 return runCall{}, false
51 }
52 if !s.cli.runs.allow(hashToken(bearer(r)), time.Now()) {
53 jsonErrorCode(w, http.StatusTooManyRequests, runCodeRateLimited, runTooManyMsg)
54 return runCall{}, false
55 }
56 c := runCall{p: p, actor: actor, acct: acct, app: app, host: r.PathValue("host"), id: r.PathValue("id")}
57 if s.cfg.Releases == nil {
58 jsonErrorCode(w, http.StatusServiceUnavailable, runCodeUnavailable, runReadFailedMsg)
59 return runCall{}, false
60 }
61 if !provision.ValidHostShape(c.host) || !runs.ValidID(c.id) {
62 jsonErrorCode(w, http.StatusNotFound, runCodeNotFound, noRunMsg(c.host, c.id))
63 return runCall{}, false
64 }
65 if p.Kind == PrincipalService {
66 req, err := runs.ReadRequest(r.Context(), s.cfg.Releases, acct.Sqid, app, c.id)
67 switch {
68 case err != nil && !errors.Is(err, os.ErrNotExist):
69 jsonErrorCode(w, http.StatusBadGateway, runCodeReadFailed, runReadFailedMsg)
70 return runCall{}, false
71 case err != nil || !s.runVisibleTo(p, *req):
72 jsonErrorCode(w, http.StatusNotFound, runCodeNotFound, noRunMsg(c.host, c.id))
73 return runCall{}, false
74 }
75 }
76 return c, true
77}
78
79// runOf is the record's run when it is c.id, nil otherwise (the record is
80// gone, or names another run). A read failure is an error.
81func (s *Server) runOf(ctx context.Context, c runCall) (*provision.RunState, error) {
82 rec, err := provision.GetRecord(ctx, s.cfg.Releases, c.acct.Sqid, c.app, c.host)
83 if errors.Is(err, os.ErrNotExist) {
84 return nil, nil
85 }
86 if err != nil {
87 return nil, err
88 }
89 if rec.ActiveRun != nil && rec.ActiveRun.ID == c.id {
90 return rec.ActiveRun, nil
91 }
92 return nil, nil
93}
94
95// requestOnHost reports whether the run's objects exist and belong to c.host.
96// A run id names one run in the app's subtree; the host in the path must be
97// the host the run was admitted for. A read failure is an error.
98func (s *Server) requestOnHost(ctx context.Context, c runCall) (bool, error) {
99 req, err := runs.ReadRequest(ctx, s.cfg.Releases, c.acct.Sqid, c.app, c.id)
100 if errors.Is(err, os.ErrNotExist) {
101 return false, nil
102 }
103 if err != nil {
104 return false, err
105 }
106 return req.Host == c.host, nil
107}
108
109// runWriteCtx is the context for a record write that must run to its end
110// even if the caller has gone: a cancelled conditional write may or may not
111// have landed, and what follows (an audit row, a delete) depends on which.
112func runWriteCtx(ctx context.Context) (context.Context, context.CancelFunc) {
113 return context.WithTimeout(context.WithoutCancel(ctx), 30*time.Second)
114}
115
116// handleAPIRunGet is GET …/runs/{id}?after=<seq> (run spec §5.6). It reads
117// result.json BEFORE it lists chunks: the box writes the result only after
118// every chunk is durable, so a page that carries the result also carries
119// (or pages on to) every chunk up to its last_seq. Reading them the other
120// way round could list the chunks, then see a result written after, and
121// tell the caller the run is complete with chunks missing.
122func (s *Server) handleAPIRunGet(w http.ResponseWriter, r *http.Request) {
123 c, ok := s.runGate(w, r)
124 if !ok {
125 return
126 }
127 ctx := r.Context()
128 var after int64
129 if q := r.URL.Query().Get("after"); q != "" {
130 var err error
131 if after, err = strconv.ParseInt(q, 10, 64); err != nil || after < 0 {
132 jsonErrorCode(w, http.StatusBadRequest, runCodeMalformed, runMalformedMsg)
133 return
134 }
135 }
136 run, err := s.runOf(ctx, c)
137 if err != nil {
138 jsonErrorCode(w, http.StatusBadGateway, runCodeReadFailed, runReadFailedMsg)
139 return
140 }
141 if run == nil {
142 // The record no longer names the run (cleared, or another run since):
143 // its objects are what is left of it.
144 found, err := s.requestOnHost(ctx, c)
145 switch {
146 case err != nil:
147 jsonErrorCode(w, http.StatusBadGateway, runCodeReadFailed, runReadFailedMsg)
148 return
149 case !found:
150 jsonErrorCode(w, http.StatusNotFound, runCodeNotFound, noRunMsg(c.host, c.id))
151 return
152 }
153 }
154 res, err := runs.ReadResult(ctx, s.cfg.Releases, c.acct.Sqid, c.app, c.id)
155 if err != nil {
156 jsonErrorCode(w, http.StatusBadGateway, runCodeReadFailed, runReadFailedMsg)
157 return
158 }
159 keys, err := runs.ListChunks(ctx, s.cfg.Releases, c.acct.Sqid, c.app, c.id, after, runs.PageChunks+1)
160 if err != nil {
161 jsonErrorCode(w, http.StatusBadGateway, runCodeReadFailed, runReadFailedMsg)
162 return
163 }
164 p := runs.Page{ID: c.id, Result: res, Chunks: []runs.Chunk{}}
165 if run != nil {
166 p.State = run.State
167 }
168 out := runs.OutPrefix(c.acct.Sqid, c.app, c.id)
169 size := 0
170 for i, k := range keys {
171 if i == runs.PageChunks {
172 p.More = true
173 break
174 }
175 seq, stream, ok := runs.ParseChunkKey(k, out)
176 if !ok {
177 continue
178 }
179 raw, _, err := s.cfg.Releases.Get(ctx, k)
180 if err != nil {
181 jsonErrorCode(w, http.StatusBadGateway, runCodeReadFailed, runReadFailedMsg)
182 return
183 }
184 if size+len(raw) > runs.PageBytes && len(p.Chunks) > 0 {
185 p.More = true
186 break
187 }
188 size += len(raw)
189 p.Chunks = append(p.Chunks, runs.Chunk{Seq: seq, Stream: stream, Data: raw})
190 }
191 if res != nil {
192 s.auditRunEnd(ctx, c.acct, c.app, c.host, c.id, runEndOutcome(res), res)
193 }
194 writeJSON(w, http.StatusOK, p)
195}
196
197// handleAPIRunCancel is POST …/runs/{id}/cancel (run spec §5.7): queued
198// becomes cancelled (terminal; the run-end row is written now), picked
199// becomes cancel and the serial is bumped so the owning box hears it.
200func (s *Server) handleAPIRunCancel(w http.ResponseWriter, r *http.Request) {
201 c, ok := s.runGate(w, r)
202 if !ok {
203 return
204 }
205 ctx, cancel := runWriteCtx(r.Context())
206 defer cancel()
207 got, err := provision.SetRecordRun(ctx, s.cfg.Releases, c.acct.Sqid, c.app, c.host, provision.RunCancelTransition(c.id))
208 if err != nil {
209 s.runTransitionRefused(w, ctx, c, err)
210 return
211 }
212 switch got.ActiveRun.State {
213 case provision.RunCancelled:
214 if err := s.endUnpicked(ctx, c, provision.RunCancelled); err != nil {
215 log.Printf("console: api run %s/%s/%s %s: end after cancel: %v", c.acct.Sqid, c.app, c.host, c.id, err)
216 jsonErrorCode(w, http.StatusBadGateway, runCodeRecordFailed, runRecordFailedMsg)
217 return
218 }
219 case provision.RunCancel:
220 s.bumpSerial(ctx)
221 }
222 writeJSON(w, http.StatusOK, runs.StateAnswer{State: got.ActiveRun.State})
223}
224
225// handleAPIRunExpire is POST …/runs/{id}/expire (run spec §5.2): the CLI's
226// "not picked up", asked for sixty seconds after its request. The route
227// applies the transition and does not judge the clock; the sweep, which
228// expires on its own, decides by the record's pickup Deadline (stamped at
229// admission, after the upload), never by request.json's requested_at. A
230// pickup that landed first wins.
231func (s *Server) handleAPIRunExpire(w http.ResponseWriter, r *http.Request) {
232 c, ok := s.runGate(w, r)
233 if !ok {
234 return
235 }
236 ctx, cancel := runWriteCtx(r.Context())
237 defer cancel()
238 got, err := provision.SetRecordRun(ctx, s.cfg.Releases, c.acct.Sqid, c.app, c.host, provision.RunExpire(c.id))
239 if err != nil {
240 s.runTransitionRefused(w, ctx, c, err)
241 return
242 }
243 if err := s.endUnpicked(ctx, c, provision.RunExpired); err != nil {
244 log.Printf("console: api run %s/%s/%s %s: end after expire: %v", c.acct.Sqid, c.app, c.host, c.id, err)
245 jsonErrorCode(w, http.StatusBadGateway, runCodeRecordFailed, runRecordFailedMsg)
246 return
247 }
248 writeJSON(w, http.StatusOK, runs.StateAnswer{State: got.ActiveRun.State})
249}
250
251// endUnpicked finishes a run that ended before any box picked it: cancelled
252// or expired is terminal, so no box will ever write its result.json. The
253// console writes one (create-only, so a repeat changes nothing), so that every
254// terminal run has a result a follower can read after the record has moved
255// on, and then the run-end row. state is the record's terminal state.
256func (s *Server) endUnpicked(ctx context.Context, c runCall, state string) error {
257 res := runs.Result{Ended: time.Now().UTC()}
258 if state == provision.RunCancelled {
259 res.Cancelled = true
260 } else {
261 res.Refused = runs.RefusedExpired
262 }
263 if err := runs.WriteResult(ctx, s.cfg.Releases, c.acct.Sqid, c.app, c.id, res); err != nil {
264 return err
265 }
266 s.auditRunEnd(ctx, c.acct, c.app, c.host, c.id, runEndOutcome(&res), &res)
267 return nil
268}
269
270// runTransitionRefused names why a cancel or expire did not apply, from a
271// fresh read of the record. A run the record shows cancelled or expired
272// gets its result and run-end row here too (both written once, so a no-op
273// when the call that ended it already wrote them): a cancel whose response
274// was lost and was retried finds its own terminal state, and these must not
275// depend on the first attempt having got that far.
276func (s *Server) runTransitionRefused(w http.ResponseWriter, ctx context.Context, c runCall, err error) {
277 if !errors.Is(err, provision.ErrRunConflict) && !errors.Is(err, os.ErrNotExist) {
278 log.Printf("console: api run %s/%s/%s %s: %v", c.acct.Sqid, c.app, c.host, c.id, err)
279 jsonErrorCode(w, http.StatusBadGateway, runCodeRecordFailed, runRecordFailedMsg)
280 return
281 }
282 run, rerr := s.runOf(ctx, c)
283 switch {
284 case rerr != nil:
285 jsonErrorCode(w, http.StatusBadGateway, runCodeReadFailed, runReadFailedMsg)
286 case run == nil:
287 jsonErrorCode(w, http.StatusNotFound, runCodeNotFound, noRunMsg(c.host, c.id))
288 case run.Open():
289 jsonErrorCode(w, http.StatusConflict, runCodeRunStarted, fmt.Sprintf("run %s has already started", c.id))
290 default:
291 if run.State == provision.RunCancelled || run.State == provision.RunExpired {
292 if err := s.endUnpicked(ctx, c, run.State); err != nil {
293 log.Printf("console: api run %s/%s/%s %s: end after retry: %v", c.acct.Sqid, c.app, c.host, c.id, err)
294 jsonErrorCode(w, http.StatusBadGateway, runCodeRecordFailed, runRecordFailedMsg)
295 return
296 }
297 }
298 jsonErrorCode(w, http.StatusConflict, runCodeRunEnded, fmt.Sprintf("run %s has already ended", c.id))
299 }
300}
301
302// handleAPIRunAck is POST …/runs/{id}/ack (run spec §5.6, §5.8, codex 5):
303// refused unless the result exists and last_seq is the result's. Then the
304// record's run is cleared and the run's objects are deleted, in that order,
305// and only for a run the record does not hold in a live state.
306func (s *Server) handleAPIRunAck(w http.ResponseWriter, r *http.Request) {
307 c, ok := s.runGate(w, r)
308 if !ok {
309 return
310 }
311 var ack runs.Ack
312 if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 4<<10)).Decode(&ack); err != nil {
313 jsonErrorCode(w, http.StatusBadRequest, runCodeMalformed, runMalformedMsg)
314 return
315 }
316 ctx, cancel := runWriteCtx(r.Context())
317 defer cancel()
318 found, err := s.requestOnHost(ctx, c)
319 switch {
320 case err != nil:
321 jsonErrorCode(w, http.StatusBadGateway, runCodeReadFailed, runReadFailedMsg)
322 return
323 case !found:
324 jsonErrorCode(w, http.StatusNotFound, runCodeNotFound, noRunMsg(c.host, c.id))
325 return
326 }
327 res, err := runs.ReadResult(ctx, s.cfg.Releases, c.acct.Sqid, c.app, c.id)
328 switch {
329 case err != nil:
330 jsonErrorCode(w, http.StatusBadGateway, runCodeReadFailed, runReadFailedMsg)
331 return
332 case res == nil:
333 jsonErrorCode(w, http.StatusConflict, runCodeRunNotFinished, fmt.Sprintf("run %s has not finished yet", c.id))
334 return
335 case res.LastSeq != ack.LastSeq:
336 jsonErrorCode(w, http.StatusConflict, runCodeLastSeqMismatch, fmt.Sprintf("run %s ended at chunk %d, not %d", c.id, res.LastSeq, ack.LastSeq))
337 return
338 }
339 run, err := s.runOf(ctx, c)
340 switch {
341 case err != nil:
342 jsonErrorCode(w, http.StatusBadGateway, runCodeReadFailed, runReadFailedMsg)
343 return
344 case run.Open():
345 // The box has written the result and not yet moved the run to done.
346 // Its objects are the box's until it has.
347 jsonErrorCode(w, http.StatusConflict, runCodeRunNotFinished, fmt.Sprintf("run %s has not finished yet", c.id))
348 return
349 }
350 s.auditRunEnd(ctx, c.acct, c.app, c.host, c.id, runEndOutcome(res), res)
351 if run != nil {
352 // Clear the record first. If the CAS fails, or its outcome is unknown,
353 // the record is asked again before anything is deleted: a run the
354 // record still names keeps its objects, and the caller may ack again.
355 _, cerr := provision.SetRecordRun(ctx, s.cfg.Releases, c.acct.Sqid, c.app, c.host, provision.RunClear(c.id))
356 still, rerr := s.runOf(ctx, c)
357 if rerr != nil || still != nil {
358 log.Printf("console: api run %s/%s/%s %s: clear after ack: %v; record read: %v", c.acct.Sqid, c.app, c.host, c.id, cerr, rerr)
359 jsonErrorCode(w, http.StatusBadGateway, runCodeRecordFailed, runRecordFailedMsg)
360 return
361 }
362 }
363 if err := runs.DeleteRun(ctx, s.cfg.Releases, c.acct.Sqid, c.app, c.id); err != nil {
364 // The sweep deletes what is left (Task 5); the ack itself stands.
365 log.Printf("console: api run %s/%s/%s %s: delete after ack: %v", c.acct.Sqid, c.app, c.host, c.id, err)
366 }
367 writeJSON(w, http.StatusOK, runs.StateAnswer{State: "acknowledged"})
368}
369
370// runEndOutcome is the run-end row's outcome, derived from the result alone so
371// that whoever writes the row first (a follower's poll, an ack, a cancel, an
372// expiry, the sweep) writes the same word: lost, cancelled, expired, or result.
373func runEndOutcome(res *runs.Result) string {
374 switch {
375 case res.Interrupted == runs.InterruptedLost:
376 return "lost"
377 case res.Cancelled:
378 return provision.RunCancelled
379 case res.Refused == runs.RefusedExpired:
380 return provision.RunExpired
381 }
382 return "result"
383}
384
385// auditRunEnd writes the run-end row exactly once per run (run spec §6):
386// whoever creates runs/<id>/audited first writes it, from the CLI's poll,
387// a cancel, an expiry or the sweep. The actor is the run's caller. It runs
388// to its end even if the caller has gone, so a cancelled request cannot
389// leave the marker without the row's chance.
390func (s *Server) auditRunEnd(ctx context.Context, acct *Account, app, host, id, outcome string, res *runs.Result) {
391 ctx, cancel := runWriteCtx(ctx)
392 defer cancel()
393 if _, err := s.cfg.Releases.Put(ctx, runs.AuditedKey(acct.Sqid, app, id), []byte(outcome), ""); err != nil {
394 if !errors.Is(err, release.ErrCASConflict) {
395 log.Printf("console: api run %s/%s/%s %s: audit marker: %v", acct.Sqid, app, host, id, err)
396 }
397 return // written already, or the bucket refused: either way not twice
398 }
399 // The marker is the last object DeleteRun removes, after request.json. A
400 // marker Put that succeeds for a run whose request.json is gone therefore
401 // raced a finished ack: the row was written already. Take the marker back
402 // and write nothing.
403 by, requester := "", ""
404 switch req, err := runs.ReadRequest(ctx, s.cfg.Releases, acct.Sqid, app, id); {
405 case errors.Is(err, os.ErrNotExist):
406 if derr := s.cfg.Releases.Delete(ctx, runs.AuditedKey(acct.Sqid, app, id)); derr != nil {
407 log.Printf("console: api run %s/%s/%s %s: remove stray audit marker: %v", acct.Sqid, app, host, id, derr)
408 }
409 return
410 case err == nil:
411 by, requester = req.By, req.ServiceLine()
412 }
413 d := struct {
414 ID string `json:"id"`
415 Outcome string `json:"outcome"`
416 Result *runs.Result `json:"result,omitempty"`
417 // Requester is a service's run's line, as on the run-start row.
418 Requester string `json:"requester,omitempty"`
419 }{id, outcome, res, requester}
420 raw, _ := json.Marshal(d)
421 s.auditActivity(acct, app, by, Activity{Action: "run-end", TargetType: "instance", Target: host, Detail: string(raw)})
422}
423