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 Ok(());
67 };
68
69 // The pull failed. That is fine if the image is already here — the local
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.
74 match docker.inspect_image(image).await {
75 Ok(_) => {
76 tracing::debug!("pull of {image} failed ({pull_error}); using the local image");
77 Ok(())
78 }
79 Err(_) => Err(format!(
80 "image {image} is not available locally and could not be pulled: {pull_error}"
81 )),
82 }
83}