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.toml`,
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/// How long anvil parks a claim that finds nothing queued.
51///
52/// This is the other half of runner liveness: an idle runner is not
53/// heartbeating anything, so its claim poll returning and being reissued is
54/// what keeps it visible. Anything watching for a runner to go quiet has to
55/// allow for a full poll window plus the reconnect, which is why the number
56/// lives here rather than beside the endpoint that parks on it. The runner's
57/// own request timeout is set comfortably above it.
58pub const CLAIM_POLL: Duration = Duration::from_secs(55);
59
60/// A MiB cap from config as a byte count: `0` (unlimited) → [`u64::MAX`].
61///
62/// Here rather than on either side because both apply the same caps — the
63/// runner while downloading artifacts, anvil while enforcing the per-repo
64/// quota — and they must agree on what zero means.
65pub fn mb_cap(mb: i64) -> u64 {
66 if mb <= 0 {
67 u64::MAX
68 } else {
69 (mb as u64).saturating_mul(1024 * 1024)
70 }
71}
72
73/// What a runner says about itself when it asks for work.
74#[derive(Clone, Debug, Deserialize, Serialize)]
75pub struct RunnerInfo {
76 /// Operator-chosen, for logs and (later) scheduling. Not a credential.
77 pub name: String,
78 /// The daemon's native platform, e.g. `linux/arm64`. Advertised so the
79 /// dispatcher can eventually prefer a runner that needs no emulation.
80 pub platform: String,
81 pub version: String,
82}
83
84/// One artifact to collect, flattened from `anvil_core::ci::ArtifactSpec`.
85///
86/// The extractor commands themselves are not here — the server has already
87/// baked them into the script. All the runner needs to know is whether to
88/// expect output for this artifact under [`META_DIR`].
89#[derive(Clone, Debug, Deserialize, Serialize)]
90pub struct ArtifactSpec {
91 pub name: String,
92 /// Relative to [`WORKDIR`].
93 pub path: String,
94 pub browse: bool,
95 pub has_meta: bool,
96}
97
98/// Resource bounds and isolation knobs, resolved from `[ci]` server-side.
99///
100/// The runner obeys these; it does not consult a config file of its own for
101/// them. An operator tightening `ci.memory_mb` should not have to redeploy
102/// every runner.
103#[derive(Clone, Debug, Deserialize, Serialize)]
104pub struct Sandbox {
105 pub memory_mb: i64,
106 pub cpus: f64,
107 pub pids_limit: i64,
108 /// 0 disables the wall-clock timeout.
109 pub timeout_secs: u64,
110 pub network: bool,
111 /// Empty keeps the image's default user.
112 pub run_as: String,
113 /// Per-artifact and per-run artifact caps, in MiB; 0 is unlimited.
114 ///
115 /// The per-run budget is charged the *stored* size, which the runner learns
116 /// from the upload response rather than computing — a `browse` directory is
117 /// extracted and any other directory recompressed, so the tar it sent is
118 /// not what lands on disk. Same accounting as the in-process runner did.
119 pub artifact_max_mb: i64,
120 pub artifact_run_max_mb: i64,
121}
122
123/// A claimed job, everything needed to run it.
124///
125/// The checkout is fetched separately (`GET /-/runner/jobs/{run_id}/checkout.tar`)
126/// rather than carried here: base64 in a JSON body inflates a large tree by a
127/// third for no benefit. Secrets *are* carried here, over TLS, so they never
128/// sit behind a separately-fetchable URL.
129#[derive(Clone, Debug, Deserialize, Serialize)]
130pub struct JobSpec {
131 pub run_id: i64,
132 /// Already resolved through `ci.default_image` and checked against
133 /// `ci.allowed_images`.
134 pub image: String,
135 /// `linux/amd64`, `linux/arm64`, … `None` takes the daemon's native
136 /// platform, which is the current behaviour everywhere.
137 pub platform: Option<String>,
138 /// The full `sh -c` program: steps, then the meta-extractor trailer.
139 pub script: String,
140 /// Secret name/value pairs, injected as environment variables.
141 pub env: Vec<(String, String)>,
142 pub artifacts: Vec<ArtifactSpec>,
143 pub sandbox: Sandbox,
144}
145
146/// What storing one artifact produced. Returned by the upload endpoint, and by
147/// the in-process sink, so the runner can charge the run budget the size that
148/// actually landed on disk without knowing how it was laid out.
149#[derive(Clone, Copy, Debug, Deserialize, Serialize)]
150pub struct Stored {
151 pub size: i64,
152 pub is_dir: bool,
153}
154
155/// One artifact the runner pulled out of the container and uploaded. The bytes
156/// went ahead of this; here is the row to record for them.
157#[derive(Clone, Debug, Deserialize, Serialize)]
158pub struct CollectedArtifact {
159 pub name: String,
160 pub size: i64,
161 pub is_dir: bool,
162 pub browse: bool,
163 /// Extractor output for this artifact: a JSON object of key → value.
164 pub meta: String,
165}
166
167/// The outcome of a job, posted once when it finishes.
168///
169/// The whole log arrives in one write, which is exactly what the in-process
170/// runner did — `append_log` was only ever called when the run ended. Live
171/// logs are a follow-up, not a regression.
172#[derive(Clone, Debug, Deserialize, Serialize)]
173pub struct JobResult {
174 pub exit_code: i64,
175 pub log: String,
176 pub artifacts: Vec<CollectedArtifact>,
177 /// The runner could not run the job at all (image pull failed, daemon
178 /// unreachable, timeout). Distinct from a job that ran and exited
179 /// non-zero: this maps to `status::ERROR`, that to `status::FAILURE`.
180 pub runner_error: Option<String>,
181}