anvilsign in

collin/anvil · b3adba61

Refactor the CI executor onto a JobSpec seam

Collin Richards · 2026-08-24 07:51 UTC · b3adba61071655540a16aaeb6df47ca1d70bdba0 · parent ddc1cc10 · browse files

modifiedCargo.lock+9 −0
⋯ 140 unchanged lines
141141 "anvil-core",
142142 "anvil-docker",
143143 "anvil-git",
144+ "anvil-job",
145+ "async-trait",
144146 "bollard",
145147 "flate2",
146148 "futures-util",
⋯ 83 unchanged lines
230232 ]
231233
232234 [[package]]
235+name = "anvil-job"
236+version = "0.0.0"
237+dependencies = [
238+ "serde",
239+]
240+
241+[[package]]
233242 name = "anvil-ssh"
234243 version = "0.0.0"
235244 dependencies = [
⋯ 5556 unchanged lines
modifiedCargo.toml+1 −0
⋯ 16 unchanged lines
1717 anvil-core = { path = "crates/anvil-core" }
1818 anvil-ci = { path = "crates/anvil-ci" }
1919 anvil-docker = { path = "crates/anvil-docker" }
20+anvil-job = { path = "crates/anvil-job" }
2021 anvil-agent = { path = "crates/anvil-agent" }
2122 anvil-git = { path = "crates/anvil-git" }
2223 anvil-web = { path = "crates/anvil-web" }
⋯ 83 unchanged lines
modifiedcrates/anvil-agent/src/supervisor.rs+3 −1
⋯ 52 unchanged lines
5353 pub async fn launch(app: App, session: AgentSession, repo_path: PathBuf) -> Result<(), String> {
5454 let cfg = &app.config.agent;
5555 let docker = anvil_docker::connect()?;
56- anvil_docker::ensure_image(&docker, &cfg.image).await?;
56+ // No platform: a session runs on whatever the daemon is, and there is no
57+ // shipped artifact whose architecture it would have to match.
58+ anvil_docker::ensure_image(&docker, &cfg.image, "").await?;
5759
5860 // The user secrets (secrets::UserSecret) this session opted into at
5961 // start time — an empty choice is the normal case, not an error. Resolved
⋯ 400 unchanged lines
modifiedcrates/anvil-ci/Cargo.toml+2 −0
⋯ 9 unchanged lines
1010 [dependencies]
1111 anvil-core.workspace = true
1212 anvil-docker.workspace = true
13+anvil-job.workspace = true
1314 anvil-git.workspace = true
15+async-trait.workspace = true
1416 bollard.workspace = true
1517 flate2.workspace = true
1618 futures-util.workspace = true
⋯ 8 unchanged lines
modifiedcrates/anvil-ci/src/lib.rs+200 −122
⋯ 23 unchanged lines
2424 App,
2525 ci::{
2626 self,
27- ArtifactSpec,
2827 Pipeline,
2928 },
3029 config::CiConfig,
⋯ 5 unchanged lines
3635 self,
3736 TreeFile,
3837 };
38+use anvil_job::{
39+ ArtifactSpec,
40+ CollectedArtifact,
41+ JobSpec,
42+ META_DIR,
43+ META_TAR_CAP,
44+ META_VALUE_CAP,
45+ Sandbox,
46+ Stored,
47+ WORKDIR,
48+};
3949 use bollard::{
4050 Docker,
4151 container::{
⋯ 11 unchanged lines
5363 use futures_util::StreamExt;
5464 use tokio::sync::mpsc::UnboundedReceiver;
5565
56-const WORKDIR: &str = "/workspace";
57-
58-/// In-container directory where meta-extractor outputs land, one file per
59-/// `<artifact>/<key>`. Downloaded as a tar after the run; file-per-value
60-/// sidesteps quoting/JSON-escaping in shell entirely.
61-const META_DIR: &str = "/tmp/anvil-meta";
62-
63-/// Cap on the meta-extractor tar (the values are short strings).
64-const META_TAR_CAP: u64 = 1024 * 1024;
66+/// Turn a parsed pipeline into the job a runner receives.
67+///
68+/// The server's half of the split (see `docs/remote-runners.md`): image
69+/// resolution, the allowlist check and script assembly all happen here. A
70+/// runner therefore never parses `.anvil/ci.yml`, and cannot widen what it was
71+/// permitted to run by reinterpreting one.
72+fn build_job(
73+ run_id: i64,
74+ pipeline: &Pipeline,
75+ cfg: &CiConfig,
76+ env: &[(String, String)],
77+) -> Result<JobSpec, String> {
78+ // What the pipeline asked for, or the shared runner image when it omitted
79+ // `image:` entirely.
80+ let image = cfg.resolve_image(&pipeline.image);
81+ if !cfg.image_allowed(image) {
82+ return Err(format!(
83+ "image {image} is not permitted by ci.allowed_images"
84+ ));
85+ }
86+ Ok(JobSpec {
87+ run_id,
88+ image: image.to_string(),
89+ // No source for this yet: the pipeline schema has no `platform:` key,
90+ // so every job takes the runner daemon's native architecture, exactly
91+ // as before. Honoured end to end the moment one is set.
92+ platform: None,
93+ script: build_script(pipeline),
94+ env: env.to_vec(),
95+ artifacts: pipeline
96+ .artifacts
97+ .iter()
98+ .map(|a| ArtifactSpec {
99+ name: a.name.clone(),
100+ path: a.path.clone(),
101+ browse: a.browse,
102+ // The extractor commands themselves are already in the script;
103+ // the runner only needs to know whether to look for output.
104+ has_meta: !a.meta.is_empty(),
105+ })
106+ .collect(),
107+ sandbox: Sandbox {
108+ memory_mb: cfg.memory_mb,
109+ cpus: cfg.cpus,
110+ pids_limit: cfg.pids_limit,
111+ timeout_secs: cfg.timeout_secs,
112+ network: cfg.network,
113+ run_as: cfg.run_as.clone(),
114+ artifact_max_mb: cfg.artifact_max_mb,
115+ artifact_run_max_mb: cfg.artifact_run_max_mb,
116+ },
117+ })
118+}
65119
66-/// Per-value cap on extractor output, in bytes (after trimming).
67-const META_VALUE_CAP: usize = 1024;
120+/// Assemble the single `sh -c` program a job runs.
121+///
122+/// The steps run in a subshell so the meta-extractor trailer still runs (and
123+/// the original exit code is preserved) when a step fails — failure artifacts
124+/// like test reports are the ones that matter most.
125+fn build_script(pipeline: &Pipeline) -> String {
126+ let mut script = String::from("(\nset -e\n");
127+ for step in &pipeline.steps {
128+ script.push_str("printf '\\n=== %s ===\\n' ");
129+ script.push_str(&single_quote(step.label()));
130+ script.push('\n');
131+ script.push_str(&step.run);
132+ script.push('\n');
133+ }
134+ script.push_str(")\nanvil_rc=$?\n");
135+ for a in &pipeline.artifacts {
136+ if a.meta.is_empty() {
137+ continue;
138+ }
139+ // Names and keys are parse-time validated to [A-Za-z0-9._-]+, so they
140+ // interpolate into the script safely.
141+ script.push_str(&format!("mkdir -p {META_DIR}/{}\n", a.name));
142+ for (key, cmd) in &a.meta {
143+ script.push_str(&format!(
144+ "{{\n{cmd}\n}} > {META_DIR}/{}/{key} 2>/dev/null || :\n",
145+ a.name
146+ ));
147+ }
148+ }
149+ script.push_str("exit $anvil_rc\n");
150+ script
151+}
68152
69153 /// Run the CI worker loop: recover interrupted runs, drain the queue, then
70154 /// process run ids as they arrive on `rx`. Runs one job at a time.
⋯ 101 unchanged lines
172256 ));
173257 }
174258
175- let (status, collected) =
176- match execute(&pipeline, tar, &mut log, &app.config.ci, &scratch, &env).await {
177- Ok((0, collected)) => (ci::status::SUCCESS, collected),
178- Ok((code, collected)) => {
179- log.push_str(&format!("\n[exited with status {code}]\n"));
180- (ci::status::FAILURE, collected)
181- }
182- Err(e) => {
183- log.push_str(&format!("\n[runner error] {e}\n"));
184- (ci::status::ERROR, Vec::new())
185- }
186- };
259+ let mut sink = ScratchSink { scratch: &scratch };
260+ let outcome = match build_job(run_id, &pipeline, &app.config.ci, &env) {
261+ Ok(job) => execute(&job, tar, &mut log, &mut sink).await,
262+ Err(e) => Err(e),
263+ };
264+ let (status, collected) = match outcome {
265+ Ok((0, collected)) => (ci::status::SUCCESS, collected),
266+ Ok((code, collected)) => {
267+ log.push_str(&format!("\n[exited with status {code}]\n"));
268+ (ci::status::FAILURE, collected)
269+ }
270+ Err(e) => {
271+ log.push_str(&format!("\n[runner error] {e}\n"));
272+ (ci::status::ERROR, Vec::new())
273+ }
274+ };
187275
188276 // Swap the collected set into place, replacing any earlier run's
189277 // artifacts for this commit, then record the rows.
⋯ 159 unchanged lines
349437 }
350438 }
351439
352-/// One artifact collected from the job container, already written under the
353-/// scratch directory; `process` swaps it into the commit's directory.
354-struct Collected {
355- name: String,
356- size: i64,
357- is_dir: bool,
358- browse: bool,
359- /// JSON object of extractor key → output.
360- meta: String,
440+/// Where a collected artifact's bytes go.
441+///
442+/// Exists so the download loop can hand each tar off and drop it rather than
443+/// buffering every artifact — `ci.artifact_run_max_mb` defaults to 512, which
444+/// is not an amount to hold in RAM on the host anvil runs on. The in-process
445+/// implementation writes to the scratch directory; a remote runner's uploads
446+/// it. Returning [`Stored`] rather than `()` is what lets the caller charge
447+/// the run budget the size that actually landed.
448+#[async_trait::async_trait]
449+pub trait ArtifactSink: Send {
450+ async fn put(&mut self, spec: &ArtifactSpec, tar: &[u8]) -> Result<Stored, String>;
451+}
452+
453+/// The in-process sink: straight into the run's scratch directory, which
454+/// `process` then renames into the commit's artifact directory.
455+struct ScratchSink<'a> {
456+ scratch: &'a Path,
457+}
458+
459+#[async_trait::async_trait]
460+impl ArtifactSink for ScratchSink<'_> {
461+ async fn put(&mut self, spec: &ArtifactSpec, tar: &[u8]) -> Result<Stored, String> {
462+ // Created lazily rather than up front: a run whose artifacts all fail
463+ // to download should leave no empty scratch directory behind.
464+ std::fs::create_dir_all(self.scratch).map_err(|e| format!("creating scratch dir: {e}"))?;
465+ let (size, is_dir) = store_artifact(spec, tar, self.scratch)?;
466+ Ok(Stored { size, is_dir })
467+ }
361468 }
362469
363-/// Execute the pipeline in a sandboxed container, streaming output into `log`.
364-/// Returns the container's exit code and any artifacts collected into
365-/// `scratch` (empty on timeout — the container is already gone).
366-///
367-/// The job container never sees the Docker socket and gets no mounts of any
368-/// kind (the checkout is *uploaded*, not bind-mounted; artifacts are
369-/// *downloaded* out the same way). All capabilities are dropped and
370-/// `no-new-privileges` is set unconditionally; pids/memory/cpu caps, the
371-/// wall-clock timeout, network access, the container user, and the image
372-/// allowlist come from `cfg`.
373470 /// Replace every secret value in `log` with `***`.
374471 ///
375472 /// Only values worth hiding: very short ones (a one-character secret) would
⋯ 6 unchanged lines
382479 }
383480 }
384481
385-async fn execute(
386- pipeline: &Pipeline,
482+/// Execute a job in a sandboxed container, streaming output into `log`.
483+/// Returns the exit code and whatever artifacts `sink` accepted (none on
484+/// timeout — the container is already gone).
485+///
486+/// The job container never sees the Docker socket and gets no mounts of any
487+/// kind (the checkout is *uploaded*, not bind-mounted; artifacts are
488+/// *downloaded* out the same way). All capabilities are dropped and
489+/// `no-new-privileges` is set unconditionally; pids/memory/cpu caps, the
490+/// wall-clock timeout, network access and the container user come from
491+/// `spec.sandbox`. The image allowlist was applied when the spec was built —
492+/// a runner receiving a spec does not get to widen it.
493+pub async fn execute(
494+ spec: &JobSpec,
387495 tar: Vec<u8>,
388496 log: &mut String,
389- cfg: &CiConfig,
390- scratch: &Path,
391- env: &[(String, String)],
392-) -> Result<(i64, Vec<Collected>), String> {
393- // What the pipeline asked for, or the shared runner image when it omitted
394- // `image:` entirely.
395- let image = cfg.resolve_image(&pipeline.image);
396- if !cfg.image_allowed(image) {
397- return Err(format!(
398- "image {image} is not permitted by ci.allowed_images"
399- ));
400- }
497+ sink: &mut dyn ArtifactSink,
498+) -> Result<(i64, Vec<CollectedArtifact>), String> {
499+ let platform = spec.platform.clone().unwrap_or_default();
401500 let docker = anvil_docker::connect()?;
402- anvil_docker::ensure_image(&docker, image).await?;
403-
404- // Build a single `set -e` script from the steps. The steps run in a
405- // subshell so the meta-extractor trailer still runs (and the original
406- // exit code is preserved) when a step fails — failure artifacts like test
407- // reports are the ones that matter most.
408- let mut script = String::from("(\nset -e\n");
409- for step in &pipeline.steps {
410- script.push_str("printf '\\n=== %s ===\\n' ");
411- script.push_str(&single_quote(step.label()));
412- script.push('\n');
413- script.push_str(&step.run);
414- script.push('\n');
415- }
416- script.push_str(")\nanvil_rc=$?\n");
417- for a in &pipeline.artifacts {
418- if a.meta.is_empty() {
419- continue;
420- }
421- // Names and keys are parse-time validated to [A-Za-z0-9._-]+, so they
422- // interpolate into the script safely.
423- script.push_str(&format!("mkdir -p {META_DIR}/{}\n", a.name));
424- for (key, cmd) in &a.meta {
425- script.push_str(&format!(
426- "{{\n{cmd}\n}} > {META_DIR}/{}/{key} 2>/dev/null || :\n",
427- a.name
428- ));
429- }
430- }
431- script.push_str("exit $anvil_rc\n");
501+ anvil_docker::ensure_image(&docker, &spec.image, &platform).await?;
502+ let sb = &spec.sandbox;
432503
433504 // The sandbox. Limits of 0 mean "unlimited" and omit the corresponding cap.
434505 let host_config = HostConfig {
435506 cap_drop: Some(vec!["ALL".to_string()]),
436507 security_opt: Some(vec!["no-new-privileges:true".to_string()]),
437- pids_limit: (cfg.pids_limit > 0).then_some(cfg.pids_limit),
438- memory: (cfg.memory_mb > 0).then(|| cfg.memory_mb * 1024 * 1024),
439- memory_swap: (cfg.memory_mb > 0).then(|| cfg.memory_mb * 1024 * 1024),
440- nano_cpus: (cfg.cpus > 0.0).then_some((cfg.cpus * 1e9) as i64),
441- network_mode: (!cfg.network).then(|| "none".to_string()),
508+ pids_limit: (sb.pids_limit > 0).then_some(sb.pids_limit),
509+ memory: (sb.memory_mb > 0).then(|| sb.memory_mb * 1024 * 1024),
510+ memory_swap: (sb.memory_mb > 0).then(|| sb.memory_mb * 1024 * 1024),
511+ nano_cpus: (sb.cpus > 0.0).then_some((sb.cpus * 1e9) as i64),
512+ network_mode: (!sb.network).then(|| "none".to_string()),
442513 ..Default::default()
443514 };
444515 let config = Config {
445- image: Some(image.to_string()),
446- cmd: Some(vec!["sh".to_string(), "-c".to_string(), script]),
447- env: (!env.is_empty()).then(|| env.iter().map(|(k, v)| format!("{k}={v}")).collect()),
516+ image: Some(spec.image.clone()),
517+ cmd: Some(vec![
518+ "sh".to_string(),
519+ "-c".to_string(),
520+ spec.script.clone(),
521+ ]),
522+ env: (!spec.env.is_empty())
523+ .then(|| spec.env.iter().map(|(k, v)| format!("{k}={v}")).collect()),
448524 working_dir: Some(WORKDIR.to_string()),
449- user: (!cfg.run_as.is_empty()).then(|| cfg.run_as.clone()),
525+ user: (!sb.run_as.is_empty()).then(|| sb.run_as.clone()),
450526 host_config: Some(host_config),
451527 ..Default::default()
452528 };
529+ // An explicit platform needs the options struct; without one, pass None so
530+ // the daemon picks its native architecture exactly as before.
531+ let opts = spec.platform.as_ref().map(|p| CreateContainerOptions {
532+ name: String::new(),
533+ platform: Some(p.clone()),
534+ });
453535 let created = docker
454- .create_container(None::<CreateContainerOptions<String>>, config)
536+ .create_container(opts, config)
455537 .await
456538 .map_err(|e| format!("create container: {e}"))?;
457539 let id = created.id;
⋯ 51 unchanged lines
509591 }
510592 Ok(code)
511593 };
512- let result = match cfg.timeout_secs {
594+ let result = match sb.timeout_secs {
513595 0 => run.await,
514596 secs => tokio::time::timeout(std::time::Duration::from_secs(secs), run)
515597 .await
⋯ 2 unchanged lines
518600
519601 // Artifacts come out of the (now stopped) container before it is removed.
520602 let collected = match &result {
521- Ok(_) if !pipeline.artifacts.is_empty() => {
522- collect_artifacts(&docker, &id, pipeline, cfg, scratch, log).await
603+ Ok(_) if !spec.artifacts.is_empty() => {
604+ collect_artifacts(&docker, &id, spec, log, sink).await
523605 }
524606 _ => Vec::new(),
525607 };
⋯ 11 unchanged lines
537619 result.map(|code| (code, collected))
538620 }
539621
540-/// Collect the pipeline's declared artifacts from the stopped container into
541-/// `scratch`. Failures are per-artifact: each is logged and skipped, never
542-/// failing the run.
622+/// Collect the job's declared artifacts from the stopped container, handing
623+/// each tar to `sink` as it is downloaded. Failures are per-artifact: each is
624+/// logged and skipped, never failing the run.
625+///
626+/// One artifact is in memory at a time by construction — the tar is dropped
627+/// once the sink has taken it — which is what keeps `artifact_run_max_mb`
628+/// (512 MiB by default) a disk budget rather than a memory one.
543629 async fn collect_artifacts(
544630 docker: &Docker,
545631 id: &str,
546- pipeline: &Pipeline,
547- cfg: &CiConfig,
548- scratch: &Path,
632+ job: &JobSpec,
549633 log: &mut String,
550-) -> Vec<Collected> {
551- if let Err(e) = std::fs::create_dir_all(scratch) {
552- log.push_str(&format!(
553- "\n[artifacts: creating scratch dir failed: {e}]\n"
554- ));
555- return Vec::new();
556- }
557-
634+ sink: &mut dyn ArtifactSink,
635+) -> Vec<CollectedArtifact> {
558636 // Extractor outputs first: artifact name → key → value.
559637 let mut metas: BTreeMap<String, BTreeMap<String, String>> = BTreeMap::new();
560- if pipeline.artifacts.iter().any(|a| !a.meta.is_empty()) {
638+ if job.artifacts.iter().any(|a| a.has_meta) {
561639 match download_tar(docker, id, META_DIR, META_TAR_CAP).await {
562640 Ok(Some(bytes)) => metas = parse_meta_tar(&bytes),
563641 Ok(None) => log.push_str("\n[artifacts: extractor output exceeded its cap]\n"),
⋯ 1 unchanged line
565643 }
566644 }
567645
568- let per_artifact_cap = mb_cap(cfg.artifact_max_mb);
569- let mut run_budget = mb_cap(cfg.artifact_run_max_mb);
646+ let per_artifact_cap = mb_cap(job.sandbox.artifact_max_mb);
647+ let mut run_budget = mb_cap(job.sandbox.artifact_run_max_mb);
570648 let mut collected = Vec::new();
571- for spec in &pipeline.artifacts {
649+ for spec in &job.artifacts {
572650 let cap = per_artifact_cap.min(run_budget);
573651 let note = |log: &mut String, what: &str| {
574652 log.push_str(&format!("\n[artifact {}: {what}]\n", spec.name));
⋯ 9 unchanged lines
584662 continue;
585663 }
586664 };
587- match store_artifact(spec, &bytes, scratch) {
588- Ok((size, is_dir)) => {
665+ match sink.put(spec, &bytes).await {
666+ Ok(Stored { size, is_dir }) => {
589667 run_budget = run_budget.saturating_sub(size as u64);
590668 let meta = metas.get(&spec.name).cloned().unwrap_or_default();
591- collected.push(Collected {
669+ collected.push(CollectedArtifact {
592670 name: spec.name.clone(),
593671 size,
594672 is_dir,
⋯ 207 unchanged lines
802880 name: name.into(),
803881 path: path.into(),
804882 browse,
805- meta: Default::default(),
883+ has_meta: false,
806884 }
807885 }
808886
⋯ 65 unchanged lines
modifiedcrates/anvil-docker/src/lib.rs+10 −2
⋯ 31 unchanged lines
3232 /// own runner image is built by `deploy/runner/build.sh` straight into the
3333 /// host's image store and exists in no registry, so an unconditional pull —
3434 /// which is what this used to be — fails for the one image most jobs now use.
35-pub async fn ensure_image(docker: &Docker, image: &str) -> Result<(), String> {
35+/// `platform` is `os[/arch[/variant]]`, or empty for the daemon's native one.
36+/// It matters on a runner whose architecture differs from the deploy target's
37+/// — an M-series Mac pulls arm64 by default, and a job that means to test what
38+/// it ships has to ask for `linux/amd64` explicitly.
39+pub async fn ensure_image(docker: &Docker, image: &str, platform: &str) -> Result<(), String> {
3640 // Split name:tag so we don't accidentally pull every tag. A ':' that has a
3741 // '/' after it is a registry port, not a tag.
3842 let (from_image, tag) = match image.rsplit_once(':') {
⋯ 5 unchanged lines
4448 Some(CreateImageOptions {
4549 from_image,
4650 tag,
51+ platform: platform.to_string(),
4752 ..Default::default()
4853 }),
4954 None,
⋯ 12 unchanged lines
6267 };
6368
6469 // The pull failed. That is fine if the image is already here — the local
65- // build case — and fatal otherwise.
70+ // build case — and fatal otherwise. Note this does not check the local
71+ // copy's architecture against `platform`: a cached image of the wrong arch
72+ // satisfies it. Pulls are the normal path, so this only bites an offline
73+ // runner that has been asked to cross-build.
6674 match docker.inspect_image(image).await {
6775 Ok(_) => {
6876 tracing::debug!("pull of {image} failed ({pull_error}); using the local image");
⋯ 7 unchanged lines
addedcrates/anvil-job/Cargo.toml+11 −0
1+[package]
2+name = "anvil-job"
3+version.workspace = true
4+edition.workspace = true
5+license.workspace = true
6+repository.workspace = true
7+rust-version.workspace = true
8+description = "The wire format between anvil and a job runner: what a claimed job is, and what its result looks like."
9+
10+[dependencies]
11+serde.workspace = true
addedcrates/anvil-job/src/lib.rs+144 −0
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+
14+use serde::{
15+ Deserialize,
16+ Serialize,
17+};
18+
19+/// Working directory inside the job container, and the root the checkout tar
20+/// extracts to. Protocol, not preference: artifact paths are resolved relative
21+/// to it on the runner, and `build_tar` roots its entries at it on the server.
22+pub const WORKDIR: &str = "/workspace";
23+
24+/// In-container directory where meta-extractor output lands, one file per
25+/// `<artifact>/<key>`. The server writes the redirections into the script; the
26+/// runner downloads the directory afterwards. File-per-value sidesteps
27+/// quoting and JSON-escaping in shell entirely.
28+pub const META_DIR: &str = "/tmp/anvil-meta";
29+
30+/// Cap on the meta-extractor tar (the values are short strings).
31+pub const META_TAR_CAP: u64 = 1024 * 1024;
32+
33+/// Per-value cap on extractor output, in bytes (after trimming).
34+pub const META_VALUE_CAP: usize = 1024;
35+
36+/// What a runner says about itself when it asks for work.
37+#[derive(Clone, Debug, Serialize, Deserialize)]
38+pub struct RunnerInfo {
39+ /// Operator-chosen, for logs and (later) scheduling. Not a credential.
40+ pub name: String,
41+ /// The daemon's native platform, e.g. `linux/arm64`. Advertised so the
42+ /// dispatcher can eventually prefer a runner that needs no emulation.
43+ pub platform: String,
44+ pub version: String,
45+}
46+
47+/// One artifact to collect, flattened from `anvil_core::ci::ArtifactSpec`.
48+///
49+/// The extractor commands themselves are not here — the server has already
50+/// baked them into the script. All the runner needs to know is whether to
51+/// expect output for this artifact under [`META_DIR`].
52+#[derive(Clone, Debug, Serialize, Deserialize)]
53+pub struct ArtifactSpec {
54+ pub name: String,
55+ /// Relative to [`WORKDIR`].
56+ pub path: String,
57+ pub browse: bool,
58+ pub has_meta: bool,
59+}
60+
61+/// Resource bounds and isolation knobs, resolved from `[ci]` server-side.
62+///
63+/// The runner obeys these; it does not consult a config file of its own for
64+/// them. An operator tightening `ci.memory_mb` should not have to redeploy
65+/// every runner.
66+#[derive(Clone, Debug, Serialize, Deserialize)]
67+pub struct Sandbox {
68+ pub memory_mb: i64,
69+ pub cpus: f64,
70+ pub pids_limit: i64,
71+ /// 0 disables the wall-clock timeout.
72+ pub timeout_secs: u64,
73+ pub network: bool,
74+ /// Empty keeps the image's default user.
75+ pub run_as: String,
76+ /// Per-artifact and per-run artifact caps, in MiB; 0 is unlimited.
77+ ///
78+ /// The per-run budget is charged the *stored* size, which the runner learns
79+ /// from the upload response rather than computing — a `browse` directory is
80+ /// extracted and any other directory recompressed, so the tar it sent is
81+ /// not what lands on disk. Same accounting as the in-process runner did.
82+ pub artifact_max_mb: i64,
83+ pub artifact_run_max_mb: i64,
84+}
85+
86+/// A claimed job, everything needed to run it.
87+///
88+/// The checkout is fetched separately (`GET /-/runner/jobs/{run_id}/checkout.tar`)
89+/// rather than carried here: base64 in a JSON body inflates a large tree by a
90+/// third for no benefit. Secrets *are* carried here, over TLS, so they never
91+/// sit behind a separately-fetchable URL.
92+#[derive(Clone, Debug, Serialize, Deserialize)]
93+pub struct JobSpec {
94+ pub run_id: i64,
95+ /// Already resolved through `ci.default_image` and checked against
96+ /// `ci.allowed_images`.
97+ pub image: String,
98+ /// `linux/amd64`, `linux/arm64`, … `None` takes the daemon's native
99+ /// platform, which is the current behaviour everywhere.
100+ pub platform: Option<String>,
101+ /// The full `sh -c` program: steps, then the meta-extractor trailer.
102+ pub script: String,
103+ /// Secret name/value pairs, injected as environment variables.
104+ pub env: Vec<(String, String)>,
105+ pub artifacts: Vec<ArtifactSpec>,
106+ pub sandbox: Sandbox,
107+}
108+
109+/// What storing one artifact produced. Returned by the upload endpoint, and by
110+/// the in-process sink, so the runner can charge the run budget the size that
111+/// actually landed on disk without knowing how it was laid out.
112+#[derive(Clone, Copy, Debug, Serialize, Deserialize)]
113+pub struct Stored {
114+ pub size: i64,
115+ pub is_dir: bool,
116+}
117+
118+/// One artifact the runner pulled out of the container and uploaded. The bytes
119+/// went ahead of this; here is the row to record for them.
120+#[derive(Clone, Debug, Serialize, Deserialize)]
121+pub struct CollectedArtifact {
122+ pub name: String,
123+ pub size: i64,
124+ pub is_dir: bool,
125+ pub browse: bool,
126+ /// Extractor output for this artifact: a JSON object of key → value.
127+ pub meta: String,
128+}
129+
130+/// The outcome of a job, posted once when it finishes.
131+///
132+/// The whole log arrives in one write, which is exactly what the in-process
133+/// runner did — `append_log` was only ever called when the run ended. Live
134+/// logs are a follow-up, not a regression.
135+#[derive(Clone, Debug, Serialize, Deserialize)]
136+pub struct JobResult {
137+ pub exit_code: i64,
138+ pub log: String,
139+ pub artifacts: Vec<CollectedArtifact>,
140+ /// The runner could not run the job at all (image pull failed, daemon
141+ /// unreachable, timeout). Distinct from a job that ran and exited
142+ /// non-zero: this maps to `status::ERROR`, that to `status::FAILURE`.
143+ pub runner_error: Option<String>,
144+}