vole
Clone
Read only, no credentials:
https://rodent.software/git/vole.git
Write over SSH with a registered key:
git clone ssh://git@0.0.0.0:2222/vole.git
A free and open-source forge that lives in your Git repo.
|
c5902451
Keenan Nemetz
16d ago
|
1 | // Package agent implements the Vole CI agent. |
| 2 | // | |
| 3 | // A runner connects to the instance over SSH with the key its account pushes | |
|
7da9111e
Keenan Nemetz
15d ago
|
4 | // with, leases one job at a time, and gives the job a disposable checkout. Steps |
|
70b70264
Keenan Nemetz
15d ago
|
5 | // run in a container built from the image the workflow names, or from a pinned |
| 6 | // .vole/images definition; the runner's key and trusted Git cache stay outside. | |
|
5ece70a5
Keenan Nemetz
10d ago
|
7 | // The runner streams the log while the job runs, uploads artifacts, and sends the |
| 8 | // attempt to the server with its finish message. This package is the agent; | |
| 9 | // argument parsing and the CLI's messages stay in cmd/vole. | |
|
c5902451
Keenan Nemetz
16d ago
|
10 | package agent |
| 11 | ||
| 12 | import ( | |
| 13 | "bufio" | |
| 14 | "context" | |
| 15 | "encoding/json" | |
| 16 | "errors" | |
| 17 | "fmt" | |
|
6bfc4294
Keenan Nemetz
12d ago
|
18 | "io" |
|
c5902451
Keenan Nemetz
16d ago
|
19 | "net/url" |
| 20 | "os" | |
| 21 | "os/exec" | |
| 22 | "path" | |
| 23 | "path/filepath" | |
| 24 | "runtime" | |
| 25 | "sort" | |
| 26 | "strings" | |
| 27 | "sync" | |
| 28 | "syscall" | |
| 29 | "time" | |
| 30 | ||
|
d37d301f
Keenan Nemetz
15h ago
|
31 | "rodent.software/vole/ci" |
| 32 | jobcache "rodent.software/vole/ci/cache" | |
| 33 | "rodent.software/vole/ci/container" | |
| 34 | "rodent.software/vole/ci/redact" | |
| 35 | "rodent.software/vole/ci/runs" | |
| 36 | "rodent.software/vole/client" | |
| 37 | "rodent.software/vole/forge/access" | |
| 38 | "rodent.software/vole/internal/format" | |
| 39 | "rodent.software/vole/metadata" | |
|
c5902451
Keenan Nemetz
16d ago
|
40 | ) |
| 41 | ||
| 42 | // leaseWait is how long an idle runner parks on the lease before asking again. It is | |
| 43 | // below the 60-second cap the server applies and long enough that a quiet pool is | |
| 44 | // invisible in the queue's load. | |
| 45 | const leaseWait = 25 * time.Second | |
| 46 | ||
|
a29ce1f7
Keenan Nemetz
2d ago
|
47 | // cacheTrimInterval is how often the runner bounds the job cache between jobs. A |
| 48 | // namespace that is past the cap while a job has it mounted is skipped and taken on | |
| 49 | // a later pass, so a longer gap costs disk, not correctness. | |
| 50 | const cacheTrimInterval = 5 * time.Minute | |
| 51 | ||
|
18c559ea
Keenan Nemetz
2d ago
|
52 | // How long a slot waits before dialling again after its session failed, and the cap |
| 53 | // that keeps a revoked key from filling the journal while still picking a long | |
| 54 | // outage up promptly. | |
| 55 | const ( | |
| 56 | reconnectDelay = 2 * time.Second | |
| 57 | reconnectMax = 30 * time.Second | |
| 58 | ) | |
| 59 | ||
|
70b70264
Keenan Nemetz
15d ago
|
60 | // errLeaseLost means the queue took the job away while the runner was still |
| 61 | // preparing its environment. There is nothing to report in that case. | |
| 62 | var errLeaseLost = errors.New("lease lost") | |
| 63 | ||
|
0c14f078
Keenan Nemetz
6d ago
|
64 | // errCanceled means a maintainer asked this run to stop. The attempt is still worth |
| 65 | // reporting: a canceled run has a story, and the partial log and step timings are it. | |
| 66 | var errCanceled = errors.New("run canceled") | |
| 67 | ||
|
f642e0aa
Keenan Nemetz
2d ago
|
68 | // errStopped says this slot was asked to stop while it held no job. The session was |
| 69 | // hung up so a parked lease would answer instead of sitting out its wait, and nothing | |
| 70 | // is lost: a slot that holds no job holds no work. | |
| 71 | var errStopped = errors.New("the runner is stopping") | |
| 72 | ||
|
18c559ea
Keenan Nemetz
2d ago
|
73 | // refusedJob says the instance handed this runner a job whose shape this runner does |
| 74 | // not accept. Reconnecting would ask the same instance the same question, so the | |
| 75 | // error stops every slot and the process says why: a runner that kept looping would | |
| 76 | // lease the same job again and refuse it again. | |
| 77 | type refusedJob struct{ err error } | |
| 78 | ||
| 79 | func (r refusedJob) Error() string { return r.err.Error() } | |
| 80 | func (r refusedJob) Unwrap() error { return r.err } | |
| 81 | ||
|
c5902451
Keenan Nemetz
16d ago
|
82 | type runnerStep struct { |
| 83 | Name string `json:"name"` | |
| 84 | Run string `json:"run"` | |
| 85 | Timeout string `json:"timeout"` | |
| 86 | } | |
| 87 | ||
| 88 | type runnerJob struct { | |
| 89 | ID string `json:"id"` | |
| 90 | Repo string `json:"repo"` | |
| 91 | SHA string `json:"sha"` | |
| 92 | // Attempt is which try at this job this is. The record lives under it, so the | |
| 93 | // runner has to know the number it is reporting. | |
| 94 | Attempt int `json:"attempt"` | |
| 95 | // Ref, WorkflowSHA and StepsSHA are part of what this runner signs when the | |
| 96 | // attempt ends: the exact revision, the exact workflow file, and the digest of | |
| 97 | // the steps that were pinned when the job was queued. | |
| 98 | Ref string `json:"ref"` | |
| 99 | WorkflowSHA string `json:"workflow_sha"` | |
| 100 | StepsSHA string `json:"steps_sha"` | |
| 101 | Branch string `json:"branch"` | |
| 102 | Workflow string `json:"workflow"` | |
| 103 | Job string `json:"job"` | |
| 104 | Steps []runnerStep `json:"steps"` | |
|
eb07ff56
Keenan Nemetz
8d ago
|
105 | // Trigger and EventPayload are the pinned event that requested this run. The |
| 106 | // runner writes the payload into a file and sets VOLE_EVENT, VOLE_EVENT_ACTION | |
| 107 | // and VOLE_EVENT_PATH; it is data, never an interpolated command. | |
| 108 | Trigger string `json:"trigger,omitempty"` | |
| 109 | TriggerAction string `json:"trigger_action,omitempty"` | |
| 110 | EventPayload string `json:"event_payload,omitempty"` | |
|
d7958024
Keenan Nemetz
6d ago
|
111 | // Network is the isolation the job asked for with `network:`, or empty when the |
| 112 | // workflow did not say. The runner applies it to the container it starts, so the | |
| 113 | // job's request decides and the machine's default is what an unspecified job gets. | |
| 114 | Network string `json:"network,omitempty"` | |
|
e7aa0785
Keenan Nemetz
6d ago
|
115 | // Timeout is the job's budget as a duration string, or empty when it has none. |
| 116 | // The runner starts the clock with the first step and cuts every later step short | |
| 117 | // when the budget runs out. | |
| 118 | Timeout string `json:"timeout,omitempty"` | |
|
6e1fca51
Keenan Nemetz
6d ago
|
119 | // Bot is the account this job acts as, and Credential is the key it may act with. |
| 120 | // The key is written into the run directory and is valid until the run ends. | |
| 121 | Bot string `json:"bot,omitempty"` | |
| 122 | Credential *runnerCredential `json:"credential,omitempty"` | |
| 123 | // CredentialError means the job asked for an account it cannot act as. The runner | |
| 124 | // fails the job before the container starts rather than running it without a key. | |
| 125 | CredentialError string `json:"credential_error,omitempty"` | |
| 126 | // HostKey is the instance's public host key line, so the job can verify the host | |
| 127 | // it pushes to without scanning it. | |
| 128 | HostKey string `json:"host_key,omitempty"` | |
|
70b70264
Keenan Nemetz
15d ago
|
129 | // Image is a registry reference or "file:<name>". For the latter, ImageDefinition |
| 130 | // is the pinned recipe and ImageSHA its digest; the runner builds it locally. | |
| 131 | Image string `json:"image"` | |
| 132 | ImageFile string `json:"image_file,omitempty"` | |
| 133 | ImageDefinition string `json:"image_definition,omitempty"` | |
| 134 | ImageSHA string `json:"image_sha,omitempty"` | |
|
3b39a84c
Keenan Nemetz
14d ago
|
135 | // Env is the job's declared environment, pinned when the run was queued. |
| 136 | Env map[string]string `json:"env,omitempty"` | |
|
c1929d2f
Keenan Nemetz
5d ago
|
137 | // Services are the containers started beside the job, in declaration order. A job |
| 138 | // with services asked for network: bridge, and the runner gives the job and its | |
| 139 | // services one network for this attempt. | |
| 140 | Services []runnerService `json:"services,omitempty"` | |
|
4ac42680
Keenan Nemetz
7d ago
|
141 | // Secrets are the declared secrets this repository has, and Missing are the |
| 142 | // names it declared that it does not. A missing one leaves its variable unset and | |
| 143 | // gets a line in the log; it is not a failure, because a workflow is allowed an | |
| 144 | // optional secret. | |
| 145 | Secrets map[string]runnerSecret `json:"secrets,omitempty"` | |
| 146 | Missing []runnerSecretIssue `json:"missing_secrets,omitempty"` | |
|
c5902451
Keenan Nemetz
16d ago
|
147 | // Artifacts are path patterns, expanded in the checkout after the steps pass. |
| 148 | Artifacts []string `json:"artifacts,omitempty"` | |
| 149 | // Tag is set for a tag-triggered run; TagAnnotated says whether it carries | |
| 150 | // notes, which is what makes it a release. ReleaseArtifacts are the patterns | |
| 151 | // that also publish to that release. | |
| 152 | Tag string `json:"tag,omitempty"` | |
| 153 | TagAnnotated bool `json:"tag_annotated,omitempty"` | |
| 154 | ReleaseArtifacts []string `json:"release_artifacts,omitempty"` | |
| 155 | // Pages is the site deploy this run performs, if any. | |
| 156 | Pages *runnerPages `json:"pages,omitempty"` | |
| 157 | } | |
| 158 | ||
|
6e1fca51
Keenan Nemetz
6d ago
|
159 | // runnerCredential is the private half of a run's key, delivered once. |
| 160 | type runnerCredential struct { | |
| 161 | PrivateKey string `json:"private_key"` | |
| 162 | Fingerprint string `json:"fingerprint"` | |
| 163 | } | |
| 164 | ||
|
c1929d2f
Keenan Nemetz
5d ago
|
165 | // runnerService is one container the job asked to run beside it. |
| 166 | type runnerService struct { | |
| 167 | ID string `json:"id"` | |
| 168 | Image string `json:"image"` | |
| 169 | Env map[string]string `json:"env,omitempty"` | |
| 170 | } | |
| 171 | ||
|
4ac42680
Keenan Nemetz
7d ago
|
172 | // runnerSecret is one delivered value and the version it came from. The version is |
| 173 | // what the attempt record reports, so a result says which value ran without | |
| 174 | // carrying it. | |
| 175 | type runnerSecret struct { | |
| 176 | Version int `json:"version"` | |
| 177 | Value string `json:"value"` | |
| 178 | } | |
| 179 | ||
| 180 | // runnerSecretIssue is a declared secret the instance could not supply. Reason is | |
| 181 | // empty when the only reason is that nobody has stored that name. | |
| 182 | type runnerSecretIssue struct { | |
| 183 | Name string `json:"name"` | |
| 184 | Reason string `json:"reason,omitempty"` | |
| 185 | } | |
| 186 | ||
|
c5902451
Keenan Nemetz
16d ago
|
187 | type runnerPages struct { |
| 188 | Path string `json:"path"` | |
| 189 | Ref string `json:"ref"` | |
| 190 | } | |
| 191 | ||
| 192 | type runnerArtifact struct { | |
| 193 | Name string `json:"name"` | |
| 194 | OID string `json:"oid"` | |
| 195 | Size int64 `json:"size"` | |
| 196 | } | |
| 197 | ||
|
5ece70a5
Keenan Nemetz
10d ago
|
198 | // runnerLogChunk is one piece of an attempt's log. Offset is where it starts in |
| 199 | // the attempt's file, so the server can refuse a gap or a duplicate. | |
| 200 | type runnerLogChunk struct { | |
| 201 | Attempt int `json:"attempt"` | |
| 202 | Offset int64 `json:"offset"` | |
| 203 | Data []byte `json:"data"` | |
| 204 | } | |
| 205 | ||
|
b312e166
Keenan Nemetz
10d ago
|
206 | // runnerRequest is the wire. A finish carries the attempt's record: the runner |
| 207 | // no longer publishes it to Git, and this channel is what the server checks and | |
| 208 | // stores. | |
|
c5902451
Keenan Nemetz
16d ago
|
209 | type runnerRequest struct { |
| 210 | Op string `json:"op"` | |
| 211 | Run string `json:"run"` | |
|
b312e166
Keenan Nemetz
10d ago
|
212 | // Record is the attempt's result, sent with finish. |
| 213 | Record *runs.Record `json:"record,omitempty"` | |
|
5ece70a5
Keenan Nemetz
10d ago
|
214 | // Log is one chunk of the attempt's log, sent while the job runs. |
| 215 | Log *runnerLogChunk `json:"log,omitempty"` | |
|
c5902451
Keenan Nemetz
16d ago
|
216 | // Repos is the --repos filter, sent with every lease. The server intersects it |
| 217 | // with what the account's allowlists grant, so it can only narrow. | |
| 218 | Repos []string `json:"repos,omitempty"` | |
| 219 | // Labels are what this machine is, sent with every lease so the server can match | |
| 220 | // them against the runs-on a job asked for. | |
| 221 | Labels []string `json:"labels,omitempty"` | |
|
d7958024
Keenan Nemetz
6d ago
|
222 | // Network is the network this machine can give, sent with every lease so the |
| 223 | // server can match it against a job's `network:`. A runner started without | |
| 224 | // --container-network bridge says none, which is the truth: it cannot provide one. | |
| 225 | Network string `json:"network,omitempty"` | |
|
c5902451
Keenan Nemetz
16d ago
|
226 | // Wait is how many seconds an idle lease may park before answering "nothing to |
| 227 | // do"; the server caps it. It is what makes an idle runner cost a parked channel | |
| 228 | // rather than a query every couple of seconds. | |
| 229 | Wait int `json:"wait,omitempty"` | |
| 230 | } | |
| 231 | ||
| 232 | type runnerReply struct { | |
| 233 | Op string `json:"op"` | |
| 234 | Error string `json:"error,omitempty"` | |
| 235 | Run *runnerJob `json:"run,omitempty"` | |
| 236 | OK bool `json:"ok"` | |
| 237 | Lease int `json:"lease,omitempty"` | |
|
62576fa8
Keenan Nemetz
14d ago
|
238 | // The account name this session authenticated as, so a runner can say whose |
| 239 | // results it reports. | |
| 240 | Name string `json:"name,omitempty"` | |
|
c5902451
Keenan Nemetz
16d ago
|
241 | // Repos are the repositories this account may run jobs for. A runner answers the |
| 242 | // "ping" with these so a --repos filter can be checked against them at startup: | |
| 243 | // a filter that matches nothing would otherwise look exactly like a quiet instance. | |
| 244 | Repos []string `json:"repos,omitempty"` | |
|
5ece70a5
Keenan Nemetz
10d ago
|
245 | // LogSize is how much of the log the server has after a log chunk. |
| 246 | LogSize int64 `json:"log_size,omitempty"` | |
|
0c14f078
Keenan Nemetz
6d ago
|
247 | // Cancel names the account that asked this run to stop. It rides the heartbeat |
| 248 | // reply: empty means keep going, and a server that does not send it is one this | |
| 249 | // runner keeps working against, exactly as before. | |
| 250 | Cancel string `json:"cancel,omitempty"` | |
|
c5902451
Keenan Nemetz
16d ago
|
251 | } |
| 252 | ||
| 253 | // Options configure one runner process. | |
| 254 | type Options struct { | |
| 255 | // Remote is the instance's SSH URL, e.g. ssh://git@host:2222. | |
| 256 | Remote string | |
| 257 | // Dir is where checkouts are cached. Empty means the user cache directory. | |
| 258 | Dir string | |
| 259 | // Repos narrows this runner to the named repositories. The server intersects | |
| 260 | // it with the account's runner allowlist, so it can only subtract. | |
| 261 | Repos []string | |
| 262 | // Labels describe the machine. A job is only leased to a runner carrying every | |
| 263 | // label the job asked for with runs-on. | |
| 264 | Labels []string | |
|
18c559ea
Keenan Nemetz
2d ago
|
265 | // Once runs one job per slot and exits, which is what a scheduled container wants. |
|
c5902451
Keenan Nemetz
16d ago
|
266 | Once bool |
|
18c559ea
Keenan Nemetz
2d ago
|
267 | // Jobs is how many jobs one process may run at once, one per session. Zero or |
| 268 | // negative means one. Each slot holds its container limits of its own, so the | |
| 269 | // machine has to carry this many times what --container-memory and the rest | |
| 270 | // allow. | |
| 271 | Jobs int | |
|
7da9111e
Keenan Nemetz
15d ago
|
272 | // ContainerRuntime is the CLI used to run containers: docker, podman, or nerdctl. |
|
70b70264
Keenan Nemetz
15d ago
|
273 | // A runner always requires one; the image itself comes from each job's workflow. |
|
7da9111e
Keenan Nemetz
15d ago
|
274 | ContainerRuntime string |
| 275 | // ContainerNetwork is the network mode: none (the default) or bridge. | |
| 276 | ContainerNetwork string | |
| 277 | // ContainerPull is the image pull policy: missing (the default), always, or never. | |
| 278 | ContainerPull string | |
| 279 | // ContainerUser is the user the job runs as inside the container, uid:gid. | |
| 280 | // Empty means the runner user's uid:gid. | |
| 281 | ContainerUser string | |
| 282 | // ContainerUserns is the user namespace mode passed to the runtime. Empty uses | |
| 283 | // keep-id for rootless Podman, which is what makes a bind-mounted job tree | |
| 284 | // writable by the runner user. | |
| 285 | ContainerUserns string | |
|
4e3759da
Keenan Nemetz
1h ago
|
286 | // ContainerMemory and ContainerCPUs are optional resource limits passed to the |
| 287 | // container runtime. Empty leaves the runtime's own behavior. | |
|
7da9111e
Keenan Nemetz
15d ago
|
288 | ContainerMemory string |
| 289 | ContainerCPUs string | |
|
4e3759da
Keenan Nemetz
1h ago
|
290 | // ContainerPIDs bounds one container's processes. Zero means the default in |
| 291 | // ci/container, not "no limit": a job that can fork without a bound is a fork bomb | |
| 292 | // away from taking the machine. | |
| 293 | ContainerPIDs int | |
| 294 | // ContainerTmpfs is the size of the job's /tmp. Empty means the default; it is a | |
| 295 | // tmpfs, so this is memory. | |
| 296 | ContainerTmpfs string | |
| 297 | // DiskReserve is how much free space the runner insists on keeping under Dir. A | |
| 298 | // job that takes the filesystem below it is stopped, because a job tree is a host | |
| 299 | // bind mount with no quota and the machine is shared. Zero disables the check. | |
| 300 | DiskReserve int64 | |
|
3b39a84c
Keenan Nemetz
14d ago
|
301 | |
| 302 | // NoCache turns off the per-repository cache that lets a job reuse downloads | |
| 303 | // and compiled output between runs. A runner without one is slower and has | |
| 304 | // nothing to poison, which is the trade. | |
| 305 | NoCache bool | |
| 306 | // CacheMaxBytes caps that cache. Zero means the default; see ci/cache. | |
| 307 | CacheMaxBytes int64 | |
|
f594f1a1
Keenan Nemetz
5d ago
|
308 | |
| 309 | // GitCacheMaxBytes caps the saved checkouts of the repositories this runner has | |
| 310 | // built. A job clones from the saved checkout instead of downloading the history | |
| 311 | // again, so the cap trades disk for network. Negative means the default, and zero | |
| 312 | // keeps every checkout; see DefaultGitCacheBytes and gitcache.go. | |
| 313 | GitCacheMaxBytes int64 | |
|
c5902451
Keenan Nemetz
16d ago
|
314 | } |
| 315 | ||
|
18c559ea
Keenan Nemetz
2d ago
|
316 | // Run leases and executes CI jobs until the context ends, a slot is told to stop, |
| 317 | // or (with Once) every slot has handled one job. | |
| 318 | // | |
| 319 | // Everything that belongs to the process happens here, once: the directories, the | |
| 320 | // cache bounds, the leftovers of a killed predecessor, and the connections. The | |
| 321 | // slots then share all of it and differ in nothing but their session, which is what | |
| 322 | // lets --jobs run several jobs on one machine without each of them repeating the | |
| 323 | // startup work or racing it. | |
|
c5902451
Keenan Nemetz
16d ago
|
324 | func Run(ctx context.Context, opts Options) error { |
| 325 | if opts.Remote == "" { | |
| 326 | return errors.New("--remote is required, e.g. --remote ssh://git@example.com:2222") | |
| 327 | } | |
|
18c559ea
Keenan Nemetz
2d ago
|
328 | // Everything below is one process's work, so one cancellation covers it: the |
| 329 | // cache trimmer and the slots stop together when a slot fails for a reason the | |
| 330 | // others cannot fix. | |
| 331 | ctx, cancel := context.WithCancel(ctx) | |
| 332 | defer cancel() | |
|
c5902451
Keenan Nemetz
16d ago
|
333 | cache := opts.Dir |
| 334 | if cache == "" { | |
| 335 | base, err := os.UserCacheDir() | |
| 336 | if err != nil { | |
| 337 | base = os.TempDir() | |
| 338 | } | |
| 339 | cache = filepath.Join(base, "vole-runner") | |
| 340 | } | |
|
7da9111e
Keenan Nemetz
15d ago
|
341 | cache, err := filepath.Abs(cache) |
| 342 | if err != nil { | |
| 343 | return err | |
| 344 | } | |
|
c5902451
Keenan Nemetz
16d ago
|
345 | if err := os.MkdirAll(cache, 0o755); err != nil { |
| 346 | return err | |
| 347 | } | |
| 348 | ||
|
7da9111e
Keenan Nemetz
15d ago
|
349 | box, err := containerOptions(opts, cache) |
| 350 | if err != nil { | |
| 351 | return err | |
| 352 | } | |
|
f28097af
Keenan Nemetz
1h ago
|
353 | // The binary being on PATH is not the runtime being able to create a container. |
| 354 | // Probe before anything is leased: a machine that cannot run a container is not | |
| 355 | // a degraded runner, and an attempt spent proving it is a commit blamed for the | |
| 356 | // machine's problem. | |
| 357 | if err := container.Probe(ctx, box.Config); err != nil { | |
| 358 | return err | |
| 359 | } | |
|
7da9111e
Keenan Nemetz
15d ago
|
360 | cleanupJobTrees(cache) |
| 361 | cleanupStaleContainers(box) | |
|
c1929d2f
Keenan Nemetz
5d ago
|
362 | cleanupStaleNetworks(box) |
|
f594f1a1
Keenan Nemetz
5d ago
|
363 | // Bound the saved checkouts before work starts, the same way the job cache is |
| 364 | // bound below: a runner whose disk filled up while it was not watching should | |
| 365 | // not start by failing a job. | |
| 366 | gitCacheMax := opts.GitCacheMaxBytes | |
| 367 | if gitCacheMax < 0 { | |
| 368 | gitCacheMax = DefaultGitCacheBytes | |
| 369 | } | |
| 370 | maintainTrustedCaches(cache, gitCacheMax, "") | |
|
7da9111e
Keenan Nemetz
15d ago
|
371 | |
|
3b39a84c
Keenan Nemetz
14d ago
|
372 | // The directory is mounted whether or not it is reused: an image that sets |
| 373 | // GOCACHE=/cache/go-build must work under --no-cache too, and a non-root job | |
| 374 | // cannot create /cache in the image's own (root-owned) tree. | |
| 375 | cacheRoot := filepath.Join(cache, "cache") | |
| 376 | if err := os.MkdirAll(cacheRoot, 0o755); err != nil { | |
| 377 | return fmt.Errorf("prepare the job cache: %w", err) | |
| 378 | } | |
| 379 | cacheMax := opts.CacheMaxBytes | |
| 380 | if cacheMax <= 0 { | |
| 381 | cacheMax = jobcache.DefaultBytes | |
| 382 | } | |
| 383 | if opts.NoCache { | |
| 384 | // Nothing survives, so nothing needs bounding; empty what a killed runner | |
| 385 | // left behind. | |
| 386 | _ = jobcache.Clear(cacheRoot) | |
|
a29ce1f7
Keenan Nemetz
2d ago
|
387 | } else { |
| 388 | if removed, err := jobcache.Trim(cacheRoot, cacheMax); err == nil && removed > 0 { | |
| 389 | // A killed runner leaves whatever the last job wrote; bound it before work | |
| 390 | // starts rather than after the disk is full. | |
| 391 | fmt.Fprintf(os.Stderr, "vole runner: trimmed %s from the job cache\n", format.Bytes(removed)) | |
| 392 | } | |
| 393 | // Bound it between jobs too, but on its own beat rather than at the end of | |
| 394 | // every job: eviction is whole-namespace and passes over a namespace a job | |
| 395 | // has mounted, so it is a disk-pressure check rather than the last step of a | |
| 396 | // build. | |
| 397 | go trimJobCache(ctx, cacheRoot, cacheMax) | |
|
3b39a84c
Keenan Nemetz
14d ago
|
398 | } |
| 399 | ||
|
c5902451
Keenan Nemetz
16d ago
|
400 | // A label has to be one the workflow validator would accept, or the machine would |
| 401 | // advertise something no job can ask for — a typo with no symptom at all. | |
| 402 | for _, label := range opts.Labels { | |
| 403 | if err := ci.ValidLabel(label); err != nil { | |
| 404 | return fmt.Errorf("--label %w", err) | |
| 405 | } | |
| 406 | } | |
| 407 | ||
|
18c559ea
Keenan Nemetz
2d ago
|
408 | jobs := opts.Jobs |
| 409 | if jobs < 1 { | |
| 410 | jobs = 1 | |
|
c5902451
Keenan Nemetz
16d ago
|
411 | } |
| 412 | ||
|
18c559ea
Keenan Nemetz
2d ago
|
413 | // One session per slot, all connected before the first lease: a slot that cannot |
| 414 | // reach the instance is a startup failure, not a job that fails later and less | |
| 415 | // clearly. | |
| 416 | slots := make([]*slot, 0, jobs) | |
| 417 | for i := 0; i < jobs; i++ { | |
| 418 | sl := &slot{opts: opts, cache: cache, box: box, cacheRoot: cacheRoot, gitCacheMax: gitCacheMax} | |
| 419 | if err := sl.connect(); err != nil { | |
| 420 | closeSlots(slots) | |
| 421 | return err | |
| 422 | } | |
| 423 | // --repos narrows the account's grant. Check it against the grant now: a filter | |
| 424 | // that matches nothing is indistinguishable from a quiet instance, so it would | |
| 425 | // idle forever while looking healthy — cheaper to find at startup than after a | |
| 426 | // build that never arrives. Every slot gets the same answer, so it is asked | |
| 427 | // once. | |
| 428 | if i == 0 { | |
| 429 | if err := checkRepoFilter(opts.Repos, sl.who); err != nil { | |
| 430 | sl.close() | |
| 431 | closeSlots(slots) | |
| 432 | return err | |
| 433 | } | |
| 434 | } | |
| 435 | slots = append(slots, sl) | |
|
c5902451
Keenan Nemetz
16d ago
|
436 | } |
|
18c559ea
Keenan Nemetz
2d ago
|
437 | |
|
3b39a84c
Keenan Nemetz
14d ago
|
438 | if opts.NoCache { |
| 439 | fmt.Fprintf(os.Stderr, "vole runner: job cache in %s, emptied after every job\n", cacheRoot) | |
| 440 | } else { | |
| 441 | fmt.Fprintf(os.Stderr, "vole runner: job cache in %s, capped at %s\n", | |
| 442 | cacheRoot, format.Bytes(cacheMax)) | |
| 443 | } | |
|
18c559ea
Keenan Nemetz
2d ago
|
444 | fmt.Fprintf(os.Stderr, "vole runner: %s\n", slots[0].describe(jobs)) |
|
3b39a84c
Keenan Nemetz
14d ago
|
445 | fmt.Fprintf(os.Stderr, "vole runner: jobs run in the workflow's image via %s\n", box.Runtime) |
| 446 | if container.RunsAsRoot(box.Config) { | |
| 447 | fmt.Fprintln(os.Stderr, "vole runner: warning: jobs run as root in their container, so a job can "+ | |
| 448 | "rewrite its own image; run the runner as an unprivileged user (or pass --container-user) "+ | |
| 449 | "to have the image's permissions keep it immutable") | |
| 450 | } | |
|
18c559ea
Keenan Nemetz
2d ago
|
451 | |
| 452 | // The slots share a process, so a failure one of them cannot fix — a job this | |
| 453 | // runner refuses to execute — stops them all rather than leaving the process | |
| 454 | // half-alive with an answer nobody is going to change. | |
| 455 | var ( | |
| 456 | wg sync.WaitGroup | |
| 457 | stopMu sync.Mutex | |
| 458 | stopErr error | |
| 459 | ) | |
| 460 | halt := func(err error) { | |
| 461 | stopMu.Lock() | |
| 462 | if stopErr == nil { | |
| 463 | stopErr = err | |
| 464 | } | |
| 465 | stopMu.Unlock() | |
| 466 | cancel() | |
| 467 | } | |
| 468 | for _, sl := range slots { | |
| 469 | wg.Add(1) | |
| 470 | go func(sl *slot) { | |
| 471 | defer wg.Done() | |
| 472 | if err := sl.run(ctx); err != nil { | |
| 473 | halt(err) | |
| 474 | } | |
| 475 | }(sl) | |
| 476 | } | |
| 477 | wg.Wait() | |
| 478 | return stopErr | |
| 479 | } | |
| 480 | ||
| 481 | // slot is one worker: one session, at most one job at a time. --jobs N is N of | |
| 482 | // these. They share the process, the cache root and the saved checkouts, and | |
| 483 | // differ only in the session they lease on, because a lease is answered on the | |
| 484 | // channel that asked for it. | |
| 485 | type slot struct { | |
| 486 | opts Options | |
| 487 | cache string | |
| 488 | box runnerBox | |
| 489 | cacheRoot string | |
| 490 | gitCacheMax int64 | |
| 491 | // sess is this slot's connection, nil between a failure and the reconnect. | |
| 492 | sess *runnerSession | |
| 493 | // who is the account the current session authenticated as. The answer is what | |
| 494 | // the runner reports its results under, so a session without one is not a | |
| 495 | // session yet. | |
| 496 | who runnerReply | |
| 497 | } | |
| 498 | ||
| 499 | // connect dials this slot's session and asks who the instance thinks it is. | |
| 500 | func (sl *slot) connect() error { | |
| 501 | sess, err := dialRunner(sl.opts.Remote) | |
| 502 | if err != nil { | |
| 503 | return err | |
| 504 | } | |
| 505 | who, err := sess.call(runnerRequest{Op: "ping"}) | |
| 506 | if err != nil { | |
| 507 | sess.close() | |
| 508 | return err | |
| 509 | } | |
| 510 | if who.Error != "" { | |
| 511 | sess.close() | |
| 512 | return errors.New(who.Error) | |
| 513 | } | |
| 514 | sess.name = who.Name | |
| 515 | sess.repos, sess.labels = sl.opts.Repos, sl.opts.Labels | |
| 516 | sess.network = sl.box.Network | |
| 517 | sess.cacheRoot, sess.cacheReuse = sl.cacheRoot, !sl.opts.NoCache | |
|
4e3759da
Keenan Nemetz
1h ago
|
518 | sess.diskRoot, sess.diskReserve = sl.cache, sl.opts.DiskReserve |
|
18c559ea
Keenan Nemetz
2d ago
|
519 | sl.sess, sl.who = sess, who |
| 520 | return nil | |
| 521 | } | |
| 522 | ||
| 523 | // close ends this slot's session, if it has one. | |
| 524 | func (sl *slot) close() { | |
| 525 | if sl.sess != nil { | |
| 526 | sl.sess.close() | |
| 527 | sl.sess = nil | |
| 528 | } | |
| 529 | } | |
| 530 | ||
| 531 | // describe is the line the runner prints when it starts work, because "why is | |
| 532 | // nothing running" is answered by reading what it says it will take. | |
| 533 | func (sl *slot) describe(jobs int) string { | |
| 534 | return sl.sess.describe(sl.who.Name, jobs) | |
| 535 | } | |
| 536 | ||
| 537 | // run keeps this slot working until the context ends. A lost connection is the | |
| 538 | // slot's problem rather than the runner's: it reconnects and carries on, so one | |
| 539 | // flaky link does not take the other slots' jobs with it, and the job that was | |
| 540 | // running is requeued when its lease expires. A refused job is the exception — see | |
| 541 | // refusedJob — because reconnecting would ask the same instance the same question. | |
| 542 | func (sl *slot) run(ctx context.Context) error { | |
| 543 | defer sl.close() | |
| 544 | delay := reconnectDelay | |
| 545 | for { | |
| 546 | if ctx.Err() != nil { | |
| 547 | return nil | |
| 548 | } | |
| 549 | if sl.sess == nil { | |
| 550 | if err := sl.connect(); err != nil { | |
| 551 | if sl.opts.Once { | |
| 552 | // A one-shot runner is a process per job: the scheduler decides | |
| 553 | // whether to try again, not a loop here. | |
| 554 | return err | |
| 555 | } | |
| 556 | fmt.Fprintf(os.Stderr, "vole runner: %v; retrying in %s\n", err, delay) | |
| 557 | if !sleep(ctx, delay) { | |
| 558 | return nil | |
| 559 | } | |
| 560 | delay = nextDelay(delay) | |
| 561 | continue | |
| 562 | } | |
| 563 | delay = reconnectDelay | |
| 564 | } | |
| 565 | err := sl.serve(ctx) | |
| 566 | sl.close() | |
| 567 | if err == nil { | |
| 568 | return nil | |
| 569 | } | |
|
f642e0aa
Keenan Nemetz
2d ago
|
570 | if ctx.Err() != nil { |
| 571 | // Stopping: the session went away because it was hung up, or a job | |
| 572 | // finished after another slot stopped the runner. Either way this slot is | |
| 573 | // done, and there is nothing to reconnect to. | |
| 574 | return nil | |
| 575 | } | |
|
18c559ea
Keenan Nemetz
2d ago
|
576 | var refused refusedJob |
| 577 | if errors.As(err, &refused) || sl.opts.Once { | |
| 578 | return err | |
| 579 | } | |
| 580 | fmt.Fprintf(os.Stderr, "vole runner: %v; reconnecting in %s\n", err, delay) | |
| 581 | if !sleep(ctx, delay) { | |
| 582 | return nil | |
| 583 | } | |
| 584 | delay = nextDelay(delay) | |
| 585 | } | |
| 586 | } | |
| 587 | ||
| 588 | // serve leases and runs jobs on this slot's session until something fails. | |
| 589 | func (sl *slot) serve(ctx context.Context) error { | |
| 590 | s := sl.sess | |
|
c5902451
Keenan Nemetz
16d ago
|
591 | for { |
|
18c559ea
Keenan Nemetz
2d ago
|
592 | // A slot that has been told to stop does not lease again. A job it is |
| 593 | // already running finishes and reports, which is what "stop" means here: | |
| 594 | // throwing away a finished build would only hand the same work to somebody | |
| 595 | // else. | |
| 596 | if ctx.Err() != nil { | |
| 597 | return nil | |
| 598 | } | |
| 599 | // A resident runner parks on the server's lease: the reply arrives when work | |
| 600 | // is queued, not on a fixed beat, so an idle slot costs one open channel | |
| 601 | // instead of a query every couple of seconds. A one-shot runner asks once and | |
| 602 | // leaves. | |
| 603 | req := runnerRequest{Op: "lease", Repos: s.repos, Labels: s.labels, Network: s.network} | |
| 604 | if !sl.opts.Once { | |
|
c5902451
Keenan Nemetz
16d ago
|
605 | req.Wait = int(leaseWait.Seconds()) |
| 606 | } | |
|
f642e0aa
Keenan Nemetz
2d ago
|
607 | rep, err := s.lease(ctx, req) |
|
c5902451
Keenan Nemetz
16d ago
|
608 | if err != nil { |
| 609 | return err | |
| 610 | } | |
| 611 | if rep.Error != "" { | |
| 612 | return errors.New(rep.Error) | |
| 613 | } | |
| 614 | if rep.Run == nil { | |
|
18c559ea
Keenan Nemetz
2d ago
|
615 | if sl.opts.Once { |
|
c5902451
Keenan Nemetz
16d ago
|
616 | return nil |
| 617 | } | |
| 618 | // Either the wait expired with nothing to do, or the server answered at | |
| 619 | // once because it is older than this client and ignores `wait`. The sleep | |
| 620 | // keeps the second case at a polite two requests per second instead of a | |
| 621 | // spin. | |
|
18c559ea
Keenan Nemetz
2d ago
|
622 | if !sleep(ctx, 2*time.Second) { |
|
c5902451
Keenan Nemetz
16d ago
|
623 | return nil |
| 624 | } | |
| 625 | continue | |
| 626 | } | |
|
18c559ea
Keenan Nemetz
2d ago
|
627 | // The job came over the channel, and the runner is the party that acts on it: |
| 628 | // every field it builds a path or a command from is checked before the | |
| 629 | // checkout, the container or the cache sees it. | |
|
f5456470
Keenan Nemetz
12d ago
|
630 | if err := validateJob(rep.Run); err != nil { |
|
18c559ea
Keenan Nemetz
2d ago
|
631 | return refusedJob{err} |
|
f5456470
Keenan Nemetz
12d ago
|
632 | } |
|
c5902451
Keenan Nemetz
16d ago
|
633 | lease := time.Duration(rep.Lease) * time.Second |
| 634 | if lease <= 0 { | |
| 635 | lease = 60 * time.Second | |
| 636 | } | |
|
18c559ea
Keenan Nemetz
2d ago
|
637 | s.run(sl.opts.Remote, sl.cache, rep.Run, lease, sl.box) |
| 638 | if sl.opts.Once { | |
|
f594f1a1
Keenan Nemetz
5d ago
|
639 | // This process is done; the next one bounds the cache at startup instead, |
| 640 | // before it leases anything. | |
|
c5902451
Keenan Nemetz
16d ago
|
641 | return nil |
| 642 | } | |
|
f594f1a1
Keenan Nemetz
5d ago
|
643 | // Tidy between jobs, never during one: the checkout this runner just used is |
| 644 | // the one the next job is most likely to want, so it is passed over as active. | |
|
18c559ea
Keenan Nemetz
2d ago
|
645 | maintainTrustedCaches(sl.cache, sl.gitCacheMax, rep.Run.Repo) |
|
c5902451
Keenan Nemetz
16d ago
|
646 | } |
| 647 | } | |
| 648 | ||
|
f642e0aa
Keenan Nemetz
2d ago
|
649 | // lease asks for a job and returns when the server answers or the runner is stopping. |
| 650 | // A resident runner parks on this call for up to leaseWait, and a shutdown that had | |
| 651 | // to sit out the park would be half a minute of nothing happening. | |
| 652 | // | |
| 653 | // The reply is read by the session's decoder, which cannot be interrupted from | |
| 654 | // inside it; ending the connection is what unblocks it, and that costs nothing here | |
| 655 | // because a slot parked on a lease is holding no job. | |
| 656 | func (s *runnerSession) lease(ctx context.Context, req runnerRequest) (runnerReply, error) { | |
| 657 | type answer struct { | |
| 658 | rep runnerReply | |
| 659 | err error | |
| 660 | } | |
| 661 | done := make(chan answer, 1) | |
| 662 | go func() { | |
| 663 | rep, err := s.call(req) | |
| 664 | done <- answer{rep, err} | |
| 665 | }() | |
| 666 | select { | |
| 667 | case a := <-done: | |
| 668 | return a.rep, a.err | |
| 669 | case <-ctx.Done(): | |
| 670 | s.hangup() | |
| 671 | return runnerReply{}, errStopped | |
| 672 | } | |
| 673 | } | |
| 674 | ||
| 675 | // hangup ends the connection from outside the session's lock: taking the lock would | |
| 676 | // wait for the goroutine that holds it, which is the one parked on the read. | |
| 677 | func (s *runnerSession) hangup() { | |
| 678 | if s.cmd != nil && s.cmd.Process != nil { | |
| 679 | _ = s.cmd.Process.Signal(syscall.SIGTERM) | |
| 680 | } | |
| 681 | } | |
| 682 | ||
|
18c559ea
Keenan Nemetz
2d ago
|
683 | func closeSlots(slots []*slot) { |
| 684 | for _, sl := range slots { | |
| 685 | sl.close() | |
| 686 | } | |
| 687 | } | |
| 688 | ||
| 689 | // sleep waits for d or until the context ends, and reports whether the wait | |
| 690 | // finished. | |
| 691 | func sleep(ctx context.Context, d time.Duration) bool { | |
| 692 | select { | |
| 693 | case <-ctx.Done(): | |
| 694 | return false | |
| 695 | case <-time.After(d): | |
| 696 | return true | |
| 697 | } | |
| 698 | } | |
| 699 | ||
| 700 | // nextDelay doubles the wait between reconnects, up to reconnectMax: a revoked key | |
| 701 | // should not fill the journal, and a long outage should still be picked up promptly. | |
| 702 | func nextDelay(d time.Duration) time.Duration { | |
| 703 | if d *= 2; d > reconnectMax { | |
| 704 | return reconnectMax | |
| 705 | } | |
| 706 | return d | |
| 707 | } | |
| 708 | ||
|
c5902451
Keenan Nemetz
16d ago
|
709 | // ---------- connection ---------- |
| 710 | ||
| 711 | type runnerSession struct { | |
| 712 | cmd *exec.Cmd | |
| 713 | in *bufio.Writer | |
| 714 | out *bufio.Reader | |
| 715 | enc *json.Encoder | |
| 716 | dec *json.Decoder | |
| 717 | mu sync.Mutex | |
|
62576fa8
Keenan Nemetz
14d ago
|
718 | // name is the account the server resolved from the connection. A record |
|
c5902451
Keenan Nemetz
16d ago
|
719 | // names it, and the gate refuses a record whose signer is not the pusher. |
|
62576fa8
Keenan Nemetz
14d ago
|
720 | name string |
|
c5902451
Keenan Nemetz
16d ago
|
721 | // repos is the --repos filter, sent with every lease. It is what the operator |
| 722 | // asked for, not the effective list: the server intersects it with the grant on | |
| 723 | // each request, so an allowlist edit takes effect without a restart. | |
| 724 | repos []string | |
|
a29ce1f7
Keenan Nemetz
2d ago
|
725 | // cacheRoot is where this runner keeps the caches it mounts into jobs. cacheReuse |
| 726 | // says whether a job keeps what the last one wrote; with --no-cache the directory | |
| 727 | // is still mounted and emptied instead, so an image that points at /cache works | |
| 728 | // either way. The cap and the trimmer live in Run: a cache is bounded on the | |
| 729 | // runner's own beat, not per job. | |
|
3b39a84c
Keenan Nemetz
14d ago
|
730 | cacheRoot string |
| 731 | cacheReuse bool | |
|
c5902451
Keenan Nemetz
16d ago
|
732 | // labels is what this machine is (--label). A job is only given to a runner that |
| 733 | // carries every label the job asked for, so an unlabelled runner only sees jobs | |
| 734 | // that ask for nothing. | |
| 735 | labels []string | |
|
d7958024
Keenan Nemetz
6d ago
|
736 | // network is the network this runner can give, sent with every lease so the server |
| 737 | // only hands it a job whose `network:` it can satisfy. | |
| 738 | network string | |
|
4e3759da
Keenan Nemetz
1h ago
|
739 | // diskRoot is the filesystem a job's tree is written to, and diskReserve is how |
| 740 | // much of it must stay free. Together they are the only bound on a job tree: it is | |
| 741 | // a host bind mount, so the runtime cannot quota it. | |
| 742 | diskRoot string | |
| 743 | diskReserve int64 | |
|
c5902451
Keenan Nemetz
16d ago
|
744 | } |
| 745 | ||
| 746 | // describe is the one line a runner prints when it starts work, because "why is | |
| 747 | // nothing running" is answered by reading what it says it will take. Both filters are | |
| 748 | // named when they are set and omitted when they are not, so the common case stays one | |
| 749 | // short line. | |
|
18c559ea
Keenan Nemetz
2d ago
|
750 | func (s *runnerSession) describe(name string, jobs int) string { |
|
c5902451
Keenan Nemetz
16d ago
|
751 | out := "connected as " + name |
| 752 | if len(s.labels) > 0 { | |
| 753 | out += ", taking jobs that ask for " + strings.Join(s.labels, ", ") | |
| 754 | } | |
| 755 | if len(s.repos) > 0 { | |
| 756 | out += ", only in " + strings.Join(s.repos, ", ") | |
| 757 | } | |
|
d7958024
Keenan Nemetz
6d ago
|
758 | if s.network == ci.NetworkBridge { |
| 759 | out += ", able to give a job the network" | |
| 760 | } | |
|
18c559ea
Keenan Nemetz
2d ago
|
761 | if jobs > 1 { |
| 762 | out += fmt.Sprintf(", running %d jobs at a time", jobs) | |
| 763 | } | |
|
c5902451
Keenan Nemetz
16d ago
|
764 | return out + "; waiting for work" |
| 765 | } | |
| 766 | ||
| 767 | // checkRepoFilter refuses a --repos filter that can never match. A name the server | |
| 768 | // did not grant is dropped by the intersection rather than refused — an operator may | |
| 769 | // reasonably name a repository before it exists — but a filter whose names are *all* | |
| 770 | // outside the grant is a typo or a permissions problem, and it would otherwise present | |
| 771 | // as a runner that connected happily and never did anything. | |
| 772 | func checkRepoFilter(asked []string, who runnerReply) error { | |
| 773 | if len(asked) == 0 { | |
| 774 | if len(who.Repos) == 0 { | |
| 775 | // Not an error: an allowlist nobody has written yet is a normal state, and it | |
| 776 | // takes effect without a restart. Say so instead of idling silently. | |
| 777 | fmt.Fprintf(os.Stderr, "vole runner: %s may not run jobs for any repository yet; "+ | |
|
2e024cc9
Keenan Nemetz
14d ago
|
778 | "waiting (an admin allows it with `vole admin acl runners add <repo> %s`)\n", |
|
c5902451
Keenan Nemetz
16d ago
|
779 | who.Name, who.Name) |
| 780 | } | |
| 781 | return nil | |
| 782 | } | |
| 783 | granted := make(map[string]bool, len(who.Repos)) | |
| 784 | for _, r := range who.Repos { | |
| 785 | granted[r] = true | |
| 786 | } | |
| 787 | for _, r := range asked { | |
| 788 | if granted[r] { | |
| 789 | return nil | |
| 790 | } | |
| 791 | } | |
| 792 | allowed := "none" | |
| 793 | if len(who.Repos) > 0 { | |
| 794 | allowed = strings.Join(who.Repos, ", ") | |
| 795 | } | |
| 796 | return fmt.Errorf("--repos %s matches nothing %s may run (allowed: %s); "+ | |
| 797 | "a runner can only narrow what its account is allowed", | |
| 798 | strings.Join(asked, ", "), who.Name, allowed) | |
| 799 | } | |
| 800 | ||
| 801 | func dialRunner(remote string) (*runnerSession, error) { | |
| 802 | u, err := url.Parse(remote) | |
| 803 | if err != nil { | |
| 804 | return nil, fmt.Errorf("%q is not an ssh URL: %w", remote, err) | |
| 805 | } | |
| 806 | if u.Scheme != "ssh" || u.Hostname() == "" { | |
| 807 | return nil, fmt.Errorf("%q is not an ssh URL (expected ssh://user@host:port)", remote) | |
| 808 | } | |
| 809 | user := u.User.Username() | |
| 810 | if user == "" { | |
| 811 | user = "git" | |
| 812 | } | |
| 813 | cmd := client.SSHCommand(user+"@"+u.Hostname(), u.Port(), "vole-runner") | |
| 814 | cmd.Stderr = os.Stderr | |
| 815 | stdin, err := cmd.StdinPipe() | |
| 816 | if err != nil { | |
| 817 | return nil, err | |
| 818 | } | |
| 819 | stdout, err := cmd.StdoutPipe() | |
| 820 | if err != nil { | |
| 821 | return nil, err | |
| 822 | } | |
| 823 | if err := cmd.Start(); err != nil { | |
| 824 | return nil, fmt.Errorf("start ssh: %w", err) | |
| 825 | } | |
| 826 | s := &runnerSession{cmd: cmd, in: bufio.NewWriter(stdin), out: bufio.NewReader(stdout)} | |
| 827 | s.enc = json.NewEncoder(s.in) | |
| 828 | s.dec = json.NewDecoder(s.out) | |
| 829 | return s, nil | |
| 830 | } | |
| 831 | ||
| 832 | func (s *runnerSession) call(req runnerRequest) (runnerReply, error) { | |
| 833 | var rep runnerReply | |
| 834 | s.mu.Lock() | |
| 835 | defer s.mu.Unlock() | |
| 836 | if err := s.enc.Encode(req); err != nil { | |
| 837 | return rep, fmt.Errorf("connection lost: %w", err) | |
| 838 | } | |
| 839 | if err := s.in.Flush(); err != nil { | |
| 840 | return rep, fmt.Errorf("connection lost: %w", err) | |
| 841 | } | |
| 842 | if err := s.dec.Decode(&rep); err != nil { | |
| 843 | return rep, fmt.Errorf("connection lost: %w", err) | |
| 844 | } | |
| 845 | return rep, nil | |
| 846 | } | |
| 847 | ||
|
539d6e6c
Keenan Nemetz
18h ago
|
848 | // heartbeat renews the lease and asks whether a maintainer has stopped the run. It |
| 849 | // is for the places that are not a running command: a command heartbeats on its own | |
| 850 | // ticker while it runs, and this is what keeps the lease across the steps that are | |
| 851 | // too short to have one. A broken connection answers false, so an attempt that can | |
| 852 | // no longer reach the instance stops instead of working without a lease. | |
| 853 | func (s *runnerSession) heartbeat(runID string) bool { | |
| 854 | rep, err := s.call(runnerRequest{Op: "heartbeat", Run: runID}) | |
| 855 | if err != nil { | |
| 856 | return false | |
| 857 | } | |
| 858 | return rep.OK | |
| 859 | } | |
| 860 | ||
|
c5902451
Keenan Nemetz
16d ago
|
861 | func (s *runnerSession) close() { |
| 862 | s.mu.Lock() | |
| 863 | _ = s.enc.Encode(runnerRequest{Op: "bye"}) | |
| 864 | _ = s.in.Flush() | |
| 865 | s.mu.Unlock() | |
| 866 | _ = s.cmd.Process.Signal(syscall.SIGTERM) | |
| 867 | _ = s.cmd.Wait() | |
| 868 | } | |
| 869 | ||
| 870 | // ---------- execution ---------- | |
| 871 | ||
| 872 | // run executes a leased job and reports the outcome. A lost lease ends the job | |
| 873 | // without reporting a result: another runner owns it now. | |
|
3b39a84c
Keenan Nemetz
14d ago
|
874 | func (s *runnerSession) run(remote, cache string, job *runnerJob, lease time.Duration, box runnerBox) { |
|
6bfc4294
Keenan Nemetz
12d ago
|
875 | log, err := newSpool(MaxLogBytes) |
| 876 | if err != nil { | |
| 877 | fmt.Fprintf(os.Stderr, "vole runner: %v\n", err) | |
| 878 | return | |
| 879 | } | |
| 880 | defer log.Close() | |
|
e82bbe7d
Keenan Nemetz
7d ago
|
881 | // The values are masked as the log is written, so neither the file the streamer |
| 882 | // sends nor the instance's copy ever holds one. Errors the runner itself prints | |
| 883 | // go through the same mask: an instance error or a runtime message is not | |
| 884 | // supposed to contain a value, and this is what makes that true rather than | |
| 885 | // assumed. | |
| 886 | mask := redact.New(secretValues(job)) | |
| 887 | log.SetMask(mask) | |
|
18c559ea
Keenan Nemetz
2d ago
|
888 | stream := s.startLogStream(job, log) |
| 889 | defer stream.stop() | |
|
7da9111e
Keenan Nemetz
15d ago
|
890 | co, err := prepareWorktree(remote, cache, job.Repo, job.SHA, job.ID, job.Attempt) |
|
c5902451
Keenan Nemetz
16d ago
|
891 | if err != nil { |
| 892 | // No checkout means no branch to commit a record to, so there is nothing to | |
| 893 | // report: the lease expires and the queue gives the job to a runner that can. | |
| 894 | fmt.Fprintf(os.Stderr, "vole runner: cannot prepare a checkout of %s at %s: %v\n", | |
| 895 | job.Repo, format.SHA(job.SHA), err) | |
| 896 | return | |
| 897 | } | |
|
7da9111e
Keenan Nemetz
15d ago
|
898 | defer co.cleanup() |
|
a29ce1f7
Keenan Nemetz
2d ago
|
899 | // The cache is held from here until after the container is gone, so eviction |
| 900 | // cannot take a namespace this job has mounted. The lock is taken before the | |
| 901 | // container starts and released after it stops: the other order would leave a | |
| 902 | // window where the tree is up for eviction but the job can still write to it. | |
| 903 | cacheMounts, releaseCache := s.cacheFor(job) | |
| 904 | defer releaseCache() | |
|
70b70264
Keenan Nemetz
15d ago
|
905 | if job.Image == "" { |
| 906 | // A run queued before images were required has no environment to start. | |
| 907 | // Fail it with a record rather than guessing at the host. | |
|
70c9ad4e
Keenan Nemetz
1h ago
|
908 | const noImage = "this run has no image; re-queue it from a workflow that declares image or image-file" |
| 909 | fmt.Fprintf(log, "\n%s\n", noImage) | |
| 910 | s.finish(remote, co, job, log, stream, attemptOutcome{ | |
| 911 | Exit: 1, Failure: runs.FailureSetup, Message: noImage, | |
| 912 | }, box) | |
|
70b70264
Keenan Nemetz
15d ago
|
913 | return |
| 914 | } | |
|
c1929d2f
Keenan Nemetz
5d ago
|
915 | jobBox := &jobSandbox{} |
| 916 | defer jobBox.Stop() | |
|
70c9ad4e
Keenan Nemetz
1h ago
|
917 | // The steps that ran, so a failure after them keeps what did run and a failure |
| 918 | // before them can say that nothing did. | |
| 919 | var steps []runs.StepResult | |
|
c5902451
Keenan Nemetz
16d ago
|
920 | fail := func(format string, args ...any) { |
|
c1929d2f
Keenan Nemetz
5d ago
|
921 | jobBox.StopWithLogs(log) |
|
e82bbe7d
Keenan Nemetz
7d ago
|
922 | msg := mask.String(fmt.Sprintf(format, args...)) |
|
c5902451
Keenan Nemetz
16d ago
|
923 | fmt.Fprintf(os.Stderr, "vole runner: %s\n", msg) |
| 924 | fmt.Fprintf(log, "\n%s\n", msg) | |
|
70c9ad4e
Keenan Nemetz
1h ago
|
925 | out := attemptOutcome{Exit: 1, Steps: steps} |
| 926 | if len(steps) == 0 { | |
| 927 | // Nothing was tested. The attempt stopped in the runner's own setup (the | |
| 928 | // image build, the container, the network) or in a definition the workflow | |
| 929 | // got wrong; either way the commit is not what failed, so the record says | |
| 930 | // where it stopped and why. | |
| 931 | out.Failure = runs.FailureSetup | |
| 932 | out.Message = failureLine(msg) | |
| 933 | } | |
| 934 | s.finish(remote, co, job, log, stream, out, box) | |
|
c5902451
Keenan Nemetz
16d ago
|
935 | } |
|
6e1fca51
Keenan Nemetz
6d ago
|
936 | // A job that asked to act as an account it cannot act as is broken as written, not |
| 937 | // degraded: fail it before the image is pulled and say why. | |
| 938 | if job.CredentialError != "" { | |
| 939 | fail("%s", job.CredentialError) | |
| 940 | return | |
| 941 | } | |
|
c5902451
Keenan Nemetz
16d ago
|
942 | fmt.Fprintf(log, "=== %s · %s · %s @ %s\n", job.Repo, job.Job, job.Branch, format.SHA(job.SHA)) |
|
d7958024
Keenan Nemetz
6d ago
|
943 | // The job's network request is resolved before anything runs, so a runner that was |
| 944 | // handed a job it cannot isolate refuses it instead of leaking the network into a | |
| 945 | // job that asked for none, or starving a job that asked for one. | |
| 946 | boxConfig, err := box.networkFor(job) | |
| 947 | if err != nil { | |
| 948 | fail("%s", err) | |
| 949 | return | |
| 950 | } | |
| 951 | fmt.Fprintf(log, "environment: %s via %s, network %s\n", job.Image, box.Runtime, boxConfig.Network) | |
|
4ac42680
Keenan Nemetz
7d ago
|
952 | // A declared secret the repository does not have is not a failure: the variable |
| 953 | // is left unset and the log says which one, because a workflow is allowed an | |
| 954 | // optional secret and a required one is ${NAME:?} in the step. | |
| 955 | for _, issue := range job.Missing { | |
| 956 | if issue.Reason != "" { | |
| 957 | fmt.Fprintf(log, "vole: %s cannot be read (%s), so this job runs without it.\n", | |
| 958 | issue.Name, issue.Reason) | |
| 959 | continue | |
| 960 | } | |
| 961 | fmt.Fprintf(log, "vole: %s is not set for this repository, so this job runs without it.\n", issue.Name) | |
| 962 | } | |
|
7da9111e
Keenan Nemetz
15d ago
|
963 | |
|
70b70264
Keenan Nemetz
15d ago
|
964 | image, err := s.resolveImage(job, box, log, lease) |
| 965 | if err != nil { | |
| 966 | if errors.Is(err, errLeaseLost) { | |
| 967 | fmt.Fprintf(os.Stderr, "vole runner: lease lost while building %s; stopping\n", job.Image) | |
|
7da9111e
Keenan Nemetz
15d ago
|
968 | return |
| 969 | } | |
|
0c14f078
Keenan Nemetz
6d ago
|
970 | if errors.Is(err, errCanceled) { |
| 971 | // The build was stopped on a heartbeat, and the log already says why. The | |
| 972 | // attempt still has a story: report it with the work that did happen. | |
|
70c9ad4e
Keenan Nemetz
1h ago
|
973 | s.finish(remote, co, job, log, stream, attemptOutcome{Exit: 1}, box) |
|
0c14f078
Keenan Nemetz
6d ago
|
974 | return |
| 975 | } | |
|
70b70264
Keenan Nemetz
15d ago
|
976 | fail("%s", err) |
| 977 | return | |
|
7da9111e
Keenan Nemetz
15d ago
|
978 | } |
|
c1929d2f
Keenan Nemetz
5d ago
|
979 | // Services are containers beside the job on a network of their own. They start |
| 980 | // after the image is resolved so a slow build does not leave a database running, | |
| 981 | // and before the job so their names resolve for the first step. | |
| 982 | if len(job.Services) > 0 { | |
| 983 | netName := serviceNetworkName(box, job) | |
| 984 | ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) | |
| 985 | net, err := container.CreateNetwork(ctx, box.Config, netName, box.labels(job)) | |
| 986 | cancel() | |
| 987 | if err != nil { | |
| 988 | fail("%s", err) | |
| 989 | return | |
| 990 | } | |
| 991 | jobBox.network = net | |
| 992 | serviceConfig := boxConfig | |
| 993 | serviceConfig.Network = netName | |
| 994 | boxConfig.Network = netName | |
| 995 | ids := make([]string, 0, len(job.Services)) | |
| 996 | for _, svc := range job.Services { | |
| 997 | c, err := container.StartService(serviceConfig, container.ServiceSpec{ | |
| 998 | Name: serviceContainerName(box, job, svc.ID), | |
| 999 | Alias: svc.ID, | |
| 1000 | Image: svc.Image, | |
| 1001 | Env: svc.Env, | |
| 1002 | Labels: box.labels(job), | |
| 1003 | }) | |
| 1004 | if err != nil { | |
| 1005 | fail("%s", err) | |
| 1006 | return | |
| 1007 | } | |
| 1008 | jobBox.services = append(jobBox.services, c) | |
| 1009 | jobBox.serviceIDs = append(jobBox.serviceIDs, svc.ID) | |
| 1010 | ids = append(ids, svc.ID+" ("+svc.Image+")") | |
| 1011 | } | |
| 1012 | fmt.Fprintf(log, "services: %s\n", strings.Join(ids, ", ")) | |
| 1013 | for i, c := range jobBox.services { | |
| 1014 | running, err := c.Running(context.Background()) | |
| 1015 | if err != nil { | |
| 1016 | fail("service %s: %s", job.Services[i].ID, err) | |
| 1017 | return | |
| 1018 | } | |
| 1019 | if !running { | |
| 1020 | fail("service %s exited before the steps started", job.Services[i].ID) | |
| 1021 | return | |
| 1022 | } | |
| 1023 | } | |
| 1024 | } | |
|
4ac42680
Keenan Nemetz
7d ago
|
1025 | // The run directory is mounted at /run/vole for the pinned event payload and for |
| 1026 | // the job's secrets. Both arrive as files rather than as interpolated commands: a | |
| 1027 | // large issue body cannot make an argument oversized, and a secret never becomes | |
| 1028 | // an argument at all. | |
|
a29ce1f7
Keenan Nemetz
2d ago
|
1029 | mounts := cacheMounts |
|
eb07ff56
Keenan Nemetz
8d ago
|
1030 | env := job.Env |
|
4ac42680
Keenan Nemetz
7d ago
|
1031 | runDir := "" |
|
6e1fca51
Keenan Nemetz
6d ago
|
1032 | if job.EventPayload != "" || len(job.Secrets) > 0 || job.Credential != nil { |
|
4ac42680
Keenan Nemetz
7d ago
|
1033 | runDir = filepath.Join(filepath.Dir(co.Work), "run-"+job.ID+"-"+fmt.Sprint(job.Attempt)) |
| 1034 | if err := os.MkdirAll(runDir, 0o700); err != nil { | |
| 1035 | fail("cannot prepare the run directory: %s", err) | |
|
eb07ff56
Keenan Nemetz
8d ago
|
1036 | return |
| 1037 | } | |
|
4ac42680
Keenan Nemetz
7d ago
|
1038 | defer os.RemoveAll(runDir) |
| 1039 | mounts = append(mounts, container.Mount{Host: runDir, Path: "/run/vole"}) | |
| 1040 | } | |
| 1041 | if job.EventPayload != "" { | |
| 1042 | if err := os.WriteFile(filepath.Join(runDir, "event.json"), []byte(job.EventPayload), 0o600); err != nil { | |
|
eb07ff56
Keenan Nemetz
8d ago
|
1043 | fail("cannot write the event payload: %s", err) |
| 1044 | return | |
| 1045 | } | |
| 1046 | env = make(map[string]string, len(job.Env)+3) | |
| 1047 | for name, value := range job.Env { | |
| 1048 | env[name] = value | |
| 1049 | } | |
| 1050 | env["VOLE_EVENT"] = job.Trigger | |
| 1051 | env["VOLE_EVENT_ACTION"] = job.TriggerAction | |
| 1052 | env["VOLE_EVENT_PATH"] = "/run/vole/event.json" | |
| 1053 | } | |
|
4ac42680
Keenan Nemetz
7d ago
|
1054 | secretsFile := "" |
| 1055 | if len(job.Secrets) > 0 { | |
| 1056 | script, err := secretsScript(job.Secrets) | |
| 1057 | if err != nil { | |
| 1058 | fail("%s", err) | |
| 1059 | return | |
| 1060 | } | |
| 1061 | if err := os.WriteFile(filepath.Join(runDir, "secrets.env"), script, 0o600); err != nil { | |
| 1062 | fail("cannot prepare the secrets: %s", err) | |
| 1063 | return | |
| 1064 | } | |
| 1065 | secretsFile = "/run/vole/secrets.env" | |
| 1066 | } | |
|
6e1fca51
Keenan Nemetz
6d ago
|
1067 | if job.Credential != nil { |
| 1068 | // The key is a file, not a value in the environment, so `git push` finds it by | |
| 1069 | // path and no step has to interpolate it. The known_hosts entry comes from the | |
| 1070 | // lease: the job cannot scan the host without trusting whatever answers. | |
| 1071 | if env, err = withCredentialEnv(env, remote, job); err != nil { | |
| 1072 | fail("%s", err) | |
| 1073 | return | |
| 1074 | } | |
| 1075 | if err := os.WriteFile(filepath.Join(runDir, "id_bot"), []byte(job.Credential.PrivateKey), 0o600); err != nil { | |
| 1076 | fail("cannot write the run credential: %s", err) | |
| 1077 | return | |
| 1078 | } | |
| 1079 | known, err := knownHostsLine(remote, job.HostKey) | |
| 1080 | if err != nil { | |
| 1081 | fail("%s", err) | |
| 1082 | return | |
| 1083 | } | |
| 1084 | if err := os.WriteFile(filepath.Join(runDir, "known_hosts"), []byte(known), 0o644); err != nil { | |
| 1085 | fail("cannot write the known_hosts entry: %s", err) | |
| 1086 | return | |
| 1087 | } | |
| 1088 | fmt.Fprintf(log, "acting as %s for this run\n", job.Bot) | |
| 1089 | } | |
|
a29ce1f7
Keenan Nemetz
2d ago
|
1090 | if s.cacheRoot != "" && !s.cacheReuse { |
|
3b39a84c
Keenan Nemetz
14d ago
|
1091 | defer func() { |
|
a29ce1f7
Keenan Nemetz
2d ago
|
1092 | // The namespace is held exclusively for this job and this defer runs before |
| 1093 | // the lock is released, so nothing else is inside it while it is emptied. | |
| 1094 | // Only the cache mount: the run directory is the job's own and is removed | |
| 1095 | // by the defer above. | |
| 1096 | for _, m := range cacheMounts { | |
| 1097 | _ = os.RemoveAll(m.Host) | |
|
3b39a84c
Keenan Nemetz
14d ago
|
1098 | } |
| 1099 | }() | |
| 1100 | } | |
|
d7958024
Keenan Nemetz
6d ago
|
1101 | sandbox, err := container.Start(boxConfig, container.Spec{ |
|
4ac42680
Keenan Nemetz
7d ago
|
1102 | Image: image, |
| 1103 | Name: containerName(box, job), | |
| 1104 | Work: co.Work, | |
| 1105 | Labels: box.labels(job), | |
| 1106 | Mounts: mounts, | |
| 1107 | Env: env, | |
| 1108 | SecretsFile: secretsFile, | |
|
3b39a84c
Keenan Nemetz
14d ago
|
1109 | }) |
|
70b70264
Keenan Nemetz
15d ago
|
1110 | if err != nil { |
| 1111 | fail("%s", err) | |
| 1112 | return | |
| 1113 | } | |
|
c1929d2f
Keenan Nemetz
5d ago
|
1114 | jobBox.job = sandbox |
|
c5902451
Keenan Nemetz
16d ago
|
1115 | |
|
70c9ad4e
Keenan Nemetz
1h ago
|
1116 | steps = make([]runs.StepResult, 0, len(job.Steps)) |
|
e7aa0785
Keenan Nemetz
6d ago
|
1117 | // The job's budget starts with its first step. An image build has its own cap, so a |
| 1118 | // slow pull must not eat the time the steps were given. | |
| 1119 | var deadline time.Time | |
| 1120 | if d, err := time.ParseDuration(job.Timeout); err == nil && d > 0 { | |
| 1121 | deadline = time.Now().Add(d) | |
| 1122 | } | |
|
c5902451
Keenan Nemetz
16d ago
|
1123 | for _, step := range job.Steps { |
|
c765ae2e
Keenan Nemetz
13d ago
|
1124 | // The step's section of the log starts at its header, so a reader can open |
| 1125 | // one step without holding the image build that ran before it. | |
|
6bfc4294
Keenan Nemetz
12d ago
|
1126 | logStart := log.Len() |
|
c765ae2e
Keenan Nemetz
13d ago
|
1127 | started := time.Now().UTC() |
|
c5902451
Keenan Nemetz
16d ago
|
1128 | fmt.Fprintf(log, "\n--- %s\n", step.Name) |
|
e7aa0785
Keenan Nemetz
6d ago
|
1129 | own, _ := time.ParseDuration(step.Timeout) |
| 1130 | limit := ci.StepLimit(ci.BudgetLeft(deadline, time.Now()), own) | |
|
0c14f078
Keenan Nemetz
6d ago
|
1131 | code, lost, canceled := 0, false, false |
|
e7aa0785
Keenan Nemetz
6d ago
|
1132 | if limit <= 0 { |
| 1133 | // The budget is spent, so the step must not start. Reporting it as a failed | |
| 1134 | // step keeps the record the same shape as any other failure. | |
| 1135 | fmt.Fprintf(log, "\nthe job's %s budget ran out before this step\n", job.Timeout) | |
| 1136 | code = 1 | |
| 1137 | } else { | |
|
539d6e6c
Keenan Nemetz
18h ago
|
1138 | // Renew before the step, not only during it: a step heartbeats while it runs, |
| 1139 | // but its ticker starts with the step. Steps that each finish inside one | |
| 1140 | // interval never heartbeat at all, so an attempt whose steps add up to more | |
| 1141 | // than the lease expires between two of them and the next heartbeat arrives | |
| 1142 | // to find the queue has already given the job away. | |
| 1143 | if !s.heartbeat(job.ID) { | |
| 1144 | fmt.Fprintf(os.Stderr, "vole runner: lease lost before %q; stopping\n", step.Name) | |
| 1145 | return | |
| 1146 | } | |
|
0c14f078
Keenan Nemetz
6d ago
|
1147 | code, lost, canceled = s.execStep(job.ID, sandbox, step, limit, lease, log) |
|
e7aa0785
Keenan Nemetz
6d ago
|
1148 | } |
|
e82bbe7d
Keenan Nemetz
7d ago
|
1149 | // The mask holds back a few bytes so a value split across writes is caught; |
| 1150 | // flushing here keeps this step's byte range its own. | |
| 1151 | log.Flush() | |
|
c765ae2e
Keenan Nemetz
13d ago
|
1152 | result := runs.StepResult{ |
| 1153 | Name: step.Name, Started: started, Finished: time.Now().UTC(), Exit: code, | |
|
6bfc4294
Keenan Nemetz
12d ago
|
1154 | LogOffset: logStart, LogSize: log.Len() - logStart, |
|
c765ae2e
Keenan Nemetz
13d ago
|
1155 | } |
|
c5902451
Keenan Nemetz
16d ago
|
1156 | if lost { |
| 1157 | fmt.Fprintf(os.Stderr, "vole runner: lease lost while running %q; stopping\n", step.Name) | |
| 1158 | return // no finish: the queue already gave it to somebody else | |
| 1159 | } | |
|
0c14f078
Keenan Nemetz
6d ago
|
1160 | if canceled { |
| 1161 | // The heartbeat said a maintainer asked the run to stop, and runCommand | |
| 1162 | // already wrote the line to the log. Keep the partial attempt: the log and | |
| 1163 | // the timings are what a reader asks for first after "what had it done". | |
| 1164 | steps = append(steps, result) | |
|
c1929d2f
Keenan Nemetz
5d ago
|
1165 | jobBox.StopWithLogs(log) |
|
70c9ad4e
Keenan Nemetz
1h ago
|
1166 | s.finish(remote, co, job, log, stream, attemptOutcome{Exit: code, Steps: steps}, box) |
|
0c14f078
Keenan Nemetz
6d ago
|
1167 | return |
| 1168 | } | |
|
c5902451
Keenan Nemetz
16d ago
|
1169 | if code != 0 { |
| 1170 | fmt.Fprintf(log, "\nstep %q exited %d\n", step.Name, code) | |
|
6bfc4294
Keenan Nemetz
12d ago
|
1171 | result.LogSize = log.Len() - logStart |
|
c765ae2e
Keenan Nemetz
13d ago
|
1172 | result.Finished = time.Now().UTC() |
| 1173 | steps = append(steps, result) | |
|
c1929d2f
Keenan Nemetz
5d ago
|
1174 | jobBox.StopWithLogs(log) |
|
70c9ad4e
Keenan Nemetz
1h ago
|
1175 | s.finish(remote, co, job, log, stream, attemptOutcome{Exit: code, Steps: steps}, box) |
|
c5902451
Keenan Nemetz
16d ago
|
1176 | return |
| 1177 | } | |
|
c765ae2e
Keenan Nemetz
13d ago
|
1178 | steps = append(steps, result) |
|
c5902451
Keenan Nemetz
16d ago
|
1179 | } |
|
7da9111e
Keenan Nemetz
15d ago
|
1180 | // The steps are done. Stop the container before reading artifacts or deploying a |
| 1181 | // site, so a process the workflow left in the background cannot change the files | |
| 1182 | // while the runner is publishing them. | |
|
c1929d2f
Keenan Nemetz
5d ago
|
1183 | jobBox.StopJob() |
|
7da9111e
Keenan Nemetz
15d ago
|
1184 | arts, releaseArts, err := publishArtifacts(remote, co.Work, job, log) |
|
c5902451
Keenan Nemetz
16d ago
|
1185 | if err != nil { |
| 1186 | fail("%s", err) | |
| 1187 | return | |
| 1188 | } | |
| 1189 | if len(releaseArts) > 0 { | |
| 1190 | // The pointers ride the record's push, but a declaration that cannot name a | |
| 1191 | // release is a mistake worth failing on before the push rather than after it. | |
| 1192 | if err := checkReleaseJob(job); err != nil { | |
| 1193 | fail("%s", err) | |
| 1194 | return | |
| 1195 | } | |
| 1196 | } | |
|
7da9111e
Keenan Nemetz
15d ago
|
1197 | if err := publishSite(remote, co, job, log); err != nil { |
|
c5902451
Keenan Nemetz
16d ago
|
1198 | fail("%s", err) |
| 1199 | return | |
| 1200 | } | |
|
70c9ad4e
Keenan Nemetz
1h ago
|
1201 | s.finish(remote, co, job, log, stream, attemptOutcome{ |
| 1202 | Steps: steps, Artifacts: arts, ReleaseArtifacts: releaseArts, | |
| 1203 | }, box) | |
|
c5902451
Keenan Nemetz
16d ago
|
1204 | } |
| 1205 | ||
|
a29ce1f7
Keenan Nemetz
2d ago
|
1206 | // cacheFor returns the mount for one job's cache and the lock that keeps eviction |
| 1207 | // off the namespace until release is called. The release function is always | |
| 1208 | // non-nil, so a caller can defer it without asking whether there is a cache: a | |
| 1209 | // runner with none returns no mounts and a no-op release. | |
| 1210 | // | |
| 1211 | // It sets no environment: a job opts in by pointing its own variables at Mount. The | |
| 1212 | // namespace separates repositories from each other, and contributor branches from | |
| 1213 | // everything else: a cache is shared mutable state, and a branch nobody has vouched | |
| 1214 | // for must not be able to seed the one a protected branch builds into. | |
| 1215 | func (s *runnerSession) cacheFor(job *runnerJob) ([]container.Mount, func()) { | |
|
3b39a84c
Keenan Nemetz
14d ago
|
1216 | if s.cacheRoot == "" { |
|
a29ce1f7
Keenan Nemetz
2d ago
|
1217 | return nil, func() {} |
|
3b39a84c
Keenan Nemetz
14d ago
|
1218 | } |
|
6bfc4294
Keenan Nemetz
12d ago
|
1219 | // A contributor branch is untrusted code, and a cache is shared mutable state: |
| 1220 | // one contributor's branch must not be able to seed another's cache or the one a | |
| 1221 | // protected branch builds into. Namespacing by account means a contributor's | |
| 1222 | // cache follows them across their branches and nobody else can write it. | |
|
e5478085
Keenan Nemetz
11d ago
|
1223 | trust := access.TrustNamespace(job.Ref) |
|
3b39a84c
Keenan Nemetz
14d ago
|
1224 | ns := jobcache.Namespace(s.cacheRoot, job.Repo, trust, jobcache.ImageKey(job.ImageSHA, job.Image)) |
|
a29ce1f7
Keenan Nemetz
2d ago
|
1225 | // Take the lock before creating the directory, so an eviction between the two |
| 1226 | // cannot remove what this job has just made. The shared lock is what lets two | |
| 1227 | // jobs of the same repository run at once while eviction passes over both; | |
| 1228 | // --no-cache takes it exclusively, because emptying the namespace afterwards must | |
| 1229 | // not land on somebody else's build. | |
| 1230 | var release func() | |
| 1231 | var err error | |
| 1232 | if s.cacheReuse { | |
| 1233 | release, err = jobcache.Lock(s.cacheRoot, ns) | |
| 1234 | } else { | |
| 1235 | release, err = jobcache.LockExclusive(s.cacheRoot, ns) | |
| 1236 | } | |
| 1237 | if err != nil { | |
| 1238 | fmt.Fprintf(os.Stderr, "vole runner: cannot lock a cache for %s: %v\n", job.Repo, err) | |
| 1239 | return nil, func() {} | |
| 1240 | } | |
|
3b39a84c
Keenan Nemetz
14d ago
|
1241 | if err := os.MkdirAll(ns, 0o755); err != nil { |
|
a29ce1f7
Keenan Nemetz
2d ago
|
1242 | release() |
|
3b39a84c
Keenan Nemetz
14d ago
|
1243 | fmt.Fprintf(os.Stderr, "vole runner: cannot prepare a cache for %s: %v\n", job.Repo, err) |
|
a29ce1f7
Keenan Nemetz
2d ago
|
1244 | return nil, func() {} |
| 1245 | } | |
| 1246 | return []container.Mount{{Host: ns, Path: jobcache.Mount}}, release | |
| 1247 | } | |
| 1248 | ||
| 1249 | // trimJobCache bounds the job cache until the runner stops. Each pass skips a | |
| 1250 | // namespace a job has mounted; the next pass tries again, so the cap is a bound on | |
| 1251 | // what the runner keeps rather than a promise about the moment a job ends. | |
| 1252 | func trimJobCache(ctx context.Context, root string, max int64) { | |
| 1253 | t := time.NewTicker(cacheTrimInterval) | |
| 1254 | defer t.Stop() | |
| 1255 | for { | |
| 1256 | select { | |
| 1257 | case <-ctx.Done(): | |
| 1258 | return | |
| 1259 | case <-t.C: | |
| 1260 | if removed, err := jobcache.Trim(root, max); err == nil && removed > 0 { | |
| 1261 | fmt.Fprintf(os.Stderr, "vole runner: trimmed %s from the job cache\n", format.Bytes(removed)) | |
| 1262 | } | |
| 1263 | } | |
|
3b39a84c
Keenan Nemetz
14d ago
|
1264 | } |
| 1265 | } | |
| 1266 | ||
|
70c9ad4e
Keenan Nemetz
1h ago
|
1267 | // attemptOutcome is what the runner has to say about a finished attempt: the exit |
| 1268 | // code, the steps that ran, what it published, and — when the attempt stopped before | |
| 1269 | // its first step — where it stopped and the runner's own reason. A setup failure is | |
| 1270 | // not a verdict about the commit, so it is carried apart from the exit code rather | |
| 1271 | // than folded into it. | |
| 1272 | type attemptOutcome struct { | |
| 1273 | Exit int | |
| 1274 | Steps []runs.StepResult | |
| 1275 | Artifacts []runnerArtifact | |
| 1276 | ReleaseArtifacts []runnerArtifact | |
| 1277 | // Failure is empty when a step decided the result, or runs.FailureSetup when no | |
| 1278 | // step ever started. Message is the one-line reason that goes with it, already | |
| 1279 | // masked. | |
| 1280 | Failure string | |
| 1281 | Message string | |
| 1282 | } | |
| 1283 | ||
| 1284 | // failureLine is the one line a record keeps for a failure that never reached a | |
| 1285 | // step. The message is already masked, so this only makes it display text: one line, | |
| 1286 | // bounded to what a record accepts. | |
| 1287 | func failureLine(msg string) string { | |
| 1288 | line := strings.Join(strings.Fields(msg), " ") | |
| 1289 | if len(line) > runs.MaxFailureMessage { | |
| 1290 | line = strings.ToValidUTF8(line[:runs.MaxFailureMessage], "") | |
| 1291 | } | |
| 1292 | return line | |
| 1293 | } | |
| 1294 | ||
|
b312e166
Keenan Nemetz
10d ago
|
1295 | // finish uploads this attempt's log, sends its record to the server with the |
| 1296 | // finish message, and then attaches any release pointers the build published. | |
|
c5902451
Keenan Nemetz
16d ago
|
1297 | // |
| 1298 | // The bytes go in first, the same rule artifacts follow: a record is a set of | |
|
b312e166
Keenan Nemetz
10d ago
|
1299 | // pointers, and the server refuses one whose log or artifact is not in the store. |
| 1300 | // The record is not a commit; the server stores it with the run's result in one | |
| 1301 | // transaction. A tagged build's release pointers are product metadata and are pushed | |
| 1302 | // afterwards, so a release cannot exist without the build that produced it. | |
|
c5902451
Keenan Nemetz
16d ago
|
1303 | // |
| 1304 | // A log that cannot be stored does not lose the result: the record then names no log | |
|
b312e166
Keenan Nemetz
10d ago
|
1305 | // and the failure is reported on stderr rather than swallowed. A record the server |
| 1306 | // does not accept is not reported at all. | |
|
70c9ad4e
Keenan Nemetz
1h ago
|
1307 | func (s *runnerSession) finish(remote string, co checkout, job *runnerJob, log *spool, stream *logStream, out attemptOutcome, box runnerBox) { |
|
5ece70a5
Keenan Nemetz
10d ago
|
1308 | if err := log.seal(); err != nil { |
| 1309 | fmt.Fprintf(os.Stderr, "vole runner: could not seal the log for %s: %v\n", job.ID, err) | |
|
c5902451
Keenan Nemetz
16d ago
|
1310 | } |
|
18c559ea
Keenan Nemetz
2d ago
|
1311 | streamErr := stream.drain() |
|
5ece70a5
Keenan Nemetz
10d ago
|
1312 | if streamErr != nil { |
| 1313 | fmt.Fprintf(os.Stderr, "vole runner: log stream for %s failed: %v\n", job.ID, streamErr) | |
| 1314 | // The step ranges describe bytes the server does not have. Keep the timings, | |
| 1315 | // which are still true, and drop the claims about the log. | |
|
70c9ad4e
Keenan Nemetz
1h ago
|
1316 | for i := range out.Steps { |
| 1317 | out.Steps[i].LogOffset, out.Steps[i].LogSize = 0, 0 | |
|
c765ae2e
Keenan Nemetz
13d ago
|
1318 | } |
| 1319 | } | |
|
70c9ad4e
Keenan Nemetz
1h ago
|
1320 | artifacts := make([]runs.Artifact, 0, len(out.Artifacts)) |
| 1321 | for _, a := range out.Artifacts { | |
|
c5902451
Keenan Nemetz
16d ago
|
1322 | artifacts = append(artifacts, runs.Artifact{Name: a.Name, OID: a.OID, Size: a.Size}) |
| 1323 | } | |
| 1324 | rec := runs.Record{ | |
| 1325 | RunID: job.ID, Attempt: job.Attempt, Repo: job.Repo, Ref: job.Ref, SHA: job.SHA, | |
| 1326 | Workflow: job.Workflow, WorkflowSHA: job.WorkflowSHA, Job: job.Job, | |
|
70c9ad4e
Keenan Nemetz
1h ago
|
1327 | StepsSHA: job.StepsSHA, Exit: out.Exit, Finished: time.Now().UTC(), |
| 1328 | Signer: s.name, Steps: out.Steps, Artifacts: artifacts, | |
| 1329 | Failure: out.Failure, FailureMessage: out.Message, | |
|
c5902451
Keenan Nemetz
16d ago
|
1330 | // The machine this attempt ran on. The runner binary's own build target, |
| 1331 | // which is the honest thing it can say: a live probe would add a subprocess | |
| 1332 | // and a platform quirk for a fact the binary already knows. | |
| 1333 | OS: runtime.GOOS, Arch: runtime.GOARCH, | |
| 1334 | } | |
|
70b70264
Keenan Nemetz
15d ago
|
1335 | rec.Image, rec.ImageFile, rec.ImageSHA = job.Image, job.ImageFile, job.ImageSHA |
|
3b39a84c
Keenan Nemetz
14d ago
|
1336 | rec.Runtime = box.Runtime |
|
4ac42680
Keenan Nemetz
7d ago
|
1337 | // The record says which version of each secret the job ran with, never the value. |
| 1338 | // The names are sorted so the same attempt reported twice produces the same bytes. | |
| 1339 | for name, delivered := range job.Secrets { | |
| 1340 | rec.Secrets = append(rec.Secrets, runs.SecretRef{Name: name, Version: delivered.Version}) | |
| 1341 | } | |
| 1342 | sort.Slice(rec.Secrets, func(i, j int) bool { return rec.Secrets[i].Name < rec.Secrets[j].Name }) | |
|
5ece70a5
Keenan Nemetz
10d ago
|
1343 | if streamErr == nil { |
| 1344 | rec.LogSize = log.Len() | |
|
c5902451
Keenan Nemetz
16d ago
|
1345 | } |
| 1346 | ||
|
b312e166
Keenan Nemetz
10d ago
|
1347 | // Keep the lease through the report: the record is checked against it, and a |
| 1348 | // slow upload must not turn a finished job into one that expired mid-report. | |
|
c5902451
Keenan Nemetz
16d ago
|
1349 | if rep, err := s.call(runnerRequest{Op: "heartbeat", Run: job.ID}); err != nil { |
|
73755a56
Keenan Nemetz
9d ago
|
1350 | // The channel is gone, so there is no way to send the record. The queue |
| 1351 | // will hand the job to another runner after the lease expires. | |
|
c5902451
Keenan Nemetz
16d ago
|
1352 | fmt.Fprintf(os.Stderr, "vole runner: lost the connection while finishing %s: %v\n", job.ID, err) |
| 1353 | return | |
|
73755a56
Keenan Nemetz
9d ago
|
1354 | } else if rep.Error != "" { |
| 1355 | // The server could not answer for the lease, so there is no evidence that | |
| 1356 | // it is still ours. A record sent now would be a guess. | |
| 1357 | fmt.Fprintf(os.Stderr, "vole runner: cannot renew the lease for %s: %s\n", job.ID, rep.Error) | |
|
c5902451
Keenan Nemetz
16d ago
|
1358 | return |
|
73755a56
Keenan Nemetz
9d ago
|
1359 | } else if !rep.OK { |
| 1360 | // The lease expired while the last step was running. The queue may not | |
| 1361 | // have given the job to anyone else yet, and this attempt has a real | |
| 1362 | // result. Send it: the server accepts it only while this is still the | |
| 1363 | // attempt the run is on, and refuses it once another runner leases the job. | |
| 1364 | fmt.Fprintf(os.Stderr, "vole runner: the lease for %s expired before the result was reported; sending it anyway\n", job.ID) | |
|
c5902451
Keenan Nemetz
16d ago
|
1365 | } |
| 1366 | ||
|
b312e166
Keenan Nemetz
10d ago
|
1367 | // The record goes to the server on the same channel that holds the lease. If |
| 1368 | // this call is lost after the server stored the record, the retry sends the | |
| 1369 | // same bytes and the server answers ok again; a run record is append-only. | |
| 1370 | rep, err := s.call(runnerRequest{Op: "finish", Run: job.ID, Record: &rec}) | |
|
c5902451
Keenan Nemetz
16d ago
|
1371 | switch { |
| 1372 | case err != nil: | |
|
b312e166
Keenan Nemetz
10d ago
|
1373 | fmt.Fprintf(os.Stderr, "vole runner: connection lost while reporting %s; the result was not stored\n", job.ID) |
| 1374 | return | |
|
c5902451
Keenan Nemetz
16d ago
|
1375 | case rep.Error != "": |
| 1376 | fmt.Fprintf(os.Stderr, "vole runner: %s\n", rep.Error) | |
|
b312e166
Keenan Nemetz
10d ago
|
1377 | return |
|
c5902451
Keenan Nemetz
16d ago
|
1378 | case !rep.OK: |
|
b312e166
Keenan Nemetz
10d ago
|
1379 | fmt.Fprintf(os.Stderr, "vole runner: the lease for %s moved; the result was not stored\n", job.ID) |
| 1380 | return | |
| 1381 | } | |
| 1382 | ||
| 1383 | // The result is stored. A tagged build's release pointers are product metadata | |
| 1384 | // and still live on the branch; attach them only after the record, so a release | |
| 1385 | // cannot exist without the build that produced it. | |
|
70c9ad4e
Keenan Nemetz
1h ago
|
1386 | if len(out.ReleaseArtifacts) > 0 { |
| 1387 | if err := publishReleases(remote, co, job, out.ReleaseArtifacts, log); err != nil { | |
|
b312e166
Keenan Nemetz
10d ago
|
1388 | fmt.Fprintf(os.Stderr, "vole runner: could not attach %d artifact(s) to %s: %v\n", |
|
70c9ad4e
Keenan Nemetz
1h ago
|
1389 | len(out.ReleaseArtifacts), job.Tag, err) |
|
b312e166
Keenan Nemetz
10d ago
|
1390 | fmt.Fprintf(log, "\nrelease: could not attach %d artifact(s) to %s: %v\n", |
|
70c9ad4e
Keenan Nemetz
1h ago
|
1391 | len(out.ReleaseArtifacts), job.Tag, err) |
|
b312e166
Keenan Nemetz
10d ago
|
1392 | } |
|
c5902451
Keenan Nemetz
16d ago
|
1393 | } |
| 1394 | } | |
| 1395 | ||
|
b312e166
Keenan Nemetz
10d ago
|
1396 | // publishReleases attaches a tagged build's release pointers to the metadata |
| 1397 | // branch. The run record is already in the database, so a failure here loses the | |
| 1398 | // release attachment, not the result; the operator can attach it again with | |
| 1399 | // `vole release attach`. | |
|
c5902451
Keenan Nemetz
16d ago
|
1400 | // |
|
b312e166
Keenan Nemetz
10d ago
|
1401 | // The branch is pushed once, and a lost race against somebody else's issue is |
| 1402 | // re-applied on the new tip the same way every other metadata write is. The | |
| 1403 | // runner's trusted cache is used, never the job tree: the workflow may have edited | |
| 1404 | // the job tree's .git, and a push from there could run a hook the workflow wrote. | |
| 1405 | func publishReleases(remote string, co checkout, job *runnerJob, rel []runnerArtifact, log io.Writer) error { | |
|
c5902451
Keenan Nemetz
16d ago
|
1406 | url, err := client.RepoURL(remote, job.Repo) |
| 1407 | if err != nil { | |
| 1408 | return err | |
| 1409 | } | |
|
f594f1a1
Keenan Nemetz
5d ago
|
1410 | // Opening the branch fetches into the trusted cache and the write adds objects to |
| 1411 | // it. Hold the cache's lock so a repack or a deletion in another runner process | |
| 1412 | // cannot move the object store while this reads and writes it. | |
| 1413 | unlock, err := co.lock() | |
| 1414 | if err != nil { | |
| 1415 | return err | |
| 1416 | } | |
| 1417 | defer unlock() | |
|
7da9111e
Keenan Nemetz
15d ago
|
1418 | c, err := client.OpenBranch(co.GitDir, url, strings.TrimPrefix(metadata.Ref, "refs/heads/"), "vole-runner") |
|
c5902451
Keenan Nemetz
16d ago
|
1419 | if err != nil { |
| 1420 | return err | |
| 1421 | } | |
|
b312e166
Keenan Nemetz
10d ago
|
1422 | arts := make([]metadata.Artifact, 0, len(rel)) |
| 1423 | for _, a := range rel { | |
| 1424 | arts = append(arts, metadata.Artifact{Name: a.Name, OID: a.OID, Size: a.Size}) | |
|
c5902451
Keenan Nemetz
16d ago
|
1425 | } |
|
b312e166
Keenan Nemetz
10d ago
|
1426 | message := fmt.Sprintf("release: attach %d artifact(s) to %s from run %s", len(arts), job.Tag, job.ID) |
|
c5902451
Keenan Nemetz
16d ago
|
1427 | write := func() error { |
|
b312e166
Keenan Nemetz
10d ago
|
1428 | return metadata.WriteReleasePointers(c.Repo, metadata.Ref, job.Tag, arts, message, c.Author) |
|
c5902451
Keenan Nemetz
16d ago
|
1429 | } |
| 1430 | if err := write(); err != nil { | |
| 1431 | return err | |
| 1432 | } | |
| 1433 | if err := c.CommitAndPush(false, write); err != nil { | |
| 1434 | return err | |
| 1435 | } | |
|
b312e166
Keenan Nemetz
10d ago
|
1436 | fmt.Fprintf(log, "\nrelease: attached %d artifact(s) to %s\n", len(arts), job.Tag) |
|
c5902451
Keenan Nemetz
16d ago
|
1437 | return nil |
| 1438 | } | |
| 1439 | ||
| 1440 | // checkReleaseJob refuses a workflow that publishes to a release it cannot name. It | |
| 1441 | // runs before the push so the refusal lands in the job's log, where somebody will see | |
| 1442 | // it, rather than as a push error after a successful build. | |
| 1443 | func checkReleaseJob(job *runnerJob) error { | |
| 1444 | if job.Tag == "" { | |
| 1445 | return fmt.Errorf("this job declares release artifacts but ran on a branch; a release " + | |
| 1446 | "is built by a tag push (add on.push.tags and push a tag)") | |
| 1447 | } | |
| 1448 | if !job.TagAnnotated { | |
| 1449 | return fmt.Errorf("%s is a lightweight tag; a release needs an annotated tag with notes "+ | |
| 1450 | "(git tag -a %s -F notes.md)", job.Tag, job.Tag) | |
| 1451 | } | |
| 1452 | return nil | |
| 1453 | } | |
| 1454 | ||
| 1455 | // publishArtifacts expands the job's declared patterns in the checkout and uploads | |
| 1456 | // what they match through the same SSH object transfer git-lfs uses. The bytes go | |
| 1457 | // in before the finish, so the server can refuse a claim it cannot serve. | |
| 1458 | // publishArtifacts uploads every declared output and returns them twice: once as | |
| 1459 | // the run's outputs, once as the subset that belongs to the tag's release. | |
|
6bfc4294
Keenan Nemetz
12d ago
|
1460 | func publishArtifacts(remote, work string, job *runnerJob, log io.Writer) (all, release []runnerArtifact, err error) { |
|
c5902451
Keenan Nemetz
16d ago
|
1461 | if len(job.Artifacts) == 0 { |
| 1462 | return nil, nil, nil | |
| 1463 | } | |
| 1464 | files, err := collectArtifactFiles(work, job.Artifacts) | |
| 1465 | if err != nil { | |
| 1466 | return nil, nil, err | |
| 1467 | } | |
|
6bfc4294
Keenan Nemetz
12d ago
|
1468 | if err := checkArtifactLimits(work, files); err != nil { |
| 1469 | return nil, nil, err | |
| 1470 | } | |
|
c5902451
Keenan Nemetz
16d ago
|
1471 | isRelease := map[string]bool{} |
| 1472 | for _, pat := range job.ReleaseArtifacts { | |
| 1473 | isRelease[pat] = true | |
| 1474 | } | |
| 1475 | url, err := client.RepoURL(remote, job.Repo) | |
| 1476 | if err != nil { | |
| 1477 | return nil, nil, err | |
| 1478 | } | |
| 1479 | tx, err := client.DialTransfer(url, "upload") | |
| 1480 | if err != nil { | |
| 1481 | return nil, nil, fmt.Errorf("connect to publish %d artifact(s): %w", len(files), err) | |
| 1482 | } | |
| 1483 | defer tx.Close() | |
| 1484 | ||
| 1485 | all = make([]runnerArtifact, 0, len(files)) | |
| 1486 | for _, f := range files { | |
| 1487 | ptr, err := tx.Upload(filepath.Join(work, filepath.FromSlash(f.Path))) | |
| 1488 | if err != nil { | |
| 1489 | return nil, nil, fmt.Errorf("upload %s: %w", f.Path, err) | |
| 1490 | } | |
| 1491 | art := runnerArtifact{Name: f.Name, OID: ptr.OID, Size: ptr.Size} | |
| 1492 | fmt.Fprintf(log, "\nartifact %s (%s) sha256:%s\n", f.Name, format.Bytes(ptr.Size), ptr.OID) | |
| 1493 | all = append(all, art) | |
| 1494 | if isRelease[f.Pattern] { | |
| 1495 | release = append(release, art) | |
| 1496 | } | |
| 1497 | } | |
| 1498 | return all, release, nil | |
| 1499 | } | |
| 1500 | ||
| 1501 | // publishSite deploys the job's declared directory as the repository's site, using | |
| 1502 | // the same snapshot code the pages CLI uses. | |
| 1503 | // | |
| 1504 | // The staleness check first: a site is a snapshot, so a run that finishes after a | |
| 1505 | // newer commit has landed would quietly revert the site to an older build. Refusing | |
| 1506 | // is the honest outcome — the log says why, and the job fails instead of publishing | |
| 1507 | // a stale tree that nobody notices for a week. | |
|
6bfc4294
Keenan Nemetz
12d ago
|
1508 | func publishSite(remote string, co checkout, job *runnerJob, log io.Writer) error { |
|
c5902451
Keenan Nemetz
16d ago
|
1509 | if job.Pages == nil || job.Pages.Path == "" { |
| 1510 | return nil | |
| 1511 | } | |
| 1512 | url, err := client.RepoURL(remote, job.Repo) | |
| 1513 | if err != nil { | |
| 1514 | return err | |
| 1515 | } | |
|
f594f1a1
Keenan Nemetz
5d ago
|
1516 | // The snapshot is committed and pushed from the trusted cache, which this runner |
| 1517 | // shares with any other process given the same --dir. Take its lock, as the | |
| 1518 | // release push and the maintenance pass do. | |
| 1519 | unlock, err := co.lock() | |
| 1520 | if err != nil { | |
| 1521 | return err | |
| 1522 | } | |
| 1523 | defer unlock() | |
|
7da9111e
Keenan Nemetz
15d ago
|
1524 | tip, err := client.LSRemote(co.GitDir, url, "refs/heads/"+job.Branch) |
|
c5902451
Keenan Nemetz
16d ago
|
1525 | if err != nil { |
| 1526 | return fmt.Errorf("check %s before deploying the site: %w", job.Branch, err) | |
| 1527 | } | |
| 1528 | switch { | |
| 1529 | case tip == "": | |
| 1530 | return fmt.Errorf("%s no longer exists, so there is no site to deploy to", job.Branch) | |
| 1531 | case tip != job.SHA: | |
| 1532 | return fmt.Errorf("this run built %s but %s is at %s now; the site was not deployed", | |
| 1533 | format.SHA(job.SHA), job.Branch, format.SHA(tip)) | |
| 1534 | } | |
| 1535 | ||
|
7da9111e
Keenan Nemetz
15d ago
|
1536 | c, err := client.OpenBranch(co.GitDir, url, job.Pages.Ref, "vole-runner") |
|
c5902451
Keenan Nemetz
16d ago
|
1537 | if err != nil { |
| 1538 | return err | |
| 1539 | } | |
|
7da9111e
Keenan Nemetz
15d ago
|
1540 | dir := filepath.Join(co.Work, filepath.FromSlash(job.Pages.Path)) |
|
c5902451
Keenan Nemetz
16d ago
|
1541 | msg := fmt.Sprintf("site: %s from run %s (%s)", job.Branch, format.SHA(job.SHA), job.ID) |
| 1542 | if len(msg) > 200 { | |
| 1543 | msg = fmt.Sprintf("site: %s from run %s", job.Branch, job.ID) | |
| 1544 | } | |
| 1545 | diff, files, deployed, err := client.DeploySite(c, dir, msg, false) | |
| 1546 | if err != nil { | |
| 1547 | return err | |
| 1548 | } | |
| 1549 | if deployed { | |
| 1550 | fmt.Fprintf(log, "\nsite: published %d file(s) to %s (%s)\n", files, job.Pages.Ref, diff) | |
| 1551 | } else { | |
| 1552 | fmt.Fprintf(log, "\nsite: %s already matches %s\n", job.Pages.Ref, job.Pages.Path) | |
| 1553 | } | |
| 1554 | return nil | |
| 1555 | } | |
| 1556 | ||
| 1557 | // artifactFile is one matched output. Pattern is the declaration that matched it, | |
| 1558 | // because a pattern can also mean "publish this to the release". | |
| 1559 | type artifactFile struct { | |
| 1560 | Path string // slash-separated, relative to the checkout | |
| 1561 | Name string // the download name: the file's base name | |
| 1562 | Pattern string // the declaration that matched | |
| 1563 | } | |
| 1564 | ||
| 1565 | // collectArtifactFiles expands patterns against a checkout. A pattern that matches | |
| 1566 | // nothing is an error rather than an empty artifact list: a build that stopped | |
| 1567 | // producing the file is the failure a declaration is supposed to catch, and a | |
| 1568 | // silent publish of nothing is how that goes unnoticed for a week. | |
|
6bfc4294
Keenan Nemetz
12d ago
|
1569 | // checkArtifactLimits bounds what one run may publish. The workflow caps the number |
| 1570 | // of declarations; a declaration is a pattern, and a pattern can match a directory | |
| 1571 | // tree, so the bound on what they expand to has to be here. An artifact set is | |
| 1572 | // uploaded into the instance's object store, and a job choosing its size is a job | |
| 1573 | // choosing the instance's disk. | |
| 1574 | func checkArtifactLimits(work string, files []artifactFile) error { | |
| 1575 | if len(files) > MaxRunArtifactFiles { | |
| 1576 | return fmt.Errorf("this run publishes %d artifact files, limit %d; narrow the patterns", | |
| 1577 | len(files), MaxRunArtifactFiles) | |
| 1578 | } | |
| 1579 | var total int64 | |
| 1580 | for _, f := range files { | |
| 1581 | info, err := os.Stat(filepath.Join(work, filepath.FromSlash(f.Path))) | |
| 1582 | if err != nil { | |
| 1583 | continue | |
| 1584 | } | |
| 1585 | total += info.Size() | |
| 1586 | } | |
| 1587 | if total > MaxRunArtifactBytes { | |
| 1588 | return fmt.Errorf("this run publishes %s of artifacts, limit %s; narrow the patterns", | |
| 1589 | format.Bytes(total), format.Bytes(MaxRunArtifactBytes)) | |
| 1590 | } | |
| 1591 | return nil | |
| 1592 | } | |
| 1593 | ||
|
c5902451
Keenan Nemetz
16d ago
|
1594 | func collectArtifactFiles(root string, patterns []string) ([]artifactFile, error) { |
| 1595 | matched := make([]int, len(patterns)) | |
| 1596 | var files []artifactFile | |
| 1597 | byName := map[string]string{} | |
| 1598 | err := filepath.WalkDir(root, func(p string, d os.DirEntry, err error) error { | |
| 1599 | if err != nil { | |
| 1600 | return err | |
| 1601 | } | |
| 1602 | if d.IsDir() { | |
| 1603 | if d.Name() == ".git" { | |
| 1604 | return filepath.SkipDir | |
| 1605 | } | |
| 1606 | return nil | |
| 1607 | } | |
| 1608 | rel, err := filepath.Rel(root, p) | |
| 1609 | if err != nil { | |
| 1610 | return err | |
| 1611 | } | |
| 1612 | rel = filepath.ToSlash(rel) | |
| 1613 | for i, pat := range patterns { | |
| 1614 | if ok, err := path.Match(pat, rel); err != nil || !ok { | |
| 1615 | continue | |
| 1616 | } | |
| 1617 | matched[i]++ | |
| 1618 | info, err := d.Info() | |
| 1619 | if err != nil { | |
| 1620 | return err | |
| 1621 | } | |
| 1622 | if !info.Mode().IsRegular() { | |
| 1623 | return nil // a symlink or device: not an artifact | |
| 1624 | } | |
| 1625 | name := path.Base(rel) | |
| 1626 | if prev, dup := byName[name]; dup { | |
| 1627 | return fmt.Errorf("two files would be published as %q (%s and %s); rename one "+ | |
| 1628 | "or narrow the patterns", name, prev, rel) | |
| 1629 | } | |
| 1630 | byName[name] = rel | |
| 1631 | files = append(files, artifactFile{Path: rel, Name: name, Pattern: pat}) | |
| 1632 | return nil | |
| 1633 | } | |
| 1634 | return nil | |
| 1635 | }) | |
| 1636 | if err != nil { | |
| 1637 | return nil, err | |
| 1638 | } | |
| 1639 | for i, n := range matched { | |
| 1640 | if n == 0 { | |
| 1641 | return nil, fmt.Errorf("artifact pattern %q matched nothing in the checkout", patterns[i]) | |
| 1642 | } | |
| 1643 | } | |
| 1644 | sort.Slice(files, func(i, j int) bool { return files[i].Name < files[j].Name }) | |
| 1645 | return files, nil | |
| 1646 | } | |
| 1647 | ||
| 1648 | // execStep runs one shell step, heartbeating while it works. It returns the exit | |
|
0c14f078
Keenan Nemetz
6d ago
|
1649 | // code, whether the lease was lost, and whether a maintainer asked the run to stop. |
| 1650 | func (s *runnerSession) execStep(runID string, sandbox *container.Container, step runnerStep, limit time.Duration, lease time.Duration, out io.Writer) (int, bool, bool) { | |
|
e7aa0785
Keenan Nemetz
6d ago
|
1651 | // limit is already resolved: the step's own timeout, or what is left of the job's |
| 1652 | // budget. The caller never passes zero. | |
| 1653 | ctx, cancel := context.WithTimeout(context.Background(), limit) | |
|
c5902451
Keenan Nemetz
16d ago
|
1654 | defer cancel() |
| 1655 | ||
|
3b39a84c
Keenan Nemetz
14d ago
|
1656 | cmd := sandbox.Exec(ctx, step.Run) |
|
0c14f078
Keenan Nemetz
6d ago
|
1657 | code, lost, canceled := s.runCommand(ctx, cmd, runID, lease, sandbox.Stop, out) |
| 1658 | if canceled { | |
| 1659 | return code, false, true | |
| 1660 | } | |
|
70b70264
Keenan Nemetz
15d ago
|
1661 | if ctx.Err() == context.DeadlineExceeded { |
| 1662 | // The heartbeat will have extended the lease this far; report the timeout as | |
| 1663 | // a failure rather than leaving the job hanging. | |
|
e7aa0785
Keenan Nemetz
6d ago
|
1664 | // What is left of a budget is rarely a round number; a reader wants the promise, |
| 1665 | // not the microseconds the bookkeeping cost. | |
| 1666 | fmt.Fprintf(out, "\nstep timed out after %s\n", limit.Round(time.Millisecond)) | |
|
0c14f078
Keenan Nemetz
6d ago
|
1667 | return 1, lost, false |
|
7da9111e
Keenan Nemetz
15d ago
|
1668 | } |
|
0c14f078
Keenan Nemetz
6d ago
|
1669 | return code, lost, false |
|
70b70264
Keenan Nemetz
15d ago
|
1670 | } |
| 1671 | ||
| 1672 | // runCommand starts one host-side command and heartbeats while it runs. It is used | |
| 1673 | // for docker exec and for building a custom image; onTimeout is what closes the | |
|
0c14f078
Keenan Nemetz
6d ago
|
1674 | // sandbox when the command runs out of time. A stop request from the server rides a |
| 1675 | // heartbeat, so the command is killed the same way a timeout kills it; the difference | |
| 1676 | // is that the caller reports the partial attempt instead of a failure. | |
| 1677 | func (s *runnerSession) runCommand(ctx context.Context, cmd *exec.Cmd, runID string, lease time.Duration, onTimeout func(), out io.Writer) (code int, lost bool, canceled bool) { | |
|
70b70264
Keenan Nemetz
15d ago
|
1678 | // Own process group: a command that spawns children must not outlive its timeout. |
|
c5902451
Keenan Nemetz
16d ago
|
1679 | cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} |
|
6bfc4294
Keenan Nemetz
12d ago
|
1680 | cmd.Stdout, cmd.Stderr = out, out |
|
c5902451
Keenan Nemetz
16d ago
|
1681 | |
| 1682 | if err := cmd.Start(); err != nil { | |
|
6bfc4294
Keenan Nemetz
12d ago
|
1683 | fmt.Fprintf(out, "cannot start: %v\n", err) |
|
0c14f078
Keenan Nemetz
6d ago
|
1684 | return 1, false, false |
|
c5902451
Keenan Nemetz
16d ago
|
1685 | } |
| 1686 | done := make(chan error, 1) | |
| 1687 | go func() { done <- cmd.Wait() }() | |
| 1688 | ||
| 1689 | interval := lease / 3 | |
| 1690 | if interval < time.Second { | |
| 1691 | interval = time.Second | |
| 1692 | } | |
| 1693 | tick := time.NewTicker(interval) | |
| 1694 | defer tick.Stop() | |
|
4e3759da
Keenan Nemetz
1h ago
|
1695 | // The disk is checked on its own beat, not the heartbeat's: a build that writes |
| 1696 | // gigabytes a second must not get a whole lease interval to do it. | |
| 1697 | disk := time.NewTicker(time.Second) | |
| 1698 | defer disk.Stop() | |
|
c5902451
Keenan Nemetz
16d ago
|
1699 | |
|
0c14f078
Keenan Nemetz
6d ago
|
1700 | stop := func() { |
| 1701 | if onTimeout != nil { | |
| 1702 | onTimeout() | |
| 1703 | } | |
| 1704 | if cmd.Process != nil { | |
| 1705 | _ = syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL) | |
| 1706 | } | |
| 1707 | // Wait for the copy goroutines before the caller closes the log. | |
| 1708 | <-done | |
| 1709 | } | |
| 1710 | ||
|
c5902451
Keenan Nemetz
16d ago
|
1711 | for { |
| 1712 | select { | |
| 1713 | case err := <-done: | |
| 1714 | if err == nil { | |
|
0c14f078
Keenan Nemetz
6d ago
|
1715 | return 0, false, false |
|
c5902451
Keenan Nemetz
16d ago
|
1716 | } |
| 1717 | var ee *exec.ExitError | |
| 1718 | if errors.As(err, &ee) { | |
|
0c14f078
Keenan Nemetz
6d ago
|
1719 | return ee.ExitCode(), false, false |
|
c5902451
Keenan Nemetz
16d ago
|
1720 | } |
|
6bfc4294
Keenan Nemetz
12d ago
|
1721 | fmt.Fprintf(out, "\n%v\n", err) |
|
0c14f078
Keenan Nemetz
6d ago
|
1722 | return 1, false, false |
|
c5902451
Keenan Nemetz
16d ago
|
1723 | case <-ctx.Done(): |
|
0c14f078
Keenan Nemetz
6d ago
|
1724 | stop() |
| 1725 | return 1, false, false | |
|
4e3759da
Keenan Nemetz
1h ago
|
1726 | case <-disk.C: |
| 1727 | free, low, err := diskLow(s.diskRoot, s.diskReserve) | |
| 1728 | if err != nil { | |
| 1729 | // One failed check is worth saying once; a stat on a directory that | |
| 1730 | // vanished would otherwise fill the log with the same line. | |
| 1731 | fmt.Fprintf(out, "\ncannot check the runner's free space: %v\n", err) | |
| 1732 | disk.Stop() | |
| 1733 | continue | |
| 1734 | } | |
| 1735 | if low { | |
| 1736 | fmt.Fprintf(out, "\nthe runner has %s free, below its %s reserve; stopping the job "+ | |
| 1737 | "so the machine does not fill (--disk-reserve)\n", | |
| 1738 | format.Bytes(free), format.Bytes(s.diskReserve)) | |
| 1739 | stop() | |
| 1740 | return 1, false, false | |
| 1741 | } | |
|
c5902451
Keenan Nemetz
16d ago
|
1742 | case <-tick.C: |
| 1743 | rep, err := s.call(runnerRequest{Op: "heartbeat", Run: runID}) | |
| 1744 | if err != nil || !rep.OK { | |
|
0c14f078
Keenan Nemetz
6d ago
|
1745 | return 1, true, false |
| 1746 | } | |
| 1747 | if rep.Cancel != "" { | |
| 1748 | fmt.Fprintf(out, "\nthe run was canceled by %s; stopping\n", rep.Cancel) | |
| 1749 | stop() | |
| 1750 | return 1, false, true | |
|
c5902451
Keenan Nemetz
16d ago
|
1751 | } |
| 1752 | } | |
| 1753 | } | |
| 1754 | } | |
|
4ac42680
Keenan Nemetz
7d ago
|
1755 | |
|
e82bbe7d
Keenan Nemetz
7d ago
|
1756 | // secretValues is what the log has to redact: the values this job was actually |
| 1757 | // given, not the names it asked for. | |
| 1758 | func secretValues(job *runnerJob) []string { | |
|
6e1fca51
Keenan Nemetz
6d ago
|
1759 | values := make([]string, 0, len(job.Secrets)+1) |
|
e82bbe7d
Keenan Nemetz
7d ago
|
1760 | for _, delivered := range job.Secrets { |
| 1761 | values = append(values, delivered.Value) | |
| 1762 | } | |
|
6e1fca51
Keenan Nemetz
6d ago
|
1763 | // The run credential is a secret in the same sense: short-lived and scoped, but a |
| 1764 | // step that cats the key should not leave it in the log the instance keeps. | |
| 1765 | if job.Credential != nil && job.Credential.PrivateKey != "" { | |
| 1766 | values = append(values, job.Credential.PrivateKey) | |
| 1767 | } | |
|
e82bbe7d
Keenan Nemetz
7d ago
|
1768 | return values |
| 1769 | } | |
| 1770 | ||
|
4ac42680
Keenan Nemetz
7d ago
|
1771 | // secretsScript renders a job's delivered values as the file every step sources. |
| 1772 | // The rendering lives in ci/container because that is the package that sources it, | |
| 1773 | // so a local run writes exactly the same file a runner does. | |
| 1774 | func secretsScript(secrets map[string]runnerSecret) ([]byte, error) { | |
| 1775 | values := make(map[string]string, len(secrets)) | |
| 1776 | for name, delivered := range secrets { | |
| 1777 | values[name] = delivered.Value | |
| 1778 | } | |
| 1779 | return container.SecretsEnv(values) | |
| 1780 | } |