anvilsign in

collin/anvil

1//! The wire format between anvil and a job runner (see
2//! `docs/remote-runners.md`).
3//!
4//! Its own crate, depending on nothing but serde, so the runner binary does not
5//! link `anvil-core` (and therefore toasty, SQLite, gix and the rest of the
6//! forge) just to learn the shape of a job.
7//!
8//! The split it encodes: anvil resolves the repo, parses `.anvil/ci.yml`,
9//! checks the image allowlist, opens the secret vault and assembles the shell
10//! script. A runner receives an image, a script, a tar and some limits, and
11//! never parses a pipeline. That keeps this format stable as the pipeline
12//! schema grows.
13
14use std::time::Duration;
15
16use serde::{
17 Deserialize,
18 Serialize,
19};
20
21/// Working directory inside the job container, and the root the checkout tar
22/// extracts to. Protocol, not preference: artifact paths are resolved relative
23/// to it on the runner, and `build_tar` roots its entries at it on the server.
24pub const WORKDIR: &str = "/workspace";
25
26/// In-container directory where meta-extractor output lands, one file per
27/// `<artifact>/<key>`. The server writes the redirections into the script; the
28/// runner downloads the directory afterwards. File-per-value sidesteps
29/// quoting and JSON-escaping in shell entirely.
30pub const META_DIR: &str = "/tmp/anvil-meta";
31
32/// Cap on the meta-extractor tar (the values are short strings).
33pub const META_TAR_CAP: u64 = 1024 * 1024;
34
35/// Per-value cap on extractor output, in bytes (after trimming).
36pub const META_VALUE_CAP: usize = 1024;
37
38/// How long a claim survives without a heartbeat before anvil requeues the run.
39///
40/// Generous relative to [`HEARTBEAT_INTERVAL`] so a slow network or a busy
41/// runner does not lose a job that is running fine. It bounds how long a dead
42/// runner strands a run, not how long a job may take — a live runner holds its
43/// lease across a 30-minute build by heartbeating through it.
44pub const LEASE_TTL: Duration = Duration::from_secs(120);
45
46/// How often a runner heartbeats while a job runs. Here rather than in the
47/// runner so both halves of the pair stay in view of each other.
48pub const HEARTBEAT_INTERVAL: Duration = Duration::from_secs(30);
49
50/// A MiB cap from config as a byte count: `0` (unlimited) → [`u64::MAX`].
51///
52/// Here rather than on either side because both apply the same caps — the
53/// runner while downloading artifacts, anvil while enforcing the per-repo
54/// quota — and they must agree on what zero means.
55pub fn mb_cap(mb: i64) -> u64 {
56 if mb <= 0 {
57 u64::MAX
58 } else {
59 (mb as u64).saturating_mul(1024 * 1024)
60 }
61}
62
63/// What a runner says about itself when it asks for work.
64#[derive(Clone, Debug, Serialize, Deserialize)]
65pub struct RunnerInfo {
66 /// Operator-chosen, for logs and (later) scheduling. Not a credential.
67 pub name: String,
68 /// The daemon's native platform, e.g. `linux/arm64`. Advertised so the
69 /// dispatcher can eventually prefer a runner that needs no emulation.
70 pub platform: String,
71 pub version: String,
72}
73
74/// One artifact to collect, flattened from `anvil_core::ci::ArtifactSpec`.
75///
76/// The extractor commands themselves are not here — the server has already
77/// baked them into the script. All the runner needs to know is whether to
78/// expect output for this artifact under [`META_DIR`].
79#[derive(Clone, Debug, Serialize, Deserialize)]
80pub struct ArtifactSpec {
81 pub name: String,
82 /// Relative to [`WORKDIR`].
83 pub path: String,
84 pub browse: bool,
85 pub has_meta: bool,
86}
87
88/// Resource bounds and isolation knobs, resolved from `[ci]` server-side.
89///
90/// The runner obeys these; it does not consult a config file of its own for
91/// them. An operator tightening `ci.memory_mb` should not have to redeploy
92/// every runner.
93#[derive(Clone, Debug, Serialize, Deserialize)]
94pub struct Sandbox {
95 pub memory_mb: i64,
96 pub cpus: f64,
97 pub pids_limit: i64,
98 /// 0 disables the wall-clock timeout.
99 pub timeout_secs: u64,
100 pub network: bool,
101 /// Empty keeps the image's default user.
102 pub run_as: String,
103 /// Per-artifact and per-run artifact caps, in MiB; 0 is unlimited.
104 ///
105 /// The per-run budget is charged the *stored* size, which the runner learns
106 /// from the upload response rather than computing — a `browse` directory is
107 /// extracted and any other directory recompressed, so the tar it sent is
108 /// not what lands on disk. Same accounting as the in-process runner did.
109 pub artifact_max_mb: i64,
110 pub artifact_run_max_mb: i64,
111}
112
113/// A claimed job, everything needed to run it.
114///
115/// The checkout is fetched separately (`GET /-/runner/jobs/{run_id}/checkout.tar`)
116/// rather than carried here: base64 in a JSON body inflates a large tree by a
117/// third for no benefit. Secrets *are* carried here, over TLS, so they never
118/// sit behind a separately-fetchable URL.
119#[derive(Clone, Debug, Serialize, Deserialize)]
120pub struct JobSpec {
121 pub run_id: i64,
122 /// Already resolved through `ci.default_image` and checked against
123 /// `ci.allowed_images`.
124 pub image: String,
125 /// `linux/amd64`, `linux/arm64`, … `None` takes the daemon's native
126 /// platform, which is the current behaviour everywhere.
127 pub platform: Option<String>,
128 /// The full `sh -c` program: steps, then the meta-extractor trailer.
129 pub script: String,
130 /// Secret name/value pairs, injected as environment variables.
131 pub env: Vec<(String, String)>,
132 pub artifacts: Vec<ArtifactSpec>,
133 pub sandbox: Sandbox,
134}
135
136/// What storing one artifact produced. Returned by the upload endpoint, and by
137/// the in-process sink, so the runner can charge the run budget the size that
138/// actually landed on disk without knowing how it was laid out.
139#[derive(Clone, Copy, Debug, Serialize, Deserialize)]
140pub struct Stored {
141 pub size: i64,
142 pub is_dir: bool,
143}
144
145/// One artifact the runner pulled out of the container and uploaded. The bytes
146/// went ahead of this; here is the row to record for them.
147#[derive(Clone, Debug, Serialize, Deserialize)]
148pub struct CollectedArtifact {
149 pub name: String,
150 pub size: i64,
151 pub is_dir: bool,
152 pub browse: bool,
153 /// Extractor output for this artifact: a JSON object of key → value.
154 pub meta: String,
155}
156
157/// The outcome of a job, posted once when it finishes.
158///
159/// The whole log arrives in one write, which is exactly what the in-process
160/// runner did — `append_log` was only ever called when the run ended. Live
161/// logs are a follow-up, not a regression.
162#[derive(Clone, Debug, Serialize, Deserialize)]
163pub struct JobResult {
164 pub exit_code: i64,
165 pub log: String,
166 pub artifacts: Vec<CollectedArtifact>,
167 /// The runner could not run the job at all (image pull failed, daemon
168 /// unreachable, timeout). Distinct from a job that ran and exited
169 /// non-zero: this maps to `status::ERROR`, that to `status::FAILURE`.
170 pub runner_error: Option<String>,
171}