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 "crypto/sha256"
6 "encoding/hex"
7 "encoding/json"
8 "errors"
9 "fmt"
10 "io"
11 "log"
12 "mime/multipart"
13 "net/http"
14 netmail "net/mail"
15 "os"
16 "time"
17
18 "github.com/carlosframework/platform/internal/hostclaim"
19 "github.com/carlosframework/platform/internal/provision"
20 "github.com/carlosframework/platform/internal/registry"
21 "github.com/carlosframework/platform/internal/release"
22 "github.com/carlosframework/platform/internal/runs"
23 "github.com/carlosframework/platform/internal/svcrole"
24)
25
26// The run handlers' refusal codes (service runs spec §3): every refusal a
27// run handler writes carries one beside its sentence, so a caller with no
28// person reading the answer can act on it. The shared gates before the
29// handler (not signed in, the role mismatch, the membership 404) keep their
30// bodies exactly as every other door gives them.
31const (
32 runCodeNotFound = "not_found"
33 runCodeHostClaim = "host_claim"
34 runCodeRateLimited = "rate_limited"
35 runCodeUnavailable = "unavailable"
36 runCodeRecordFailed = "record_failed"
37 runCodeReadFailed = "read_failed"
38 runCodeNotRunnable = "not_runnable"
39 runCodeMoving = "moving"
40 runCodeRunOpen = "run_open"
41 runCodeMalformed = "malformed"
42 runCodeInvalidRequest = "invalid_request"
43 runCodeFilesTooLarge = "files_too_large"
44 runCodeImportUncommitted = "import_uncommitted"
45 runCodeCommandNotGranted = "command_not_granted"
46 runCodeOnBehalfOfInvalid = "on_behalf_of_invalid"
47 runCodeRunStarted = "run_started"
48 runCodeRunEnded = "run_ended"
49 runCodeRunNotFinished = "run_not_finished"
50 runCodeLastSeqMismatch = "last_seq_mismatch"
51 runCodeKeyInvalid = "idempotency_key_invalid"
52 runCodeKeyReused = "idempotency_key_reused"
53 runCodeKeysNotReady = "keys_not_ready"
54 runCodeFileHashMismatch = "file_hash_mismatch"
55)
56
57// runRefusalCodes is every code above, the one list the docs and the tests
58// read.
59var runRefusalCodes = []string{
60 runCodeNotFound, runCodeHostClaim, runCodeRateLimited, runCodeUnavailable,
61 runCodeRecordFailed, runCodeReadFailed, runCodeNotRunnable, runCodeMoving,
62 runCodeRunOpen, runCodeMalformed, runCodeInvalidRequest, runCodeFilesTooLarge,
63 runCodeImportUncommitted, runCodeCommandNotGranted, runCodeOnBehalfOfInvalid,
64 runCodeRunStarted, runCodeRunEnded, runCodeRunNotFinished, runCodeLastSeqMismatch,
65 runCodeKeyInvalid, runCodeKeyReused, runCodeKeysNotReady, runCodeFileHashMismatch,
66}
67
68// runCaller reports whether p may use the run doors at all: a person (whose
69// membership the handler checks next), or a run service with a credential
70// id to own its runs by.
71func runCaller(p Principal) bool {
72 switch p.Kind {
73 case PrincipalUser:
74 return true
75 case PrincipalService:
76 return p.Role == svcrole.Run && p.CredentialID != ""
77 }
78 return false
79}
80
81// callerScope names a caller for every comparison between callers: the
82// principal's KIND and its stable identity, so a person's address can never
83// equal a credential's id. "" for a principal with no identity, which
84// matches nothing.
85func callerScope(p Principal) string {
86 switch {
87 case p.Kind == PrincipalService && p.CredentialID != "":
88 return "svc:" + p.CredentialID
89 case p.Kind == PrincipalUser && p.Address != "":
90 return "user:" + p.Address
91 }
92 return ""
93}
94
95// runVisibleTo reports whether p may see and act on req. A person sees every
96// run on an account they are a member of (the gate has checked that); a
97// service sees only the runs its own credential started, by the credential's
98// id, never its name, so a name minted again after a revoke inherits
99// nothing.
100func (s *Server) runVisibleTo(p Principal, req runs.Request) bool {
101 switch p.Kind {
102 case PrincipalUser:
103 return true
104 case PrincipalService:
105 return req.ByCredential != "" && callerScope(p) == "svc:"+req.ByCredential
106 }
107 return false
108}
109
110// maxOnBehalfOf is the longest on_behalf_of an address may be.
111const maxOnBehalfOf = 254
112
113const onBehalfOfPersonMsg = "on_behalf_of is for service credentials. a person runs as themselves"
114
115const onBehalfOfShapeMsg = "on_behalf_of must be an email address of at most 254 bytes"
116
117// onBehalfOfRefusal is "" when meta's on_behalf_of may stand: empty, or an
118// address of the right shape sent by a run service. Its shape is checked,
119// never its membership: it is recorded on the service's word and grants
120// nothing.
121func onBehalfOfRefusal(p Principal, v string) string {
122 switch {
123 case v == "":
124 return ""
125 case p.Kind != PrincipalService:
126 return onBehalfOfPersonMsg
127 case len(v) > maxOnBehalfOf || !ValidAddress(v):
128 return onBehalfOfShapeMsg
129 }
130 if parsed, err := netmail.ParseAddress(v); err != nil || parsed.Address != v || parsed.Name != "" {
131 return onBehalfOfShapeMsg
132 }
133 return ""
134}
135
136// claimRunUnsupportedMsg is every run route's answer on a host-claim
137// console (index item 12, RUN-C42).
138const claimRunUnsupportedMsg = "carlos run is not available on this console yet. it needs the host-claim launch protocol"
139
140const runTooManyMsg = "too many run requests. wait a minute and try again"
141
142// runOpenMsg is refusal 7 (RUN-C24), at the door and at admission. req is
143// the open run's request when it could be read: a service's run is named in
144// the house wording (runs.Request.ServiceBy), never by its audit address.
145func runOpenMsg(host string, run provision.RunState, req *runs.Request) string {
146 if req != nil {
147 if by := req.ServiceBy(); by != "" {
148 return fmt.Sprintf("%s already has a run open: %s, started %s", host, run.ID, by)
149 }
150 }
151 return fmt.Sprintf("%s already has a run open: %s, started by %s", host, run.ID, run.By)
152}
153
154// runOpenRedactedMsg is refusal 7 for a service whose open run is not its
155// own: neither that run's id nor who started it.
156func runOpenRedactedMsg(host string) string {
157 return fmt.Sprintf("%s already has a run open. try again once it has finished", host)
158}
159
160// runOpenRefusal is refusal 7 as p may read it. A service is told the open
161// run's id and requester only when the run is its own; a run whose request
162// cannot be read is not shown to be its own.
163func (s *Server) runOpenRefusal(ctx context.Context, p Principal, sqid, app, host string, run provision.RunState) string {
164 req, err := runs.ReadRequest(ctx, s.cfg.Releases, sqid, app, run.ID)
165 if err != nil {
166 req = nil
167 }
168 if p.Kind == PrincipalUser {
169 return runOpenMsg(host, run, req)
170 }
171 if req != nil && s.runVisibleTo(p, *req) {
172 return runOpenMsg(host, run, req)
173 }
174 return runOpenRedactedMsg(host)
175}
176
177// runMovingMsg is refusal 6 (RUN-C23), at the door and at admission.
178func runMovingMsg(host string) string {
179 return fmt.Sprintf("%s is moving. run the command once the move has finished", host)
180}
181
182// runImportMsg is refusal 9 (RUN-C33), at the door and at admission.
183func runImportMsg(host string) string {
184 return fmt.Sprintf("%s is an import that is not committed yet. finish or abort the import first", host)
185}
186
187// runFilesTooLargeMsg is refusal 8 for the file total (RUN-C30), whether meta
188// declares it or the body grows past it.
189const runFilesTooLargeMsg = "the files come to more than 64 MB"
190
191const runMalformedMsg = "the run request is malformed"
192
193const runRecordFailedMsg = "could not record the run. try again"
194
195const runCommandNotGrantedMsg = "this credential may not run that command. it runs only the commands it was minted with"
196
197// runKeyHeader is the name of a create's idempotency key (service runs spec
198// § 3).
199const runKeyHeader = "Idempotency-Key"
200
201const runKeysNotReadyMsg = "this console does not take an Idempotency-Key on runs yet. try again later"
202
203const runKeyInvalidMsg = "an Idempotency-Key is 1 to 128 characters: letters, digits, dots, underscores, colons and hyphens"
204
205const runKeyReusedMsg = "this Idempotency-Key was used for a different run request. use a new key for a new request"
206
207const runFileHashRequiredMsg = "a run created with an Idempotency-Key must declare the sha256 of each file"
208
209func runFileHashShapeMsg(name string) string {
210 return fmt.Sprintf("the sha256 declared for %s is not 64 hex digits", name)
211}
212
213func runFileHashMismatchMsg(name string) string {
214 return fmt.Sprintf("%s does not match the sha256 declared for it", name)
215}
216
217func runNoKeyMsg(host string) string {
218 return fmt.Sprintf("%s has no run started with that Idempotency-Key", host)
219}
220
221// runKeyOf reads the create's Idempotency-Key. sent reports whether the
222// header was there at all, so an empty, repeated or malformed key is
223// refused (ok false), never taken for an unkeyed create.
224func runKeyOf(r *http.Request) (key string, sent, ok bool) {
225 vals := r.Header.Values(runKeyHeader)
226 switch {
227 case len(vals) == 0:
228 return "", false, false
229 case len(vals) != 1 || !runs.ValidKey(vals[0]):
230 return "", true, false
231 }
232 return vals[0], true, true
233}
234
235// runFileHashRefusal is "" when meta's declared sha256s may stand: each one
236// declared is 64 hex digits, and a keyed create declares them all.
237func runFileHashRefusal(m runs.Meta, keyed bool) string {
238 for _, f := range m.Files {
239 switch {
240 case f.SHA256 == "" && keyed:
241 return runFileHashRequiredMsg
242 case f.SHA256 != "" && !runs.ValidFileHash(f.SHA256):
243 return runFileHashShapeMsg(f.Name)
244 }
245 }
246 return ""
247}
248
249// runReplayed answers a create whose key already admitted run id: 202 with
250// that run, replayed, and its state while the record still names it.
251func runReplayed(w http.ResponseWriter, rec *provision.Record, host, id string) {
252 state := ""
253 if rec != nil && rec.ActiveRun != nil && rec.ActiveRun.ID == id {
254 state = rec.ActiveRun.State
255 }
256 writeJSON(w, http.StatusAccepted, runs.Created{ID: id, Host: host, State: state, Replayed: true})
257}
258
259// runMetaRefusalCode is the code for a refusal runMetaRefusal named.
260func runMetaRefusalCode(msg string) string {
261 if msg == runFilesTooLargeMsg {
262 return runCodeFilesTooLarge
263 }
264 return runCodeInvalidRequest
265}
266
267// runClassRefusal is spec §5.1 refusal 5 as a sentence, "" for a
268// platform-run, hibernating exec instance on a platform box.
269func runClassRefusal(rec provision.Record) string {
270 switch {
271 case rec.Kind != "" && rec.Kind != "instance":
272 return fmt.Sprintf("%s is a %s route. carlos run works on instances", rec.Host, rec.Kind)
273 case rec.Addr != "" || len(rec.Addrs) > 0:
274 return fmt.Sprintf("%s is served from its own address, so the platform does not run its process", rec.Host)
275 case !rec.HibernateOrDefault():
276 return fmt.Sprintf("%s does not sleep, so the platform does not run its process", rec.Host)
277 case rec.Pool != "":
278 return fmt.Sprintf("%s is a pooled instance. carlos run works on instances with a process of their own", rec.Host)
279 }
280 if fleet, ok := registry.FleetPlacement(rec.Region); ok {
281 return fmt.Sprintf("%s runs on your fleet %s. carlos run works on platform boxes only", rec.Host, fleet)
282 }
283 return ""
284}
285
286// runMetaRefusal is refusal 8 as a sentence, "" when meta is valid.
287func runMetaRefusal(m runs.Meta) string {
288 switch err := runs.CheckArgv(m.Argv); {
289 case errors.Is(err, runs.ErrNoArgs):
290 return "name the command to run after --"
291 case errors.Is(err, runs.ErrTooManyArgs):
292 return "a run takes at most 256 arguments"
293 case errors.Is(err, runs.ErrArgvTooLarge):
294 return "the arguments come to more than 64 KB"
295 case errors.Is(err, runs.ErrArgNUL):
296 return "an argument holds a NUL byte"
297 }
298 if runs.CheckTimeout(time.Duration(m.TimeoutS)*time.Second) != nil {
299 return "the timeout must be between 1 second and 6 hours"
300 }
301 err := runs.CheckFiles(m.Files)
302 var fe *runs.FileNameError
303 switch {
304 case errors.Is(err, runs.ErrFilesTooLarge):
305 return runFilesTooLargeMsg
306 case errors.As(err, &fe) && fe.Twice:
307 return fmt.Sprintf("two files are both named %s", fe.Name)
308 case errors.As(err, &fe):
309 return fmt.Sprintf("%s is not a plain file name", fe.Name)
310 }
311 return ""
312}
313
314// spooled is one uploaded file on the console's disk.
315type spooled struct {
316 runs.File
317 path string
318}
319
320// spoolRunFiles reads the file parts, one per meta file in meta's order,
321// into temp files, checking each name and size and hashing it. The caller
322// removes the files. A short, long, misnamed or missing part is
323// malformed; a body past the cap is the 64 MB refusal.
324func spoolRunFiles(mr *multipart.Reader, files []runs.MetaFile) ([]spooled, error) {
325 var out []spooled
326 fail := func(err error) ([]spooled, error) {
327 for _, f := range out {
328 os.Remove(f.path)
329 }
330 return nil, err
331 }
332 for _, want := range files {
333 part, err := mr.NextPart()
334 if err != nil {
335 return fail(err)
336 }
337 if part.FormName() != "file" || part.FileName() != want.Name {
338 return fail(errRunMalformed)
339 }
340 tmp, err := os.CreateTemp("", "carlos-run-*")
341 if err != nil {
342 return fail(err)
343 }
344 h := sha256.New()
345 n, err := io.Copy(io.MultiWriter(tmp, h), io.LimitReader(part, want.Size+1))
346 cerr := tmp.Close()
347 out = append(out, spooled{File: runs.File{Name: want.Name, Size: n, SHA256: hex.EncodeToString(h.Sum(nil))}, path: tmp.Name()})
348 if err != nil {
349 return fail(err)
350 }
351 if cerr != nil {
352 return fail(cerr)
353 }
354 if n != want.Size {
355 return fail(errRunMalformed)
356 }
357 }
358 if _, err := mr.NextPart(); err != io.EOF {
359 return fail(errRunMalformed)
360 }
361 return out, nil
362}
363
364var errRunMalformed = errors.New("console: run request disagrees with its meta")
365
366// handleAPIRunCreate is POST …/instances/{host}/runs (run spec §5.1). The
367// refusals come in the spec's order; nothing is written before every one
368// that needs no body has passed.
369//
370// A create that carries an Idempotency-Key (service runs spec § 3) reads its
371// meta straight after the record, and answers a key the record already
372// holds before any other refusal and without reading a file: the same
373// request is the run that key admitted, a different one is refused. Only
374// then come the record's refusals, the command check and the files, whose
375// bytes must match the sha256 meta declares. The admission records the key
376// in the same write. An unkeyed create keeps the order it had before keys.
377func (s *Server) handleAPIRunCreate(w http.ResponseWriter, r *http.Request) {
378 p, ok := principalFrom(r.Context())
379 if !ok || !runCaller(p) {
380 http.NotFound(w, r)
381 return
382 }
383 actor, acct, app, ok := s.apiAccountApp(w, r)
384 if !ok {
385 return
386 }
387 if s.cfg.HostClaim {
388 jsonErrorCode(w, http.StatusConflict, runCodeHostClaim, claimRunUnsupportedMsg)
389 return
390 }
391 if !s.cli.instances.allow(hashToken(bearer(r)), time.Now()) {
392 jsonErrorCode(w, http.StatusTooManyRequests, runCodeRateLimited, runTooManyMsg)
393 return
394 }
395 key, keyed, keyOK := runKeyOf(r)
396 if keyed && !s.cfg.RunKeysEnabled {
397 // Never run as unkeyed: the caller would retry it into a second run.
398 jsonErrorCode(w, http.StatusServiceUnavailable, runCodeKeysNotReady, runKeysNotReadyMsg)
399 return
400 }
401 scope := callerScope(p)
402 if keyed && (!keyOK || scope == "") {
403 jsonErrorCode(w, http.StatusBadRequest, runCodeKeyInvalid, runKeyInvalidMsg)
404 return
405 }
406 if s.cfg.Releases == nil {
407 jsonErrorCode(w, http.StatusServiceUnavailable, runCodeUnavailable, runRecordFailedMsg)
408 return
409 }
410 ctx := r.Context()
411 host := r.PathValue("host")
412 notInstance := fmt.Sprintf("%s is not an instance of %s", host, app)
413 if !provision.ValidHostShape(host) {
414 jsonErrorCode(w, http.StatusNotFound, runCodeNotFound, notInstance)
415 return
416 }
417 rec, err := provision.GetRecord(ctx, s.cfg.Releases, acct.Sqid, app, host)
418 if errors.Is(err, os.ErrNotExist) {
419 jsonErrorCode(w, http.StatusNotFound, runCodeNotFound, notInstance)
420 return
421 }
422 if err != nil {
423 log.Printf("console: api run %s/%s/%s: read record: %v", acct.Sqid, app, host, err)
424 jsonErrorCode(w, http.StatusBadGateway, runCodeRecordFailed, runRecordFailedMsg)
425 return
426 }
427 // recordRefused writes refusals 5 to 7 from the record (the class, a move
428 // in flight, an open run) and reports whether it wrote one.
429 recordRefused := func() bool {
430 if msg := runClassRefusal(*rec); msg != "" {
431 jsonErrorCode(w, http.StatusConflict, runCodeNotRunnable, msg)
432 return true
433 }
434 if rec.Move != nil {
435 jsonErrorCode(w, http.StatusConflict, runCodeMoving, runMovingMsg(host))
436 return true
437 }
438 if rec.ActiveRun.Open() {
439 jsonErrorCode(w, http.StatusConflict, runCodeRunOpen, s.runOpenRefusal(ctx, p, acct.Sqid, app, host, *rec.ActiveRun))
440 return true
441 }
442 return false
443 }
444 if !keyed && recordRefused() {
445 return
446 }
447 r.Body = http.MaxBytesReader(w, r.Body, runs.MaxFileBytes+runs.MetaAllowance)
448 mr, err := r.MultipartReader()
449 if err != nil {
450 jsonErrorCode(w, http.StatusBadRequest, runCodeMalformed, runMalformedMsg)
451 return
452 }
453 var meta runs.Meta
454 part, err := mr.NextPart()
455 if err == nil && part.FormName() == "meta" {
456 err = json.NewDecoder(io.LimitReader(part, runs.MetaAllowance)).Decode(&meta)
457 } else if err == nil {
458 err = errRunMalformed
459 }
460 if err != nil {
461 jsonErrorCode(w, http.StatusBadRequest, runCodeMalformed, runMalformedMsg)
462 return
463 }
464 // A declared sha256 is compared and digested in the lowercase form the
465 // spool computes, so either case declares the same file.
466 for i := range meta.Files {
467 meta.Files[i].SHA256 = runs.NormalFileHash(meta.Files[i].SHA256)
468 }
469 if msg := runMetaRefusal(meta); msg != "" {
470 jsonErrorCode(w, http.StatusBadRequest, runMetaRefusalCode(msg), msg)
471 return
472 }
473 if msg := onBehalfOfRefusal(p, meta.OnBehalfOf); msg != "" {
474 jsonErrorCode(w, http.StatusBadRequest, runCodeOnBehalfOfInvalid, msg)
475 return
476 }
477 if msg := runFileHashRefusal(meta, keyed); msg != "" {
478 jsonErrorCode(w, http.StatusBadRequest, runCodeInvalidRequest, msg)
479 return
480 }
481 var scoped, digest, caller string
482 if keyed {
483 scoped, digest, caller = runs.ScopedKey(scope, key), runs.MetaDigest(meta), runs.CallerOf(scope)
484 if k, ok := rec.RunKeyFor(scoped, time.Now()); ok {
485 if k.Digest != digest {
486 jsonErrorCode(w, http.StatusConflict, runCodeKeyReused, runKeyReusedMsg)
487 return
488 }
489 // The files are never read: the run that key admitted has them.
490 runReplayed(w, rec, host, k.RunID)
491 return
492 }
493 if recordRefused() {
494 return
495 }
496 if rec.ImportUncommitted() {
497 jsonErrorCode(w, http.StatusConflict, runCodeImportUncommitted, runImportMsg(host))
498 return
499 }
500 }
501 // A run service runs only what it was granted, refused before any
502 // object is written. A person runs any command.
503 if p.Kind == PrincipalService && !runs.CommandGranted(meta.Argv, p.Commands) {
504 jsonErrorCode(w, http.StatusForbidden, runCodeCommandNotGranted, runCommandNotGrantedMsg)
505 return
506 }
507 if !keyed && rec.ImportUncommitted() {
508 jsonErrorCode(w, http.StatusConflict, runCodeImportUncommitted, runImportMsg(host))
509 return
510 }
511 files, err := spoolRunFiles(mr, meta.Files)
512 defer func() {
513 for _, f := range files {
514 os.Remove(f.path)
515 }
516 }()
517 var tooLarge *http.MaxBytesError
518 switch {
519 case errors.As(err, &tooLarge):
520 jsonErrorCode(w, http.StatusRequestEntityTooLarge, runCodeFilesTooLarge, runFilesTooLargeMsg)
521 return
522 case err != nil:
523 jsonErrorCode(w, http.StatusBadRequest, runCodeMalformed, runMalformedMsg)
524 return
525 }
526 for i, f := range files {
527 if want := meta.Files[i].SHA256; want != "" && f.SHA256 != want {
528 jsonErrorCode(w, http.StatusBadRequest, runCodeFileHashMismatch, runFileHashMismatchMsg(f.Name))
529 return
530 }
531 }
532 id, err := runs.MintID()
533 if err != nil {
534 jsonErrorCode(w, http.StatusBadGateway, runCodeRecordFailed, runRecordFailedMsg)
535 return
536 }
537 inc, err := hostclaim.Incarnation(rec.Account, rec.App, rec.Host, rec.CreatedAt)
538 if err != nil {
539 jsonErrorCode(w, http.StatusBadGateway, runCodeRecordFailed, runRecordFailedMsg)
540 return
541 }
542 now := time.Now().UTC()
543 req := runs.Request{Schema: 1, ID: id, Account: acct.Sqid, App: app, Host: host, Incarnation: inc,
544 Argv: meta.Argv, TimeoutS: meta.TimeoutS, By: actor, RequestedAt: now, IdempotencyKey: scoped}
545 if p.Kind == PrincipalService {
546 req.ByService, req.ByCredential, req.OnBehalfOf = p.Name, p.CredentialID, meta.OnBehalfOf
547 }
548 for _, f := range files {
549 req.Files = append(req.Files, f.File)
550 }
551 if err := s.writeRunObjects(ctx, req, files); err != nil {
552 log.Printf("console: api run %s/%s/%s: write %s: %v", acct.Sqid, app, host, id, err)
553 _ = runs.DeleteRun(context.WithoutCancel(ctx), s.cfg.Releases, acct.Sqid, app, id)
554 jsonErrorCode(w, http.StatusBadGateway, runCodeRecordFailed, runRecordFailedMsg)
555 return
556 }
557 // The pickup window opens at admission, not at the first byte: a slow
558 // upload must not eat the box's 60 seconds.
559 //
560 // The admission runs to its end even if the caller has gone: a conditional
561 // Put that is cancelled may or may not have landed, and deleting the
562 // objects of a run the record holds would leave a queued run no box can
563 // read. The outcome is then settled from the record, not from the error.
564 wctx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 30*time.Second)
565 defer cancel()
566 state, err := s.admitRun(wctx, acct.Sqid, app, host, id, actor, scoped, digest, caller)
567 // twinWon answers with the run a twin (the same key and request) had
568 // admitted first. This request's own objects are no one's.
569 twinWon := func(cur *provision.Record, twin string) {
570 if derr := runs.DeleteRun(context.WithoutCancel(ctx), s.cfg.Releases, acct.Sqid, app, id); derr != nil {
571 log.Printf("console: api run %s/%s/%s: delete %s after its twin %s won: %v", acct.Sqid, app, host, id, twin, derr)
572 }
573 runReplayed(w, cur, host, twin)
574 }
575 var replay *provision.RunKeyReplay
576 if errors.As(err, &replay) {
577 // admitRun answered a replay of this request's own id as its
578 // admission, so this one names a twin.
579 cur, _ := provision.GetRecord(wctx, s.cfg.Releases, acct.Sqid, app, host)
580 twinWon(cur, replay.RunID)
581 return
582 }
583 if err != nil && !runAdmitRefused(err) {
584 // The outcome is unknown (a lost response, a cancelled request, a
585 // transport error after the write, or both CAS attempts lost): ask
586 // the record whose run it holds.
587 cur, rerr := provision.GetRecord(wctx, s.cfg.Releases, acct.Sqid, app, host)
588 switch {
589 case rerr != nil:
590 // Still unknown. Keep the objects: if the run was admitted a box
591 // needs them, and if not, the orphan sweep ages them out by
592 // request.json (Task 5).
593 log.Printf("console: api run %s/%s/%s: admit %s: %v; record unreadable: %v", acct.Sqid, app, host, id, err, rerr)
594 jsonErrorCode(w, http.StatusBadGateway, runCodeRecordFailed, runRecordFailedMsg)
595 return
596 case keyed:
597 // A keyed admission is settled by its key, which commits with it
598 // or not at all, never by ActiveRun, which a box or an ack may
599 // already have moved on: a committed run keeps its objects.
600 k, found := cur.RunKeyFor(scoped, time.Now())
601 switch {
602 case found && k.RunID == id:
603 state, err = "", nil
604 if cur.ActiveRun != nil && cur.ActiveRun.ID == id {
605 state = cur.ActiveRun.State
606 }
607 case found && k.Digest == digest:
608 twinWon(cur, k.RunID)
609 return
610 case found:
611 err = provision.ErrRunKeyReused
612 case cur.ActiveRun != nil && cur.ActiveRun.ID == id:
613 // The key is gone (a record writer dropped run_keys between
614 // the admit and now) but the record still holds this run:
615 // it is admitted, and deleting its objects would leave a
616 // queued run no box can read.
617 state, err = cur.ActiveRun.State, nil
618 }
619 case cur.ActiveRun != nil && cur.ActiveRun.ID == id:
620 state, err = cur.ActiveRun.State, nil
621 }
622 }
623 if err != nil {
624 _ = runs.DeleteRun(context.WithoutCancel(ctx), s.cfg.Releases, acct.Sqid, app, id)
625 var open *provision.RunOpenError
626 switch {
627 case errors.As(err, &open):
628 jsonErrorCode(w, http.StatusConflict, runCodeRunOpen, s.runOpenRefusal(wctx, p, acct.Sqid, app, host, open.Run))
629 case errors.Is(err, provision.ErrRunMoving):
630 jsonErrorCode(w, http.StatusConflict, runCodeMoving, runMovingMsg(host))
631 case errors.Is(err, provision.ErrRunImport):
632 jsonErrorCode(w, http.StatusConflict, runCodeImportUncommitted, runImportMsg(host))
633 case errors.Is(err, provision.ErrRunKeyReused):
634 jsonErrorCode(w, http.StatusConflict, runCodeKeyReused, runKeyReusedMsg)
635 case errors.Is(err, os.ErrNotExist):
636 jsonErrorCode(w, http.StatusNotFound, runCodeNotFound, notInstance)
637 default:
638 log.Printf("console: api run %s/%s/%s: admit %s: %v", acct.Sqid, app, host, id, err)
639 jsonErrorCode(w, http.StatusBadGateway, runCodeRecordFailed, runRecordFailedMsg)
640 }
641 return
642 }
643 // The serial bump and the run-start row belong to the admission, so they
644 // run on its context too: a caller that left after it must not cost the
645 // box its prompt pickup or the row its version.
646 s.bumpSerial(wctx)
647 s.auditActivity(acct, app, actor, Activity{Action: "run-start", TargetType: "instance", Target: host,
648 Detail: runStartDetail(req, s.runVersion(wctx, acct.Sqid, app, rec.Channel))})
649 writeJSON(w, http.StatusAccepted, runs.Created{ID: id, Host: host, State: state})
650}
651
652// admitRun is the admission CAS, with the caller's scoped key, request
653// digest and runs.CallerOf when the create is keyed. A RunOpenError naming id itself means an
654// earlier attempt of this same call committed (updateRecord retries after a
655// lost-response conflict, and finds its own run queued): that is admitted.
656// So is a RunKeyReplay naming id, the same retry finding its own key; any
657// other RunKeyReplay is returned for the caller to answer.
658func (s *Server) admitRun(ctx context.Context, sqid, app, host, id, by, key, digest, caller string) (string, error) {
659 got, err := provision.SetRecordRun(ctx, s.cfg.Releases, sqid, app, host,
660 provision.RunAdmit(provision.RunAdmission{ID: id, By: by, Deadline: time.Now().UTC().Add(runs.PickupWindow), Key: key, Digest: digest, Caller: caller}))
661 var open *provision.RunOpenError
662 var replay *provision.RunKeyReplay
663 switch {
664 case err == nil:
665 return got.ActiveRun.State, nil
666 case errors.As(err, &open) && open.Run.ID == id:
667 return open.Run.State, nil
668 case errors.As(err, &replay) && replay.RunID == id:
669 // This request's own admission committed; delete nothing. The state
670 // is the record's, "" if it cannot be read now.
671 state := ""
672 if cur, rerr := provision.GetRecord(ctx, s.cfg.Releases, sqid, app, host); rerr == nil && cur.ActiveRun != nil && cur.ActiveRun.ID == id {
673 state = cur.ActiveRun.State
674 }
675 return state, nil
676 }
677 return "", err
678}
679
680// runAdmitRefused reports whether err is the record saying no (another run,
681// a move, an uncommitted import, a key reused, no record) rather than a
682// failure whose outcome the record has to settle.
683func runAdmitRefused(err error) bool {
684 var open *provision.RunOpenError
685 return errors.As(err, &open) || errors.Is(err, provision.ErrRunMoving) ||
686 errors.Is(err, provision.ErrRunImport) || errors.Is(err, provision.ErrRunKeyReused) ||
687 errors.Is(err, os.ErrNotExist)
688}
689
690// handleAPIRunKeyLookup is GET …/instances/{host}/runs?key=<key> (service
691// runs spec § 3): the run the caller's own key admitted on this instance,
692// from the record, or 404. Another caller's key is never found, because the
693// record holds keys scoped to their caller. It is the run doors' gate, then
694// a read.
695func (s *Server) handleAPIRunKeyLookup(w http.ResponseWriter, r *http.Request) {
696 p, ok := principalFrom(r.Context())
697 if !ok || !runCaller(p) {
698 http.NotFound(w, r)
699 return
700 }
701 _, acct, app, ok := s.apiAccountApp(w, r)
702 if !ok {
703 return
704 }
705 if s.cfg.HostClaim {
706 jsonErrorCode(w, http.StatusConflict, runCodeHostClaim, claimRunUnsupportedMsg)
707 return
708 }
709 if !s.cli.runs.allow(hashToken(bearer(r)), time.Now()) {
710 jsonErrorCode(w, http.StatusTooManyRequests, runCodeRateLimited, runTooManyMsg)
711 return
712 }
713 keys, scope := r.URL.Query()["key"], callerScope(p)
714 if len(keys) != 1 || !runs.ValidKey(keys[0]) || scope == "" {
715 jsonErrorCode(w, http.StatusBadRequest, runCodeKeyInvalid, runKeyInvalidMsg)
716 return
717 }
718 if s.cfg.Releases == nil {
719 jsonErrorCode(w, http.StatusServiceUnavailable, runCodeUnavailable, runReadFailedMsg)
720 return
721 }
722 host := r.PathValue("host")
723 if !provision.ValidHostShape(host) {
724 jsonErrorCode(w, http.StatusNotFound, runCodeNotFound, fmt.Sprintf("%s is not an instance of %s", host, app))
725 return
726 }
727 rec, err := provision.GetRecord(r.Context(), s.cfg.Releases, acct.Sqid, app, host)
728 switch {
729 case errors.Is(err, os.ErrNotExist):
730 jsonErrorCode(w, http.StatusNotFound, runCodeNotFound, fmt.Sprintf("%s is not an instance of %s", host, app))
731 return
732 case err != nil:
733 jsonErrorCode(w, http.StatusBadGateway, runCodeReadFailed, runReadFailedMsg)
734 return
735 }
736 k, found := rec.RunKeyFor(runs.ScopedKey(scope, keys[0]), time.Now())
737 if !found {
738 jsonErrorCode(w, http.StatusNotFound, runCodeNotFound, runNoKeyMsg(host))
739 return
740 }
741 writeJSON(w, http.StatusOK, runs.KeyAnswer{RunID: k.RunID})
742}
743
744// writeRunObjects writes request.json first (the orphan sweep ages a run
745// directory by it, Task 5), then the files. The record CAS comes after
746// both, so no box ever sees a run whose objects are partial.
747func (s *Server) writeRunObjects(ctx context.Context, req runs.Request, files []spooled) error {
748 body, err := json.Marshal(req)
749 if err != nil {
750 return err
751 }
752 if _, err := s.cfg.Releases.Put(ctx, runs.RequestKey(req.Account, req.App, req.ID), body, ""); err != nil {
753 return err
754 }
755 for _, f := range files {
756 fh, err := os.Open(f.path)
757 if err != nil {
758 return err
759 }
760 _, err = release.PutObjectFrom(ctx, s.cfg.Releases, runs.InputKey(req.Account, req.App, req.ID, f.Name), fh, f.Size, "")
761 fh.Close()
762 if err != nil {
763 return err
764 }
765 }
766 return nil
767}
768
769// runVersion is the release the instance's channel serves, "" when it
770// cannot be read: the audit row's version is a record, not a gate.
771func (s *Server) runVersion(ctx context.Context, sqid, app, channel string) string {
772 ptr, err := release.Channel(ctx, s.cfg.Releases, s.cfg.ReleasePub, sqid, app, channel)
773 if err != nil || ptr == nil {
774 return ""
775 }
776 return ptr.Version
777}
778
779// runStartDetail is the run-start row's detail (run spec §6): the run id,
780// argv, the file names and sizes and the version, as JSON. Output never
781// goes in the audit log; argv does, and the docs say so.
782func runStartDetail(req runs.Request, version string) string {
783 type file struct {
784 Name string `json:"name"`
785 Size int64 `json:"size"`
786 }
787 d := struct {
788 ID string `json:"id"`
789 Argv []string `json:"argv"`
790 Files []file `json:"files"`
791 Version string `json:"version"`
792 // Requester is a service's run's line (runs.Request.ServiceLine),
793 // absent for a person's: the row's actor already names them.
794 Requester string `json:"requester,omitempty"`
795 }{ID: req.ID, Argv: req.Argv, Version: version, Requester: req.ServiceLine()}
796 for _, f := range req.Files {
797 d.Files = append(d.Files, file{f.Name, f.Size})
798 }
799 raw, _ := json.Marshal(d)
800 return string(raw)
801}
802