// Package runs is what the console, the box and the CLI share about one // `carlos run` (run spec): ids, the run's objects under its app's subtree, // the wire shapes, the limits and the reasons a run ends without an exit. // It holds no policy beyond validation; the record's run state is // provision.RunState. package runs import ( "crypto/rand" "encoding/base32" "fmt" "io" "regexp" "strconv" "strings" "time" "github.com/carlosframework/platform/internal/release" ) const ( MaxArgs = 256 MaxArgvBytes = 64 << 10 MaxFileBytes = 64 << 20 MaxOutputBytes = 64 << 20 // MetaAllowance bounds the meta part and the multipart framing on top // of the files (run spec §5.1). MetaAllowance = 1 << 20 MinTimeout = time.Second MaxTimeout = 6 * time.Hour DefaultTimeout = 30 * time.Minute PickupWindow = 60 * time.Second LostAfter = time.Hour KeepFor = 24 * time.Hour ChunkBytes = 256 << 10 ChunkEvery = time.Second StreamOut = "o" StreamErr = "e" // A follow page holds at most PageChunks chunks and PageBytes of // output, so its JSON stays under the CLI's 1 MiB response cap. PageChunks = 64 PageBytes = 512 << 10 ) // Why the box refused a run (result.json's refused). const ( RefusedFlat = "flat" RefusedClaim = "claim" RefusedWake = "wake" RefusedIncarnation = "incarnation" RefusedImport = "import" // RefusedExpired is written by the console, not a box: the run expired // before any box picked it up (run spec §5.2), so no box ever wrote a // result. The CLI reports it as "not picked up". RefusedExpired = "expired" ) // Why a run was interrupted, beside activator's Stop* reasons (RUN-C44 to // RUN-C46, RUN-C82). const ( InterruptedLost = "its box stopped reporting" InterruptedAgentRestart = "the box's agent restarted" InterruptedGone = "the command's unit went away" InterruptedStartFailed = "the box could not start the command" ) // DirName is the directory under an instance's own data directory that // holds each run's files: activator.CmdRunDirName, restated here because // runs imports no activator (Plan 3 pins the two equal). const DirName = ".carlos-run" var idEnc = base32.NewEncoding("abcdefghijklmnopqrstuvwxyz234567").WithPadding(base32.NoPadding) var idShape = regexp.MustCompile(`^run_[a-z2-7]{26}$`) // NewID mints a run id from r: "run_" and 26 lowercase base32 characters, // the encoding of 16 random bytes (128 bits). A run id is the only thing that // stops the record from admitting a run twice and the only thing that names // a run's objects, so production code mints with MintID; NewID takes a reader // only so a test can supply a fixed one. func NewID(r io.Reader) (string, error) { var b [16]byte if _, err := io.ReadFull(r, b[:]); err != nil { return "", err } return "run_" + idEnc.EncodeToString(b[:]), nil } // MintID mints a run id from the operating system's cryptographic random // source. func MintID() (string, error) { return NewID(rand.Reader) } // ValidID reports whether id is a run id. Every id that passes is exactly 30 // bytes of [a-z2-7_] and so is one clean path element: never empty, with no // slash, backslash or dot, and never "." or "..". The console validates an // id from a request with it, the box validates one from a record with it, and // the store helpers below refuse an id that fails it. func ValidID(id string) bool { return idShape.MatchString(id) } // RunsPrefix is where an app's runs live: under its own subtree, so the // owner gate maps onto them and app trash and purge sweep them. func RunsPrefix(acct, app string) string { return release.AppPrefix(acct, app) + "runs/" } func Prefix(acct, app, id string) string { return RunsPrefix(acct, app) + id + "/" } func RequestKey(acct, app, id string) string { return Prefix(acct, app, id) + "request.json" } func ResultKey(acct, app, id string) string { return Prefix(acct, app, id) + "result.json" } func AuditedKey(acct, app, id string) string { return Prefix(acct, app, id) + "audited" } func OutPrefix(acct, app, id string) string { return Prefix(acct, app, id) + "out/" } // InputKey is where an uploaded file lives. name must have passed PlainName // (CheckFiles does that); InputKey does not check it, and a name with a // slash or ".." would leave in/. func InputKey(acct, app, id, name string) string { return Prefix(acct, app, id) + "in/" + name } // ChunkKey is out/-, the seq zero-padded so keys sort in the // order the agent read the output. stream must be StreamOut or StreamErr // (ChunkKey does not check it), and seq at most 99,999,999, the 8-digit // format; the 64 MB output cap in 256 KB chunks never gets near it. func ChunkKey(acct, app, id string, seq int64, stream string) string { return OutPrefix(acct, app, id) + fmt.Sprintf("%08d-%s", seq, stream) } // ParseChunkKey reads a key ChunkKey made under outPrefix. func ParseChunkKey(key, outPrefix string) (int64, string, bool) { name, ok := strings.CutPrefix(key, outPrefix) if !ok || len(name) != 10 || name[8] != '-' { return 0, "", false } stream := name[9:] if stream != StreamOut && stream != StreamErr { return 0, "", false } seq, err := strconv.ParseInt(name[:8], 10, 64) if err != nil || seq < 1 { return 0, "", false } for i := 0; i < 8; i++ { // ParseInt would also take a sign if name[i] < '0' || name[i] > '9' { return 0, "", false } } return seq, stream, true } // Meta is the create request's first part. type Meta struct { Argv []string `json:"argv"` TimeoutS int64 `json:"timeout_s"` Files []MetaFile `json:"files"` // OnBehalfOf is the person a run service says it is running for. Only // a run service may send it; it is recorded on the service's word and // grants nothing (service runs spec §2). OnBehalfOf string `json:"on_behalf_of,omitempty"` } type MetaFile struct { Name string `json:"name"` Size int64 `json:"size"` // SHA256 is the file's hex sha256 as the caller declares it. A keyed // create must declare it, and the console checks the uploaded bytes // against it whenever it is declared (service runs spec § 3). SHA256 string `json:"sha256,omitempty"` } // File is one uploaded file as request.json records it. type File struct { Name string `json:"name"` Size int64 `json:"size"` SHA256 string `json:"sha256"` } // Request is runs//request.json (run spec §5.1). Incarnation is the // record's (hostclaim.Incarnation of account, app, host and CreatedAt). type Request struct { Schema int `json:"schema"` ID string `json:"id"` Account string `json:"account"` App string `json:"app"` Host string `json:"host"` Incarnation string `json:"incarnation"` Argv []string `json:"argv"` TimeoutS int64 `json:"timeout_s"` Files []File `json:"files"` By string `json:"by"` RequestedAt time.Time `json:"requested_at"` // A service's run (service runs spec §2): the credential's name, for // display; its stable id, which is what decides whose run it is (a // name is reused after a revoke, an id never); and the person it said // it ran for, unverified. All three are empty for a person's run. ByService string `json:"by_service,omitempty"` ByCredential string `json:"by_credential,omitempty"` OnBehalfOf string `json:"on_behalf_of,omitempty"` // IdempotencyKey is a keyed create's key as the record's run_keys holds // it, scoped to its caller and hashed (ScopedKey), never the caller's // own words. It links the run to its key after the record has pruned // the entry (service runs spec §§ 2, 3). Empty for an unkeyed create. IdempotencyKey string `json:"idempotency_key,omitempty"` } // Timeout is the run's timeout as a duration. func (r Request) Timeout() time.Duration { return time.Duration(r.TimeoutS) * time.Second } // ServiceLine is how every surface names the requester of a service's run, // "" for a person's. The person a service names is always shown as that // service's statement, never as the one who ran it. func (r Request) ServiceLine() string { if by := r.ServiceBy(); by != "" { return "run " + by } return "" } // ServiceBy is ServiceLine without its leading "run ", for a sentence that // already says "run" ("run started by service ci (scheduled)"). "" for // a person's run. func (r Request) ServiceBy() string { switch { case r.ByService == "": return "" case r.OnBehalfOf == "": return "by service " + r.ByService + " (scheduled)" } return "by service " + r.ByService + " for " + r.OnBehalfOf + " (as stated by the service)" } // Result is runs//result.json (run spec §5.5), written only after the // last chunk. Signal is the decimal signal number when a signal ended the // command (index item 5); Detail is the member-safe wake error for a // refused wake (index item 4). type Result struct { Exit int `json:"exit"` Signal string `json:"signal"` TimedOut bool `json:"timed_out"` Cancelled bool `json:"cancelled"` Interrupted string `json:"interrupted"` Refused string `json:"refused"` Detail string `json:"detail,omitempty"` Started time.Time `json:"started"` Ended time.Time `json:"ended"` Box string `json:"box"` LastSeq int64 `json:"last_seq"` Truncated bool `json:"truncated"` } // Chunk is one output chunk in a follow page; Data is base64 in JSON. type Chunk struct { Seq int64 `json:"seq"` Stream string `json:"stream"` Data []byte `json:"data"` } // Page is GET …/runs/{id}?after=. State is the record's state for // this run, or "" when the record no longer names it. More says the page // was cut short: ask again at once. type Page struct { ID string `json:"id"` State string `json:"state"` Chunks []Chunk `json:"chunks"` Result *Result `json:"result,omitempty"` More bool `json:"more"` } // Ack is POST …/runs/{id}/ack. type Ack struct { LastSeq int64 `json:"last_seq"` } // Created is the 202 to a create. State is the record's state for the run, // "" when the record no longer names it. Replayed says the run was admitted // earlier under the same Idempotency-Key, and this create started nothing. type Created struct { ID string `json:"id"` Host string `json:"host"` State string `json:"state"` Replayed bool `json:"replayed,omitempty"` } // KeyAnswer is GET …/runs?key=: the run admitted under the caller's // key. type KeyAnswer struct { RunID string `json:"run_id"` } // StateAnswer is the answer to cancel, expire and ack. type StateAnswer struct { State string `json:"state"` }