Skip to main content

runnerd_core/
docker.rs

1//! Runner containers through the Docker Engine API: the connection, container, volume and image requests, and the
2//! `DockerApi` trait the engine and the caches call. It decides nothing about scheduling, and a JIT config only
3//! ever reaches a container as a root-only file, never the create request or a log line.
4
5use std::collections::{BTreeMap, BTreeSet, HashMap};
6use std::fmt;
7use std::time::{Duration, Instant};
8
9use bollard::exec::{CreateExecOptions, StartExecResults};
10use std::pin::Pin;
11
12use bollard::models::{
13    ChangeType, ContainerConfig, ContainerCreateBody, CreateImageInfo, HostConfig, Mount,
14    NetworkCreateRequest, SystemInfo, VolumeCreateRequest,
15};
16use bollard::query_parameters::{
17    AttachContainerOptionsBuilder, CommitContainerOptionsBuilder, CreateContainerOptionsBuilder,
18    CreateImageOptionsBuilder, DataUsageOptionsBuilder, InspectContainerOptionsBuilder,
19    ListContainersOptionsBuilder, ListImagesOptionsBuilder, LogsOptionsBuilder,
20    RemoveContainerOptionsBuilder, RemoveImageOptionsBuilder, RemoveVolumeOptionsBuilder,
21    ResizeContainerTTYOptionsBuilder, UploadToContainerOptionsBuilder, WaitContainerOptionsBuilder,
22};
23use futures_util::stream::BoxStream;
24use futures_util::{StreamExt, TryStreamExt};
25use tracing::info;
26
27use crate::config::ByteSize;
28use crate::isolation::Isolation;
29
30pub use bollard::errors::Error;
31
32/// One runner container: a profile's image stage with the profile's limits.
33#[derive(Debug, Clone)]
34pub struct RunnerSpec {
35    /// The container's name; for a runner, also the runner's.
36    pub name: String,
37    /// The image, such as `runnerd-runner:base`.
38    pub image: String,
39    /// The profile, put on the `runnerd.profile` label.
40    pub profile: String,
41    /// The systemd slice the container is placed in.
42    pub slice: String,
43    /// CPUs the container may use.
44    pub cpus: u32,
45    /// Memory the container may use, swap included.
46    pub memory: ByteSize,
47    /// `NAME=value` environment variables.
48    pub env: Vec<String>,
49    /// Volumes to mount.
50    pub mounts: Vec<Mount>,
51    /// The network the container joins; `None` is Docker's default bridge.
52    pub network: Option<String>,
53    /// Labels on top of `runnerd.profile` and `runnerd.isolation`.
54    pub labels: BTreeMap<String, String>,
55    /// Replaces the image's entrypoint; `None` runs the runner.
56    pub entrypoint: Option<Vec<String>>,
57    /// The runner's just-in-time config, which the entrypoint reads from `JIT_CONFIG_PATH`.
58    pub jit_config: Option<JitConfig>,
59}
60
61/// Where `start` puts the just-in-time config in the container: a root-only file, never the create request,
62/// which Docker stores and clients may log.
63pub const JIT_CONFIG_PATH: &str = "/run/runnerd/jitconfig";
64
65/// A runner's just-in-time config from GitHub: credentials, never printed.
66#[derive(Clone)]
67pub struct JitConfig(pub String);
68
69impl fmt::Debug for JitConfig {
70    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
71        f.write_str("JitConfig(<redacted>)")
72    }
73}
74
75/// What `inspect` reports about a runner container.
76#[derive(Debug, Clone, PartialEq, Eq)]
77pub struct RunnerState {
78    /// Whether the container runs.
79    pub running: bool,
80    /// Its exit code, once it stopped.
81    pub exit_code: Option<i64>,
82    /// Whether the kernel killed a process in it for memory, as Docker saw.
83    pub oom_killed: bool,
84    /// True under `unsafe-dind`: a job in the container can take over the host.
85    pub privileged: bool,
86    /// The runtime Docker runs it with, such as `sysbox-runc`.
87    pub runtime: Option<String>,
88    /// Its main process on the host, while it runs.
89    pub pid: Option<u32>,
90    /// Its address on its first network.
91    pub address: Option<String>,
92    /// When it started, in milliseconds since the epoch.
93    pub started_at: Option<u64>,
94    /// Its memory cap in bytes; `None` when it has none.
95    pub memory: Option<u64>,
96    /// Its CPU limit in millicores; `None` when it has none.
97    pub cpus: Option<u64>,
98}
99
100/// What a command run in a container prints on stdout, as it prints it.
101pub type Output = BoxStream<'static, Result<Vec<u8>, Error>>;
102
103/// An image `images` found.
104#[derive(Debug, Clone, PartialEq, Eq)]
105pub struct Image {
106    /// Its first tag, such as `runnerd-record:42`; its ID when it has none.
107    pub reference: String,
108    /// Its labels.
109    pub labels: HashMap<String, String>,
110    /// The disk it takes beyond its parent image: what removing it frees.
111    pub bytes: u64,
112}
113
114/// An image `tagged_images` found.
115#[derive(Debug, Clone, PartialEq, Eq)]
116pub struct TaggedImage {
117    /// Its ID, such as `sha256:224e…`.
118    pub id: String,
119    /// Its tags, such as `ghcr.io/ayagmar/runnerd-runner:node-0.0.15`.
120    pub tags: Vec<String>,
121    /// The registry digests it was pulled by, such as `ghcr.io/ayagmar/runnerd-runner@sha256:9e79…`.
122    pub digests: Vec<String>,
123}
124
125/// A container's terminal, attached once it was created and before it started, so none of its output is missed.
126pub struct Tty {
127    /// The container's ID.
128    pub container: String,
129    /// What the terminal shows, as it shows it; it ends when the container's process exits.
130    pub output: BoxStream<'static, Result<Vec<u8>, Error>>,
131    /// What is typed into it.
132    pub input: Pin<Box<dyn tokio::io::AsyncWrite + Send>>,
133}
134
135impl fmt::Debug for Tty {
136    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
137        f.debug_struct("Tty")
138            .field("container", &self.container)
139            .finish_non_exhaustive()
140    }
141}
142
143/// A terminal's size.
144#[derive(Debug, Clone, Copy, PartialEq, Eq)]
145pub struct TtySize {
146    /// Columns.
147    pub cols: u16,
148    /// Rows.
149    pub rows: u16,
150}
151
152/// The create-container request for a runner container under an isolation backend.
153pub fn create_body(spec: &RunnerSpec, isolation: &dyn Isolation) -> ContainerCreateBody {
154    // Clamped to 8 EiB, the most Docker takes.
155    let memory = i64::try_from(spec.memory.0).unwrap_or(i64::MAX);
156    let mut host_config = HostConfig {
157        cgroup_parent: Some(spec.slice.clone()),
158        memory: Some(memory),
159        // Swap equal to memory: the container gets no swap on top of its memory.
160        memory_swap: Some(memory),
161        nano_cpus: Some(i64::from(spec.cpus) * 1_000_000_000),
162        init: Some(true),
163        mounts: Some(spec.mounts.clone()),
164        network_mode: spec.network.clone(),
165        ..Default::default()
166    };
167    isolation.configure(&mut host_config);
168    let mut labels: HashMap<String, String> = spec.labels.clone().into_iter().collect();
169    labels.insert("runnerd.profile".to_owned(), spec.profile.clone());
170    labels.insert("runnerd.isolation".to_owned(), isolation.name().to_owned());
171    ContainerCreateBody {
172        image: Some(spec.image.clone()),
173        env: Some(spec.env.clone()),
174        entrypoint: spec.entrypoint.clone(),
175        labels: Some(labels),
176        host_config: Some(host_config),
177        ..Default::default()
178    }
179}
180
181/// A container `list` found.
182#[derive(Debug, Clone, PartialEq, Eq)]
183pub struct Container {
184    /// The Docker container ID.
185    pub id: String,
186    /// Without Docker's leading `/`.
187    pub name: String,
188    /// Whether it runs.
189    pub running: bool,
190    /// Its labels.
191    pub labels: HashMap<String, String>,
192}
193
194/// Whether a Docker error says the container, volume or image does not exist.
195pub fn is_not_found(error: &Error) -> bool {
196    matches!(
197        error,
198        Error::DockerResponseServerError {
199            status_code: 404,
200            ..
201        }
202    )
203}
204
205/// The Docker Engine calls the scheduler and the caches make. `Docker` is the real one; the engine's tests use a
206/// fake.
207pub trait DockerApi: Send + Sync {
208    /// Creates and starts a runner container; returns its ID.
209    fn start(
210        &self,
211        spec: &RunnerSpec,
212        isolation: &dyn Isolation,
213    ) -> impl Future<Output = Result<String, Error>> + Send;
214    /// A container's state and isolation.
215    fn inspect(&self, container: &str) -> impl Future<Output = Result<RunnerState, Error>> + Send;
216    /// Removes a runner container, stopping it first if it runs.
217    fn remove(&self, container: &str) -> impl Future<Output = Result<(), Error>> + Send;
218    /// Creates and starts a container that is not a runner, such as the mirror; returns its ID.
219    fn run(
220        &self,
221        name: &str,
222        body: ContainerCreateBody,
223    ) -> impl Future<Output = Result<String, Error>> + Send;
224    /// Every container, running or not, that has the label.
225    fn list(&self, label: &str) -> impl Future<Output = Result<Vec<Container>, Error>> + Send;
226    /// Waits for a container to exit; returns its exit code.
227    fn wait(&self, container: &str) -> impl Future<Output = Result<i64, Error>> + Send;
228    /// A container's output so far, stdout and stderr interleaved.
229    fn logs(&self, container: &str) -> impl Future<Output = Result<String, Error>> + Send;
230    /// A container's output since `since` (seconds since the epoch), each line prefixed with Docker's RFC 3339
231    /// timestamp and a space.
232    fn logs_since(
233        &self,
234        container: &str,
235        since: u64,
236    ) -> impl Future<Output = Result<String, Error>> + Send;
237    /// Runs a command as root in a running container; its stdout streams until it exits or the container stops.
238    /// Its stderr is dropped.
239    fn exec(
240        &self,
241        container: &str,
242        command: Vec<String>,
243    ) -> impl Future<Output = Result<Output, Error>> + Send;
244    /// Whether Docker has an image.
245    fn image_present(&self, image: &str) -> impl Future<Output = Result<bool, Error>> + Send;
246    /// Pulls an image, logging its progress every few seconds.
247    fn pull(&self, image: &str) -> impl Future<Output = Result<(), Error>> + Send;
248    /// Creates a bridge network unless it exists.
249    fn ensure_network(&self, name: &str) -> impl Future<Output = Result<(), Error>> + Send;
250    /// Creates a volume unless it exists.
251    fn ensure_volume(&self, name: &str) -> impl Future<Output = Result<(), Error>> + Send;
252    /// Removes a volume; a volume that does not exist is not an error.
253    fn remove_volume(&self, name: &str) -> impl Future<Output = Result<(), Error>> + Send;
254    /// The disk size of every volume Docker knows, by name. Slow: Docker measures each volume.
255    fn volume_sizes(&self) -> impl Future<Output = Result<BTreeMap<String, u64>, Error>> + Send;
256    /// The paths a container's own layer added or changed, not those it deleted.
257    fn changes(&self, container: &str) -> impl Future<Output = Result<Vec<String>, Error>> + Send;
258    /// The bytes a container's own layer holds.
259    fn written(&self, container: &str) -> impl Future<Output = Result<u64, Error>> + Send;
260    /// Commits a stopped container's own layer as the image `reference` (`repository:tag`) with `labels`; returns
261    /// the disk the image takes beyond its parent.
262    fn commit(
263        &self,
264        container: &str,
265        reference: &str,
266        labels: BTreeMap<String, String>,
267    ) -> impl Future<Output = Result<u64, Error>> + Send;
268    /// Every image with the label.
269    fn images(&self, label: &str) -> impl Future<Output = Result<Vec<Image>, Error>> + Send;
270    /// The ID of the image a reference (tag, digest or ID) names; `None` when Docker does not have it.
271    fn image_id(
272        &self,
273        reference: &str,
274    ) -> impl Future<Output = Result<Option<String>, Error>> + Send;
275    /// Every image Docker has a tag for.
276    fn tagged_images(&self) -> impl Future<Output = Result<Vec<TaggedImage>, Error>> + Send;
277    /// The image ID of every container, running or stopped.
278    fn container_images(&self) -> impl Future<Output = Result<BTreeSet<String>, Error>> + Send;
279    /// Removes an image and the layers only it used; one that does not exist is not an error.
280    fn remove_image(&self, reference: &str) -> impl Future<Output = Result<(), Error>> + Send;
281    /// The free disk where Docker keeps its images and containers.
282    fn free_space(&self) -> impl Future<Output = Result<u64, Error>> + Send;
283    /// Creates a container with a terminal of `size`, attaches to it and starts it. The container's stdin closes
284    /// when `Tty::input` is dropped.
285    fn shell(
286        &self,
287        name: &str,
288        body: ContainerCreateBody,
289        size: TtySize,
290    ) -> impl Future<Output = Result<Tty, Error>> + Send;
291}
292
293/// The local Docker Engine.
294#[derive(Debug)]
295pub struct Docker {
296    api: bollard::Docker,
297}
298
299impl Docker {
300    /// Connects to the Docker Engine at `DOCKER_HOST`, or its local socket. Only `unix://` hosts are supported.
301    pub fn connect() -> Result<Docker, Error> {
302        connect_to(std::env::var("DOCKER_HOST").ok().as_deref())
303    }
304
305    /// The Engine's system information.
306    pub async fn info(&self) -> Result<SystemInfo, Error> {
307        self.api.info().await
308    }
309
310    /// An image's labels; `None` when Docker does not have it.
311    pub async fn image_labels(
312        &self,
313        image: &str,
314    ) -> Result<Option<HashMap<String, String>>, Error> {
315        match self.api.inspect_image(image).await {
316            Ok(inspect) => Ok(Some(
317                inspect
318                    .config
319                    .and_then(|config| config.labels)
320                    .unwrap_or_default(),
321            )),
322            Err(error) if is_not_found(&error) => Ok(None),
323            Err(error) => Err(error),
324        }
325    }
326
327    /// Asks the image's registry for its manifest, without pulling it, and returns the platforms it is built for,
328    /// such as `linux/amd64`. An error means `pull` could not get it.
329    pub async fn registry_platforms(&self, image: &str) -> Result<Vec<String>, Error> {
330        let inspect = self.api.inspect_registry_image(image, None).await?;
331        Ok(inspect
332            .platforms
333            .into_iter()
334            .filter_map(|platform| Some(format!("{}/{}", platform.os?, platform.architecture?)))
335            // Attestation manifests in an index carry the platform unknown/unknown.
336            .filter(|platform| platform != "unknown/unknown")
337            .collect())
338    }
339}
340
341impl DockerApi for Docker {
342    async fn start(&self, spec: &RunnerSpec, isolation: &dyn Isolation) -> Result<String, Error> {
343        let options = CreateContainerOptionsBuilder::new()
344            .name(&spec.name)
345            .build();
346        let id = self
347            .api
348            .create_container(Some(options), create_body(spec, isolation))
349            .await?
350            .id;
351        let started = async {
352            if let Some(jit_config) = &spec.jit_config {
353                let options = UploadToContainerOptionsBuilder::new().path("/").build();
354                let tar = jit_config_tar(jit_config)?;
355                // As a stream: bollard traces a full body's bytes, but a stream only as "(stream)".
356                let tar = futures_util::stream::once(std::future::ready(tar.into()));
357                self.api
358                    .upload_to_container(&id, Some(options), bollard::body_stream(tar))
359                    .await?;
360            }
361            self.api.start_container(&id, None).await
362        };
363        if let Err(error) = started.await {
364            // Best effort: the start error is the one worth reporting.
365            let _ = self.remove(&id).await;
366            return Err(error);
367        }
368        Ok(id)
369    }
370
371    async fn inspect(&self, container: &str) -> Result<RunnerState, Error> {
372        let response = self.api.inspect_container(container, None).await?;
373        let state = response.state.unwrap_or_default();
374        let host_config = response.host_config.unwrap_or_default();
375        let address = response
376            .network_settings
377            .and_then(|settings| settings.networks)
378            .and_then(|networks| {
379                networks
380                    .into_values()
381                    .find_map(|network| network.ip_address.filter(|address| !address.is_empty()))
382            });
383        Ok(RunnerState {
384            running: state.running.unwrap_or(false),
385            exit_code: state.exit_code,
386            oom_killed: state.oom_killed.unwrap_or(false),
387            privileged: host_config.privileged.unwrap_or(false),
388            runtime: host_config.runtime,
389            pid: state
390                .pid
391                .and_then(|pid| u32::try_from(pid).ok())
392                .filter(|pid| *pid > 0),
393            address,
394            started_at: state.started_at.as_deref().and_then(crate::time::parse_ms),
395            memory: host_config
396                .memory
397                .and_then(|memory| u64::try_from(memory).ok())
398                .filter(|memory| *memory > 0),
399            cpus: host_config
400                .nano_cpus
401                .and_then(|nano| u64::try_from(nano / 1_000_000).ok())
402                .filter(|cpus| *cpus > 0),
403        })
404    }
405
406    async fn remove(&self, container: &str) -> Result<(), Error> {
407        // With its anonymous volumes, such as a shell's /state.
408        let options = RemoveContainerOptionsBuilder::new()
409            .force(true)
410            .v(true)
411            .build();
412        self.api.remove_container(container, Some(options)).await
413    }
414
415    async fn run(&self, name: &str, body: ContainerCreateBody) -> Result<String, Error> {
416        let options = CreateContainerOptionsBuilder::new().name(name).build();
417        let id = self.api.create_container(Some(options), body).await?.id;
418        if let Err(error) = self.api.start_container(&id, None).await {
419            let _ = self.remove(&id).await;
420            return Err(error);
421        }
422        Ok(id)
423    }
424
425    async fn list(&self, label: &str) -> Result<Vec<Container>, Error> {
426        let options = ListContainersOptionsBuilder::new()
427            .all(true)
428            .filters(&HashMap::from([("label", vec![label])]))
429            .build();
430        let containers = self.api.list_containers(Some(options)).await?;
431        Ok(containers
432            .into_iter()
433            .map(|container| Container {
434                id: container.id.unwrap_or_default(),
435                name: container
436                    .names
437                    .and_then(|names| names.into_iter().next())
438                    .unwrap_or_default()
439                    .trim_start_matches('/')
440                    .to_owned(),
441                running: container.state
442                    == Some(bollard::models::ContainerSummaryStateEnum::RUNNING),
443                labels: container.labels.unwrap_or_default(),
444            })
445            .collect())
446    }
447
448    async fn wait(&self, container: &str) -> Result<i64, Error> {
449        let options = WaitContainerOptionsBuilder::new()
450            .condition("not-running")
451            .build();
452        let mut responses = self.api.wait_container(container, Some(options));
453        match responses.next().await {
454            Some(Ok(response)) => Ok(response.status_code),
455            // Bollard reports a non-zero exit as an error.
456            Some(Err(Error::DockerContainerWaitError { code, .. })) => Ok(code),
457            Some(Err(error)) => Err(error),
458            None => Ok(0),
459        }
460    }
461
462    async fn logs(&self, container: &str) -> Result<String, Error> {
463        let options = LogsOptionsBuilder::new().stdout(true).stderr(true).build();
464        let chunks: Vec<_> = self
465            .api
466            .logs(container, Some(options))
467            .try_collect()
468            .await?;
469        Ok(chunks.iter().map(ToString::to_string).collect())
470    }
471
472    async fn logs_since(&self, container: &str, since: u64) -> Result<String, Error> {
473        let options = LogsOptionsBuilder::new()
474            .stdout(true)
475            .stderr(true)
476            .timestamps(true)
477            .since(i32::try_from(since).unwrap_or(i32::MAX))
478            .build();
479        let chunks: Vec<_> = self
480            .api
481            .logs(container, Some(options))
482            .try_collect()
483            .await?;
484        Ok(chunks.iter().map(ToString::to_string).collect())
485    }
486
487    async fn exec(&self, container: &str, command: Vec<String>) -> Result<Output, Error> {
488        let options = CreateExecOptions {
489            cmd: Some(command),
490            user: Some("root".to_owned()),
491            attach_stdout: Some(true),
492            attach_stderr: Some(true),
493            ..Default::default()
494        };
495        let exec = self.api.create_exec(container, options).await?;
496        match self.api.start_exec(&exec.id, None).await? {
497            StartExecResults::Attached { output, .. } => Ok(output
498                .try_filter_map(|output| {
499                    std::future::ready(Ok(match output {
500                        bollard::container::LogOutput::StdOut { message } => Some(message.to_vec()),
501                        _ => None,
502                    }))
503                })
504                .boxed()),
505            StartExecResults::Detached => Ok(futures_util::stream::empty().boxed()),
506        }
507    }
508
509    async fn image_present(&self, image: &str) -> Result<bool, Error> {
510        Ok(self.image_labels(image).await?.is_some())
511    }
512
513    async fn pull(&self, image: &str) -> Result<(), Error> {
514        let options = CreateImageOptionsBuilder::new().from_image(image).build();
515        let mut stream = self.api.create_image(Some(options), None, None);
516        let mut progress = PullProgress::default();
517        let mut logged = Instant::now();
518        info!("pulling {image}");
519        while let Some(update) = stream.try_next().await? {
520            progress.update(&update);
521            if logged.elapsed() >= PULL_LOG_INTERVAL {
522                logged = Instant::now();
523                info!("pulling {image}: {progress}");
524            }
525        }
526        info!("pulled {image}");
527        Ok(())
528    }
529
530    async fn ensure_network(&self, name: &str) -> Result<(), Error> {
531        match self.api.inspect_network(name, None).await {
532            Err(error) if is_not_found(&error) => {
533                let request = NetworkCreateRequest {
534                    name: name.to_owned(),
535                    driver: Some("bridge".to_owned()),
536                    ..Default::default()
537                };
538                self.api.create_network(request).await.map(|_| ())
539            }
540            other => other.map(|_| ()),
541        }
542    }
543
544    async fn ensure_volume(&self, name: &str) -> Result<(), Error> {
545        // Creating a volume that exists is a no-op in Docker.
546        let request = VolumeCreateRequest {
547            name: Some(name.to_owned()),
548            ..Default::default()
549        };
550        self.api.create_volume(request).await.map(|_| ())
551    }
552
553    async fn remove_volume(&self, name: &str) -> Result<(), Error> {
554        let options = RemoveVolumeOptionsBuilder::new().build();
555        match self.api.remove_volume(name, Some(options)).await {
556            Err(error) if is_not_found(&error) => Ok(()),
557            other => other,
558        }
559    }
560
561    async fn volume_sizes(&self) -> Result<BTreeMap<String, u64>, Error> {
562        // Without a type filter: bollard cannot encode one in the query.
563        let options = DataUsageOptionsBuilder::new().verbose(true).build();
564        let usage = self.api.df(Some(options)).await?;
565        // API 1.53 leaves the items untyped; they are volumes as `GET /volumes` describes them.
566        Ok(usage
567            .volume_usage
568            .and_then(|volumes| volumes.items)
569            .unwrap_or_default()
570            .into_iter()
571            .filter_map(|volume| {
572                let name = volume.get("Name")?.as_str()?.to_owned();
573                let size = volume.get("UsageData")?.get("Size")?.as_u64()?;
574                Some((name, size))
575            })
576            .collect())
577    }
578
579    async fn changes(&self, container: &str) -> Result<Vec<String>, Error> {
580        Ok(self
581            .api
582            .container_changes(container)
583            .await?
584            .unwrap_or_default()
585            .into_iter()
586            .filter(|change| change.kind != ChangeType::_2)
587            .map(|change| change.path)
588            .collect())
589    }
590
591    async fn written(&self, container: &str) -> Result<u64, Error> {
592        let options = InspectContainerOptionsBuilder::new().size(true).build();
593        let response = self.api.inspect_container(container, Some(options)).await?;
594        Ok(response
595            .size_rw
596            .and_then(|bytes| u64::try_from(bytes).ok())
597            .unwrap_or(0))
598    }
599
600    async fn commit(
601        &self,
602        container: &str,
603        reference: &str,
604        labels: BTreeMap<String, String>,
605    ) -> Result<u64, Error> {
606        let (repository, tag) = reference.rsplit_once(':').unwrap_or((reference, "latest"));
607        let options = CommitContainerOptionsBuilder::new()
608            .container(container)
609            .repo(repository)
610            .tag(tag)
611            .pause(false)
612            .build();
613        let config = ContainerConfig {
614            labels: Some(labels.into_iter().collect()),
615            ..Default::default()
616        };
617        // The image the container runs is the record's parent.
618        let parent = match self.api.inspect_container(container, None).await?.image {
619            Some(parent) => self.api.inspect_image(&parent).await?.size.unwrap_or(0),
620            None => 0,
621        };
622        let id = self.api.commit_container(options, config).await?.id;
623        let size = self.api.inspect_image(&id).await?.size.unwrap_or(0);
624        Ok(u64::try_from(size - parent).unwrap_or(0))
625    }
626
627    async fn images(&self, label: &str) -> Result<Vec<Image>, Error> {
628        let options = ListImagesOptionsBuilder::new()
629            .filters(&HashMap::from([("label", vec![label])]))
630            .build();
631        let images = self.api.list_images(Some(options)).await?;
632        let mut parents: HashMap<String, i64> = HashMap::new();
633        let mut found = Vec::new();
634        for image in images {
635            let parent = if image.parent_id.is_empty() {
636                0
637            } else if let Some(size) = parents.get(&image.parent_id) {
638                *size
639            } else {
640                let size = self
641                    .api
642                    .inspect_image(&image.parent_id)
643                    .await
644                    .ok()
645                    .and_then(|parent| parent.size)
646                    .unwrap_or(0);
647                parents.insert(image.parent_id.clone(), size);
648                size
649            };
650            found.push(Image {
651                reference: image.repo_tags.into_iter().next().unwrap_or(image.id),
652                labels: image.labels,
653                bytes: u64::try_from(image.size - parent).unwrap_or(0),
654            });
655        }
656        Ok(found)
657    }
658
659    async fn image_id(&self, reference: &str) -> Result<Option<String>, Error> {
660        match self.api.inspect_image(reference).await {
661            Ok(inspect) => Ok(inspect.id),
662            Err(error) if is_not_found(&error) => Ok(None),
663            Err(error) => Err(error),
664        }
665    }
666
667    async fn tagged_images(&self) -> Result<Vec<TaggedImage>, Error> {
668        let images = self
669            .api
670            .list_images(Some(ListImagesOptionsBuilder::new().build()))
671            .await?;
672        Ok(images
673            .into_iter()
674            .map(|image| TaggedImage {
675                id: image.id,
676                tags: image
677                    .repo_tags
678                    .into_iter()
679                    .filter(|tag| tag != "<none>:<none>")
680                    .collect(),
681                digests: image
682                    .repo_digests
683                    .into_iter()
684                    .filter(|digest| digest != "<none>@<none>")
685                    .collect(),
686            })
687            .filter(|image| !image.tags.is_empty())
688            .collect())
689    }
690
691    async fn container_images(&self) -> Result<BTreeSet<String>, Error> {
692        let options = ListContainersOptionsBuilder::new().all(true).build();
693        let containers = self.api.list_containers(Some(options)).await?;
694        Ok(containers
695            .into_iter()
696            .filter_map(|container| container.image_id)
697            .collect())
698    }
699
700    async fn remove_image(&self, reference: &str) -> Result<(), Error> {
701        let options = RemoveImageOptionsBuilder::new().build();
702        match self.api.remove_image(reference, Some(options), None).await {
703            Err(error) if is_not_found(&error) => Ok(()),
704            other => other.map(|_| ()),
705        }
706    }
707
708    async fn free_space(&self) -> Result<u64, Error> {
709        let root = self.api.info().await?.docker_root_dir.unwrap_or_default();
710        // Only the directories above Docker's root need to be searchable: the daemon's user cannot read the root.
711        let stat = rustix::fs::statvfs(root.as_str()).map_err(|error| Error::IOError {
712            err: std::io::Error::other(format!("cannot measure the disk of {root}: {error}")),
713        })?;
714        Ok(stat.f_bavail.saturating_mul(stat.f_frsize))
715    }
716
717    async fn shell(
718        &self,
719        name: &str,
720        body: ContainerCreateBody,
721        size: TtySize,
722    ) -> Result<Tty, Error> {
723        let options = CreateContainerOptionsBuilder::new().name(name).build();
724        let id = self.api.create_container(Some(options), body).await?.id;
725        let attached = async {
726            let options = AttachContainerOptionsBuilder::new()
727                .stream(true)
728                .stdin(true)
729                .stdout(true)
730                .stderr(true)
731                .build();
732            let attached = self.api.attach_container(&id, Some(options)).await?;
733            self.api.start_container(&id, None).await?;
734            let resize = ResizeContainerTTYOptionsBuilder::new()
735                .w(i32::from(size.cols))
736                .h(i32::from(size.rows))
737                .build();
738            self.api.resize_container_tty(&id, resize).await?;
739            Ok::<_, Error>(attached)
740        };
741        match attached.await {
742            Ok(attached) => Ok(Tty {
743                container: id,
744                output: attached
745                    .output
746                    .map_ok(|output| output.into_bytes().to_vec())
747                    .boxed(),
748                input: attached.input,
749            }),
750            Err(error) => {
751                let _ = self.remove(&id).await;
752                Err(error)
753            }
754        }
755    }
756}
757
758/// How often `pull` logs its progress.
759const PULL_LOG_INTERVAL: Duration = Duration::from_secs(10);
760
761/// A pull's layers so far, from the statuses Docker streams per layer.
762#[derive(Debug, Default)]
763struct PullProgress {
764    layers: BTreeMap<String, Layer>,
765}
766
767#[derive(Debug, Default)]
768struct Layer {
769    downloaded: u64,
770    size: u64,
771    done: bool,
772}
773
774impl PullProgress {
775    fn update(&mut self, update: &CreateImageInfo) {
776        let (Some(id), Some(status)) = (&update.id, &update.status) else {
777            return;
778        };
779        let bytes = |value: Option<i64>| value.and_then(|value| u64::try_from(value).ok());
780        let detail = update.progress_detail.as_ref();
781        match status.as_str() {
782            "Pulling fs layer" | "Waiting" => {
783                self.layers.entry(id.clone()).or_default();
784            }
785            "Downloading" => {
786                let layer = self.layers.entry(id.clone()).or_default();
787                layer.downloaded =
788                    bytes(detail.and_then(|detail| detail.current)).unwrap_or(layer.downloaded);
789                layer.size = bytes(detail.and_then(|detail| detail.total)).unwrap_or(layer.size);
790            }
791            "Download complete" => {
792                if let Some(layer) = self.layers.get_mut(id) {
793                    layer.downloaded = layer.size;
794                }
795            }
796            "Pull complete" | "Already exists" => {
797                let layer = self.layers.entry(id.clone()).or_default();
798                layer.downloaded = layer.size;
799                layer.done = true;
800            }
801            _ => {}
802        }
803    }
804}
805
806impl fmt::Display for PullProgress {
807    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
808        let done = self.layers.values().filter(|layer| layer.done).count();
809        let downloaded: u64 = self.layers.values().map(|layer| layer.downloaded).sum();
810        let size: u64 = self.layers.values().map(|layer| layer.size).sum();
811        write!(
812            f,
813            "{done} of {} layers done, {} of {} downloaded",
814            self.layers.len(),
815            ByteSize(downloaded),
816            ByteSize(size)
817        )
818    }
819}
820
821fn connect_to(docker_host: Option<&str>) -> Result<Docker, Error> {
822    let api = match docker_host.filter(|host| !host.is_empty()) {
823        None => bollard::Docker::connect_with_socket_defaults()?,
824        Some(host) if host.starts_with("unix://") => {
825            bollard::Docker::connect_with_socket(host, 120, bollard::API_DEFAULT_VERSION)?
826        }
827        // Bollard would quietly fall back to the local socket: a different Engine than the one asked for.
828        Some(host) => {
829            return Err(Error::UnsupportedURISchemeError {
830                uri: host.to_owned(),
831            });
832        }
833    };
834    Ok(Docker { api })
835}
836
837/// A tar archive, to extract at `/`, holding the config as a root-only file at `JIT_CONFIG_PATH`.
838fn jit_config_tar(jit_config: &JitConfig) -> std::io::Result<Vec<u8>> {
839    let mut tar = tar::Builder::new(Vec::new());
840    let path = JIT_CONFIG_PATH.trim_start_matches('/');
841    let mut dir = tar::Header::new_gnu();
842    dir.set_entry_type(tar::EntryType::Directory);
843    dir.set_mode(0o700);
844    dir.set_size(0);
845    tar.append_data(
846        &mut dir,
847        path.rsplit_once('/').map_or("", |(dir, _)| dir),
848        std::io::empty(),
849    )?;
850    let mut file = tar::Header::new_gnu();
851    file.set_mode(0o400);
852    file.set_size(jit_config.0.len() as u64);
853    tar.append_data(&mut file, path, jit_config.0.as_bytes())?;
854    tar.into_inner()
855}
856
857#[cfg(test)]
858mod tests;