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