anvilsign in

collin/anvil

1//! Docker plumbing shared by the job runner (`anvil-worker`) and the
2//! agent-session supervisor (`anvil-agent`).
3//!
4//! Both create containers from the same runner image
5//! (`anvil_core::config::DEFAULT_RUNNER_IMAGE`) against the same daemon, so the
6//! connect/pull dance lives here rather than being written twice. Its own crate
7//! rather than a module of either, so `anvil-agent` need not depend on the
8//! runner and the runner need not depend on `anvil-core`.
9
10use bollard::{
11 Docker,
12 image::CreateImageOptions,
13};
14use futures_util::StreamExt;
15
16/// Connect to the daemon.
17///
18/// Environment-aware (`DOCKER_HOST`, `DOCKER_CERT_PATH`, …) rather than the
19/// hardcoded `/var/run/docker.sock` this used to use. A runner on macOS is the
20/// reason: Docker Desktop creates that symlink only when "Allow the default
21/// Docker socket to be used" is ticked, and puts the real socket at
22/// `~/.docker/run/docker.sock`; Colima and OrbStack differ again. Falling back
23/// to the socket default when nothing is set keeps Linux behaviour identical.
24pub fn connect() -> Result<Docker, String> {
25 Docker::connect_with_defaults()
26 .map_err(|e| format!("docker unavailable (is DOCKER_HOST/the socket right?): {e}"))
27}
28
29/// Make sure `image` is present locally, pulling it if it is not.
30///
31/// A failed pull is only fatal when the image is *also* absent locally. anvil's
32/// own runner image is built by `deploy/runner/build.sh` straight into the
33/// host's image store and exists in no registry, so an unconditional pull —
34/// which is what this used to be — fails for the one image most jobs now use.
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.
39pub async fn ensure_image(docker: &Docker, image: &str, platform: &str) -> Result<(), String> {
40 // Split name:tag so we don't accidentally pull every tag. A ':' that has a
41 // '/' after it is a registry port, not a tag.
42 let (from_image, tag) = match image.rsplit_once(':') {
43 Some((name, tag)) if !tag.contains('/') => (name.to_string(), tag.to_string()),
44 _ => (image.to_string(), "latest".to_string()),
45 };
46
47 let mut pull = docker.create_image(
48 Some(CreateImageOptions {
49 from_image,
50 tag,
51 platform: platform.to_string(),
52 ..Default::default()
53 }),
54 None,
55 None,
56 );
57 let mut pull_error = None;
58 while let Some(item) = pull.next().await {
59 if let Err(e) = item {
60 pull_error = Some(e.to_string());
61 break;
62 }
63 }
64
65 let Some(pull_error) = pull_error else {
66 return check_platform(docker, image, platform).await;
67 };
68
69 // The pull failed. That is fine if the image is already here — the local
70 // build case — and fatal otherwise. The local copy still has to be the
71 // right architecture: satisfying an offline cross-build from a cached
72 // image of the wrong arch is how a job silently tests something other
73 // than what it asked for.
74 match docker.inspect_image(image).await {
75 Ok(_) => {
76 tracing::debug!("pull of {image} failed ({pull_error}); using the local image");
77 check_platform(docker, image, platform).await
78 }
79 Err(_) => Err(format!(
80 "image {image} is not available locally and could not be pulled: {pull_error}"
81 )),
82 }
83}
84
85/// Fail loudly when the local image is not the architecture the job asked for.
86///
87/// The daemon is not obliged to give you what `platform` asked for: a manifest
88/// with no such entry, or a pull that fell back to a cached image, both end
89/// with an image of the wrong arch and no error anywhere. That turns a job that
90/// exists precisely to test `linux/amd64` into one quietly testing arm64, and
91/// the only sign is a green run. Compared here rather than left to
92/// `create_container`, which happily starts an emulated container of the wrong
93/// architecture too.
94///
95/// The variant (`linux/arm/v7`) is not compared: it is rarely reported on the
96/// image and never the mistake worth catching.
97async fn check_platform(docker: &Docker, image: &str, platform: &str) -> Result<(), String> {
98 if platform.is_empty() {
99 return Ok(());
100 }
101 let mut wanted = platform.split('/');
102 let (Some(want_os), Some(want_arch)) = (wanted.next(), wanted.next()) else {
103 return Ok(()); // Not os/arch; the server validated it, so take it as given.
104 };
105
106 let Ok(info) = docker.inspect_image(image).await else {
107 return Ok(()); // Just pulled it; a failing inspect is not the job's problem.
108 };
109 let (Some(os), Some(arch)) = (info.os.as_deref(), info.architecture.as_deref()) else {
110 return Ok(());
111 };
112 if os == want_os && arch == want_arch {
113 return Ok(());
114 }
115 Err(format!(
116 "image {image} is {os}/{arch}, not the requested {platform} — \
117 the registry may have no {platform} manifest for it, or a local image \
118 of the wrong architecture is shadowing the pull"
119 ))
120}