carlos / platform Public

Clone
git clone https://amadan.net/carlos/platform

Plain git — no account needed to clone.

Download

Download this file

1// Package runs is what the console, the box and the CLI share about one
2// `carlos run` (run spec): ids, the run's objects under its app's subtree,
3// the wire shapes, the limits and the reasons a run ends without an exit.
4// It holds no policy beyond validation; the record's run state is
5// provision.RunState.
6package runs
7
8import (
9 "crypto/rand"
10 "encoding/base32"
11 "fmt"
12 "io"
13 "regexp"
14 "strconv"
15 "strings"
16 "time"
17
18 "github.com/carlosframework/platform/internal/release"
19)
20
21const (
22 MaxArgs = 256
23 MaxArgvBytes = 64 << 10
24 MaxFileBytes = 64 << 20
25 MaxOutputBytes = 64 << 20
26 // MetaAllowance bounds the meta part and the multipart framing on top
27 // of the files (run spec §5.1).
28 MetaAllowance = 1 << 20
29 MinTimeout = time.Second
30 MaxTimeout = 6 * time.Hour
31 DefaultTimeout = 30 * time.Minute
32 PickupWindow = 60 * time.Second
33 LostAfter = time.Hour
34 KeepFor = 24 * time.Hour
35 ChunkBytes = 256 << 10
36 ChunkEvery = time.Second
37 StreamOut = "o"
38 StreamErr = "e"
39 // A follow page holds at most PageChunks chunks and PageBytes of
40 // output, so its JSON stays under the CLI's 1 MiB response cap.
41 PageChunks = 64
42 PageBytes = 512 << 10
43)
44
45// Why the box refused a run (result.json's refused).
46const (
47 RefusedFlat = "flat"
48 RefusedClaim = "claim"
49 RefusedWake = "wake"
50 RefusedIncarnation = "incarnation"
51 RefusedImport = "import"
52 // RefusedExpired is written by the console, not a box: the run expired
53 // before any box picked it up (run spec §5.2), so no box ever wrote a
54 // result. The CLI reports it as "not picked up".
55 RefusedExpired = "expired"
56)
57
58// Why a run was interrupted, beside activator's Stop* reasons (RUN-C44 to
59// RUN-C46, RUN-C82).
60const (
61 InterruptedLost = "its box stopped reporting"
62 InterruptedAgentRestart = "the box's agent restarted"
63 InterruptedGone = "the command's unit went away"
64 InterruptedStartFailed = "the box could not start the command"
65)
66
67// DirName is the directory under an instance's own data directory that
68// holds each run's files: activator.CmdRunDirName, restated here because
69// runs imports no activator (Plan 3 pins the two equal).
70const DirName = ".carlos-run"
71
72var idEnc = base32.NewEncoding("abcdefghijklmnopqrstuvwxyz234567").WithPadding(base32.NoPadding)
73
74var idShape = regexp.MustCompile(`^run_[a-z2-7]{26}$`)
75
76// NewID mints a run id from r: "run_" and 26 lowercase base32 characters,
77// the encoding of 16 random bytes (128 bits). A run id is the only thing that
78// stops the record from admitting a run twice and the only thing that names
79// a run's objects, so production code mints with MintID; NewID takes a reader
80// only so a test can supply a fixed one.
81func NewID(r io.Reader) (string, error) {
82 var b [16]byte
83 if _, err := io.ReadFull(r, b[:]); err != nil {
84 return "", err
85 }
86 return "run_" + idEnc.EncodeToString(b[:]), nil
87}
88
89// MintID mints a run id from the operating system's cryptographic random
90// source.
91func MintID() (string, error) { return NewID(rand.Reader) }
92
93// ValidID reports whether id is a run id. Every id that passes is exactly 30
94// bytes of [a-z2-7_] and so is one clean path element: never empty, with no
95// slash, backslash or dot, and never "." or "..". The console validates an
96// id from a request with it, the box validates one from a record with it, and
97// the store helpers below refuse an id that fails it.
98func ValidID(id string) bool { return idShape.MatchString(id) }
99
100// RunsPrefix is where an app's runs live: under its own subtree, so the
101// owner gate maps onto them and app trash and purge sweep them.
102func RunsPrefix(acct, app string) string { return release.AppPrefix(acct, app) + "runs/" }
103
104func Prefix(acct, app, id string) string { return RunsPrefix(acct, app) + id + "/" }
105func RequestKey(acct, app, id string) string { return Prefix(acct, app, id) + "request.json" }
106func ResultKey(acct, app, id string) string { return Prefix(acct, app, id) + "result.json" }
107func AuditedKey(acct, app, id string) string { return Prefix(acct, app, id) + "audited" }
108func OutPrefix(acct, app, id string) string { return Prefix(acct, app, id) + "out/" }
109
110// InputKey is where an uploaded file lives. name must have passed PlainName
111// (CheckFiles does that); InputKey does not check it, and a name with a
112// slash or ".." would leave in/.
113func InputKey(acct, app, id, name string) string { return Prefix(acct, app, id) + "in/" + name }
114
115// ChunkKey is out/<seq>-<stream>, the seq zero-padded so keys sort in the
116// order the agent read the output. stream must be StreamOut or StreamErr
117// (ChunkKey does not check it), and seq at most 99,999,999, the 8-digit
118// format; the 64 MB output cap in 256 KB chunks never gets near it.
119func ChunkKey(acct, app, id string, seq int64, stream string) string {
120 return OutPrefix(acct, app, id) + fmt.Sprintf("%08d-%s", seq, stream)
121}
122
123// ParseChunkKey reads a key ChunkKey made under outPrefix.
124func ParseChunkKey(key, outPrefix string) (int64, string, bool) {
125 name, ok := strings.CutPrefix(key, outPrefix)
126 if !ok || len(name) != 10 || name[8] != '-' {
127 return 0, "", false
128 }
129 stream := name[9:]
130 if stream != StreamOut && stream != StreamErr {
131 return 0, "", false
132 }
133 seq, err := strconv.ParseInt(name[:8], 10, 64)
134 if err != nil || seq < 1 {
135 return 0, "", false
136 }
137 for i := 0; i < 8; i++ { // ParseInt would also take a sign
138 if name[i] < '0' || name[i] > '9' {
139 return 0, "", false
140 }
141 }
142 return seq, stream, true
143}
144
145// Meta is the create request's first part.
146type Meta struct {
147 Argv []string `json:"argv"`
148 TimeoutS int64 `json:"timeout_s"`
149 Files []MetaFile `json:"files"`
150 // OnBehalfOf is the person a run service says it is running for. Only
151 // a run service may send it; it is recorded on the service's word and
152 // grants nothing (service runs spec §2).
153 OnBehalfOf string `json:"on_behalf_of,omitempty"`
154}
155
156type MetaFile struct {
157 Name string `json:"name"`
158 Size int64 `json:"size"`
159 // SHA256 is the file's hex sha256 as the caller declares it. A keyed
160 // create must declare it, and the console checks the uploaded bytes
161 // against it whenever it is declared (service runs spec § 3).
162 SHA256 string `json:"sha256,omitempty"`
163}
164
165// File is one uploaded file as request.json records it.
166type File struct {
167 Name string `json:"name"`
168 Size int64 `json:"size"`
169 SHA256 string `json:"sha256"`
170}
171
172// Request is runs/<id>/request.json (run spec §5.1). Incarnation is the
173// record's (hostclaim.Incarnation of account, app, host and CreatedAt).
174type Request struct {
175 Schema int `json:"schema"`
176 ID string `json:"id"`
177 Account string `json:"account"`
178 App string `json:"app"`
179 Host string `json:"host"`
180 Incarnation string `json:"incarnation"`
181 Argv []string `json:"argv"`
182 TimeoutS int64 `json:"timeout_s"`
183 Files []File `json:"files"`
184 By string `json:"by"`
185 RequestedAt time.Time `json:"requested_at"`
186 // A service's run (service runs spec §2): the credential's name, for
187 // display; its stable id, which is what decides whose run it is (a
188 // name is reused after a revoke, an id never); and the person it said
189 // it ran for, unverified. All three are empty for a person's run.
190 ByService string `json:"by_service,omitempty"`
191 ByCredential string `json:"by_credential,omitempty"`
192 OnBehalfOf string `json:"on_behalf_of,omitempty"`
193 // IdempotencyKey is a keyed create's key as the record's run_keys holds
194 // it, scoped to its caller and hashed (ScopedKey), never the caller's
195 // own words. It links the run to its key after the record has pruned
196 // the entry (service runs spec §§ 2, 3). Empty for an unkeyed create.
197 IdempotencyKey string `json:"idempotency_key,omitempty"`
198}
199
200// Timeout is the run's timeout as a duration.
201func (r Request) Timeout() time.Duration { return time.Duration(r.TimeoutS) * time.Second }
202
203// ServiceLine is how every surface names the requester of a service's run,
204// "" for a person's. The person a service names is always shown as that
205// service's statement, never as the one who ran it.
206func (r Request) ServiceLine() string {
207 if by := r.ServiceBy(); by != "" {
208 return "run " + by
209 }
210 return ""
211}
212
213// ServiceBy is ServiceLine without its leading "run ", for a sentence that
214// already says "run" ("run <id> started by service ci (scheduled)"). "" for
215// a person's run.
216func (r Request) ServiceBy() string {
217 switch {
218 case r.ByService == "":
219 return ""
220 case r.OnBehalfOf == "":
221 return "by service " + r.ByService + " (scheduled)"
222 }
223 return "by service " + r.ByService + " for " + r.OnBehalfOf + " (as stated by the service)"
224}
225
226// Result is runs/<id>/result.json (run spec §5.5), written only after the
227// last chunk. Signal is the decimal signal number when a signal ended the
228// command (index item 5); Detail is the member-safe wake error for a
229// refused wake (index item 4).
230type Result struct {
231 Exit int `json:"exit"`
232 Signal string `json:"signal"`
233 TimedOut bool `json:"timed_out"`
234 Cancelled bool `json:"cancelled"`
235 Interrupted string `json:"interrupted"`
236 Refused string `json:"refused"`
237 Detail string `json:"detail,omitempty"`
238 Started time.Time `json:"started"`
239 Ended time.Time `json:"ended"`
240 Box string `json:"box"`
241 LastSeq int64 `json:"last_seq"`
242 Truncated bool `json:"truncated"`
243}
244
245// Chunk is one output chunk in a follow page; Data is base64 in JSON.
246type Chunk struct {
247 Seq int64 `json:"seq"`
248 Stream string `json:"stream"`
249 Data []byte `json:"data"`
250}
251
252// Page is GET …/runs/{id}?after=<seq>. State is the record's state for
253// this run, or "" when the record no longer names it. More says the page
254// was cut short: ask again at once.
255type Page struct {
256 ID string `json:"id"`
257 State string `json:"state"`
258 Chunks []Chunk `json:"chunks"`
259 Result *Result `json:"result,omitempty"`
260 More bool `json:"more"`
261}
262
263// Ack is POST …/runs/{id}/ack.
264type Ack struct {
265 LastSeq int64 `json:"last_seq"`
266}
267
268// Created is the 202 to a create. State is the record's state for the run,
269// "" when the record no longer names it. Replayed says the run was admitted
270// earlier under the same Idempotency-Key, and this create started nothing.
271type Created struct {
272 ID string `json:"id"`
273 Host string `json:"host"`
274 State string `json:"state"`
275 Replayed bool `json:"replayed,omitempty"`
276}
277
278// KeyAnswer is GET …/runs?key=<key>: the run admitted under the caller's
279// key.
280type KeyAnswer struct {
281 RunID string `json:"run_id"`
282}
283
284// StateAnswer is the answer to cancel, expire and ack.
285type StateAnswer struct {
286 State string `json:"state"`
287}
288