| 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 | |
| 10 | use bollard::{ |
| 11 | Docker, |
| 12 | image::CreateImageOptions, |
| 13 | }; |
| 14 | use 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. |
| 24 | pub 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. |
| 39 | pub 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. |
| 97 | async 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 | } |