| 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 | pub async fn ensure_image(docker: &Docker, image: &str) -> Result<(), String> { |
| 36 | // Split name:tag so we don't accidentally pull every tag. A ':' that has a |
| 37 | // '/' after it is a registry port, not a tag. |
| 38 | let (from_image, tag) = match image.rsplit_once(':') { |
| 39 | Some((name, tag)) if !tag.contains('/') => (name.to_string(), tag.to_string()), |
| 40 | _ => (image.to_string(), "latest".to_string()), |
| 41 | }; |
| 42 | |
| 43 | let mut pull = docker.create_image( |
| 44 | Some(CreateImageOptions { |
| 45 | from_image, |
| 46 | tag, |
| 47 | ..Default::default() |
| 48 | }), |
| 49 | None, |
| 50 | None, |
| 51 | ); |
| 52 | let mut pull_error = None; |
| 53 | while let Some(item) = pull.next().await { |
| 54 | if let Err(e) = item { |
| 55 | pull_error = Some(e.to_string()); |
| 56 | break; |
| 57 | } |
| 58 | } |
| 59 | |
| 60 | let Some(pull_error) = pull_error else { |
| 61 | return Ok(()); |
| 62 | }; |
| 63 | |
| 64 | // The pull failed. That is fine if the image is already here — the local |
| 65 | // build case — and fatal otherwise. |
| 66 | match docker.inspect_image(image).await { |
| 67 | Ok(_) => { |
| 68 | tracing::debug!("pull of {image} failed ({pull_error}); using the local image"); |
| 69 | Ok(()) |
| 70 | } |
| 71 | Err(_) => Err(format!( |
| 72 | "image {image} is not available locally and could not be pulled: {pull_error}" |
| 73 | )), |
| 74 | } |
| 75 | } |