Skip to main content

blue_build_process_management/drivers/
docker_driver.rs

1use std::{
2    ops::Not,
3    path::Path,
4    process::{Command, ExitStatus},
5};
6
7use blue_build_utils::{
8    constants::{BLUE_BUILD, DOCKER_HOST, GITHUB_ACTIONS},
9    container::{ContainerId, ImageRef},
10    credentials::Credentials,
11    get_env_var,
12    secret::SecretArgs,
13    semver::Version,
14    string_vec,
15};
16use cached::proc_macro::once;
17use colored::Colorize;
18use comlexr::{cmd, pipe};
19use log::{debug, info, trace, warn};
20use miette::{Context, IntoDiagnostic, Result, bail};
21use oci_client::Reference;
22use serde::Deserialize;
23use tempfile::TempDir;
24
25use crate::{
26    logging::CommandLogging,
27    signal_handler::{ContainerRuntime, ContainerSignalId, DetachedContainer, add_cid, remove_cid},
28};
29
30use super::{
31    opts::{
32        BuildOpts, BuildTagPushOpts, CreateContainerOpts, ManifestCreateOpts, ManifestPushOpts,
33        PruneOpts, PullOpts, PushOpts, RemoveContainerOpts, RemoveImageOpts, RunOpts, RunOptsEnv,
34        RunOptsVolume, TagOpts, UntagOpts,
35    },
36    traits::{BuildDriver, DriverVersion, ImageStorageDriver, RunDriver},
37};
38
39#[derive(Debug, Deserialize)]
40struct VerisonJsonClient {
41    #[serde(alias = "Version")]
42    version: Version,
43}
44
45#[derive(Debug, Deserialize)]
46struct VersionJson {
47    #[serde(alias = "Client")]
48    client: VerisonJsonClient,
49}
50
51#[derive(Debug, Deserialize)]
52#[serde(rename_all = "PascalCase")]
53struct ContextListItem {
54    name: String,
55}
56
57#[derive(Debug)]
58pub struct DockerDriver;
59
60impl DockerDriver {
61    fn setup() -> Result<()> {
62        #[once(result = true, sync_writes = true)]
63        fn exec() -> Result<()> {
64            trace!("DockerDriver::setup()");
65
66            if !DockerDriver::has_buildx() {
67                bail!("Docker Buildx is required to use the Docker driver");
68            }
69
70            let ls_out = {
71                let c = cmd!("docker", "buildx", "ls", "--format={{.Name}}");
72                trace!("{c:?}");
73                c
74            }
75            .output()
76            .into_diagnostic()?;
77
78            if !ls_out.status.success() {
79                bail!("{}", String::from_utf8_lossy(&ls_out.stderr));
80            }
81
82            let ls_out = String::from_utf8(ls_out.stdout).into_diagnostic()?;
83
84            trace!("{ls_out}");
85
86            if !ls_out.lines().any(|line| line == BLUE_BUILD) {
87                let remote = get_env_var(DOCKER_HOST).is_ok();
88                if remote {
89                    let context_list = get_context_list()?;
90                    trace!("{context_list:#?}");
91
92                    if context_list.iter().any(|ctx| ctx.name == BLUE_BUILD).not() {
93                        let context_out = {
94                            let c = cmd!(
95                                "docker",
96                                "context",
97                                "create",
98                                "--from=default",
99                                format!("{BLUE_BUILD}0")
100                            );
101                            trace!("{c:?}");
102                            c
103                        }
104                        .output()
105                        .into_diagnostic()?;
106
107                        if context_out.status.success().not() {
108                            bail!("{}", String::from_utf8_lossy(&context_out.stderr));
109                        }
110
111                        let context_list = get_context_list()?;
112                        trace!("{context_list:#?}");
113                    }
114                }
115
116                let create_out = {
117                    let c = cmd!(
118                        "docker",
119                        "buildx",
120                        "create",
121                        "--bootstrap",
122                        "--driver=docker-container",
123                        format!("--name={BLUE_BUILD}"),
124                        if remote => format!("{BLUE_BUILD}0"),
125                    );
126                    trace!("{c:?}");
127                    c
128                }
129                .output()
130                .into_diagnostic()?;
131
132                if !create_out.status.success() {
133                    bail!("{}", String::from_utf8_lossy(&create_out.stderr));
134                }
135            }
136
137            Ok(())
138        }
139
140        exec()
141    }
142
143    #[must_use]
144    pub fn has_buildx() -> bool {
145        pipe!(cmd!("docker", "--help") | cmd!("grep", "buildx"))
146            .status()
147            .is_ok_and(|status| status.success())
148    }
149}
150
151fn get_context_list() -> Result<Vec<ContextListItem>> {
152    {
153        let c = cmd!("docker", "context", "ls", "--format=json");
154        trace!("{c:?}");
155        c
156    }
157    .output()
158    .into_diagnostic()
159    .and_then(|out| {
160        if out.status.success().not() {
161            bail!("{}", String::from_utf8_lossy(&out.stderr));
162        }
163        String::from_utf8(out.stdout).into_diagnostic()
164    })?
165    .lines()
166    .map(|line| serde_json::from_str(line).into_diagnostic())
167    .collect()
168}
169
170impl DriverVersion for DockerDriver {
171    // First docker verison to use buildkit
172    // https://docs.docker.com/build/buildkit/
173    const VERSION_REQ: &'static str = ">=23";
174
175    fn version() -> Result<Version> {
176        trace!("DockerDriver::version()");
177
178        let output = {
179            let c = cmd!("docker", "version", "-f", "json");
180            trace!("{c:?}");
181            c
182        }
183        .output()
184        .into_diagnostic()?;
185
186        let version_json: VersionJson = serde_json::from_slice(&output.stdout).into_diagnostic()?;
187
188        Ok(version_json.client.version)
189    }
190}
191
192impl BuildDriver for DockerDriver {
193    fn build(opts: BuildOpts) -> Result<()> {
194        trace!("DockerDriver::build({opts:#?})");
195
196        let temp_dir = TempDir::new()
197            .into_diagnostic()
198            .wrap_err("Failed to create temporary directory for secrets")?;
199
200        if opts.squash {
201            warn!("Squash is deprecated for docker so this build will not squash");
202        }
203
204        let status = {
205            let c = cmd!(
206                "docker",
207                "build",
208                if let Some(platform) = opts.platform => [
209                    "--platform",
210                    platform.to_string(),
211                ],
212                "-t",
213                opts.image.to_string(),
214                "-f",
215                for opts.secrets.args(&temp_dir)?,
216                if opts.secrets.ssh() => "--ssh",
217                if let Some(cache_from) = opts.cache_from.as_ref() => [
218                    "--cache-from",
219                    format!(
220                        "type=registry,ref={registry}/{repository}",
221                        registry = cache_from.registry(),
222                        repository = cache_from.repository(),
223                    ),
224                ],
225                if let Some(cache_to) = opts.cache_to.as_ref() => [
226                    "--cache-to",
227                    format!(
228                        "type=registry,ref={registry}/{repository},mode=max",
229                        registry = cache_to.registry(),
230                        repository = cache_to.repository(),
231                    ),
232                ],
233                opts.containerfile,
234                ".",
235            );
236            trace!("{c:?}");
237            c
238        }
239        .status()
240        .into_diagnostic()?;
241
242        if status.success() {
243            info!("Successfully built {}", opts.image);
244        } else {
245            bail!("Failed to build {}", opts.image);
246        }
247        Ok(())
248    }
249
250    fn tag(opts: TagOpts) -> Result<()> {
251        trace!("DockerDriver::tag({opts:#?})");
252
253        let dest_image_str = opts.dest_image.to_string();
254
255        let status = {
256            let c = cmd!("docker", "tag", opts.src_image.to_string(), &dest_image_str);
257            trace!("{c:?}");
258            c
259        }
260        .status()
261        .into_diagnostic()?;
262
263        if status.success() {
264            info!("Successfully tagged {}!", dest_image_str.bold().green());
265        } else {
266            bail!("Failed to tag image {}", dest_image_str.bold().red());
267        }
268        Ok(())
269    }
270
271    fn untag(opts: UntagOpts) -> Result<()> {
272        trace!("DockerDriver::untag({opts:#?})");
273
274        let ref_string = opts.image.to_string();
275
276        let status = {
277            let c = cmd!(
278                "docker",
279                "untag",
280                &ref_string, // identify image by reference
281                &ref_string, // remove this reference
282            );
283            trace!("{c:?}");
284            c
285        }
286        .status()
287        .into_diagnostic()?;
288
289        if status.success() {
290            info!("Successfully untagged {}", ref_string.bold().green());
291        } else {
292            bail!("Failed to untag image {}", ref_string.bold().red());
293        }
294        Ok(())
295    }
296
297    fn push(opts: PushOpts) -> Result<()> {
298        trace!("DockerDriver::push({opts:#?})");
299
300        let image_str = opts.image.to_string();
301
302        let status = {
303            let c = cmd!("docker", "push", &image_str);
304            trace!("{c:?}");
305            c
306        }
307        .status()
308        .into_diagnostic()?;
309
310        if status.success() {
311            info!("Successfully pushed {}!", image_str.bold().green());
312        } else {
313            bail!("Failed to push image {}", image_str.bold().red());
314        }
315        Ok(())
316    }
317
318    fn pull(opts: PullOpts) -> Result<ContainerId> {
319        trace!("DockerDriver::pull({opts:#?})");
320
321        let image_str = opts.image.to_string();
322
323        let mut command = cmd!(
324            "docker",
325            "pull",
326            "--quiet",
327            if let Some(platform) = opts.platform => format!("--platform={platform}"),
328            &image_str,
329        );
330
331        info!("Pulling image {image_str}...");
332
333        trace!("{command:?}");
334        let output = command.output().into_diagnostic()?;
335
336        if !output.status.success() {
337            bail!("Failed to pull image {}", image_str.bold().red());
338        }
339        info!("Successfully pulled image {}", image_str.bold().green());
340        let container_id = {
341            let mut stdout = output.stdout;
342            while stdout.pop_if(|byte| byte.is_ascii_whitespace()).is_some() {}
343            ContainerId(String::from_utf8(stdout).into_diagnostic()?)
344        };
345        Ok(container_id)
346    }
347
348    fn login(server: &str) -> Result<()> {
349        trace!("DockerDriver::login()");
350
351        if let Some(Credentials::Basic { username, password }) = Credentials::get(server) {
352            let output = pipe!(
353                stdin = password.value();
354                {
355                    let c = cmd!(
356                        "docker",
357                        "login",
358                        "-u",
359                        &username,
360                        "--password-stdin",
361                        server,
362                    );
363                    trace!("{c:?}");
364                    c
365                }
366            )
367            .output()
368            .into_diagnostic()?;
369
370            if !output.status.success() {
371                let err_out = String::from_utf8_lossy(&output.stderr);
372                bail!("Failed to login for docker:\n{}", err_out.trim());
373            }
374            debug!("Logged into {server}");
375        }
376
377        Ok(())
378    }
379
380    fn prune(opts: PruneOpts) -> Result<()> {
381        trace!("DockerDriver::prune({opts:?})");
382
383        let (system, buildx) = std::thread::scope(
384            |scope| -> std::thread::Result<(Result<ExitStatus>, Result<ExitStatus>)> {
385                let system = scope.spawn(|| {
386                    {
387                        let c = cmd!(
388                            "docker",
389                            "system",
390                            "prune",
391                            "--force",
392                            if opts.all => "--all",
393                            if opts.volumes => "--volumes",
394                        );
395                        trace!("{c:?}");
396                        c
397                    }
398                    .message_status("docker system prune", "Pruning Docker System")
399                    .into_diagnostic()
400                });
401
402                let buildx = scope.spawn(|| {
403                    Self::setup()?;
404
405                    {
406                        let c = cmd!(
407                            "docker",
408                            "buildx",
409                            "prune",
410                            "--force",
411                            format!("--builder={BLUE_BUILD}"),
412                            if opts.all => "--all",
413                        );
414                        trace!("{c:?}");
415                        c
416                    }
417                    .message_status("docker buildx prune", "Pruning Docker Buildx")
418                    .into_diagnostic()
419                });
420
421                Ok((system.join()?, buildx.join()?))
422            },
423        )
424        .map_err(|e| miette::miette!("{e:?}"))?;
425
426        if !system?.success() {
427            bail!("Failed to prune docker system");
428        }
429
430        if !buildx?.success() {
431            bail!("Failed to prune docker buildx");
432        }
433
434        Ok(())
435    }
436
437    fn manifest_create(opts: ManifestCreateOpts) -> Result<()> {
438        let output = {
439            let c = cmd!("docker", "manifest", "rm", opts.final_image.to_string());
440            trace!("{c:?}");
441            c
442        }
443        .output()
444        .into_diagnostic()?;
445
446        if output.status.success() {
447            warn!(
448                "Existing image manifest {} exists, removing...",
449                opts.final_image
450            );
451        }
452
453        let status = {
454            let c = cmd!(
455                "docker",
456                "manifest",
457                "create",
458                opts.final_image.to_string(),
459                for image in opts.image_list => image.to_string(),
460            );
461            trace!("{c:?}");
462            c
463        }
464        .status()
465        .into_diagnostic()?;
466
467        if !status.success() {
468            bail!("Failed to create manifest for {}", opts.final_image);
469        }
470
471        Ok(())
472    }
473
474    fn manifest_push(opts: ManifestPushOpts) -> Result<()> {
475        let status = {
476            let c = cmd!("docker", "manifest", "push", opts.final_image.to_string());
477            trace!("{c:?}");
478            c
479        }
480        .status()
481        .into_diagnostic()?;
482
483        if !status.success() {
484            bail!("Failed to create manifest for {}", opts.final_image);
485        }
486
487        Ok(())
488    }
489
490    fn build_tag_push(opts: BuildTagPushOpts) -> Result<Vec<String>> {
491        trace!("DockerDriver::build_tag_push({opts:#?})");
492
493        let temp_dir = TempDir::new()
494            .into_diagnostic()
495            .wrap_err("Failed to create temporary directory for secrets")?;
496
497        if opts.squash {
498            warn!("Squash is deprecated for docker so this build will not squash");
499        }
500
501        Self::setup()?;
502
503        let final_images = get_final_images(opts);
504
505        let first_image = final_images.first().unwrap();
506
507        let status = build_tag_push_cmd(opts, first_image, &temp_dir)?
508            .build_status(first_image, "Building Image")
509            .into_diagnostic()?;
510
511        if status.success() {
512            if opts.push {
513                info!("Successfully built and pushed image {first_image}");
514            } else {
515                info!("Successfully built image {first_image}");
516            }
517        } else {
518            bail!("Failed to build image {}", first_image);
519        }
520        Ok(final_images)
521    }
522}
523
524fn build_tag_push_cmd(
525    opts: BuildTagPushOpts<'_>,
526    first_image: &str,
527    temp_dir: &TempDir,
528) -> Result<Command> {
529    let c = cmd!(
530        "docker",
531        "buildx",
532        format!("--builder={BLUE_BUILD}"),
533        "build",
534        ".",
535        for opts.secrets.args(temp_dir)?,
536        if opts.secrets.ssh() => "--ssh",
537        match &opts.image {
538            ImageRef::Remote(_remote) if opts.push => [
539                "--output",
540                format!(
541                    "type=image,name={first_image},push=true,compression={},oci-mediatypes=true",
542                    opts.compression
543                ),
544            ],
545            ImageRef::Remote(_remote)
546                if get_env_var(GITHUB_ACTIONS).is_err()
547                && opts.platform.len() <= 1 => "--load",
548            ImageRef::LocalTar(archive_path) => [
549                "--output",
550                format!("type=oci,dest={}", archive_path.display()),
551            ],
552            _ => [],
553        },
554        for opts.image.remote_ref().map_or_else(Vec::new, |image| {
555                opts.tags.iter().flat_map(|tag| {
556                    vec![
557                        "-t".to_string(),
558                        format!("{}/{}:{tag}", image.resolve_registry(), image.repository())
559                    ]
560                }).collect()
561            }),
562        "--pull",
563        for platform in opts.platform => [
564            "--platform",
565            platform.to_string(),
566        ],
567        "-f",
568        opts.containerfile,
569        if let Some(cache_from) = opts.cache_from.as_ref() => [
570            "--cache-from",
571            format!(
572                "type=registry,ref={cache_from}",
573            ),
574        ],
575        if let Some(cache_to) = opts.cache_to.as_ref() => [
576            "--cache-to",
577            format!(
578                "type=registry,ref={cache_to},mode=max",
579            ),
580        ],
581    );
582    trace!("{c:?}");
583    Ok(c)
584}
585
586fn get_final_images(opts: BuildTagPushOpts<'_>) -> Vec<String> {
587    match &opts.image {
588        ImageRef::Remote(image) => {
589            if opts.tags.is_empty() {
590                let image = image.to_string();
591                string_vec![image]
592            } else {
593                opts.tags
594                    .iter()
595                    .map(|tag| format!("{}/{}:{tag}", image.resolve_registry(), image.repository()))
596                    .collect()
597            }
598        }
599        ImageRef::LocalTar(archive_path) => {
600            string_vec![archive_path.display().to_string()]
601        }
602        ImageRef::Other(other) => {
603            string_vec![&**other]
604        }
605    }
606}
607
608impl RunDriver for DockerDriver {
609    fn run(opts: RunOpts) -> Result<ExitStatus> {
610        trace!("DockerDriver::run({opts:#?})");
611
612        let cid_path = TempDir::new().into_diagnostic()?;
613        let cid_file = cid_path.path().join("cid");
614        let cid = ContainerSignalId::new(&cid_file, ContainerRuntime::Docker, false);
615
616        add_cid(&cid);
617
618        let status = docker_run(opts, &cid_file, false)
619            .build_status(opts.image, "Running container")
620            .into_diagnostic()?;
621
622        remove_cid(&cid);
623
624        Ok(status)
625    }
626
627    fn run_output(opts: RunOpts) -> Result<std::process::Output> {
628        trace!("DockerDriver::run_output({opts:#?})");
629
630        let cid_path = TempDir::new().into_diagnostic()?;
631        let cid_file = cid_path.path().join("cid");
632        let cid = ContainerSignalId::new(&cid_file, ContainerRuntime::Docker, false);
633
634        add_cid(&cid);
635
636        let output = docker_run(opts, &cid_file, false)
637            .output()
638            .into_diagnostic()?;
639
640        remove_cid(&cid);
641
642        Ok(output)
643    }
644
645    fn run_detached(opts: RunOpts) -> Result<DetachedContainer> {
646        trace!("DockerDriver::run_detached({opts:#?})");
647
648        let cid_path = TempDir::new().into_diagnostic()?;
649        let cid_file = cid_path.path().join("cid");
650
651        let cid = ContainerSignalId::new(&cid_file, ContainerRuntime::Docker, opts.privileged);
652        let run_cmd = docker_run(opts, &cid_file, true);
653
654        DetachedContainer::start(cid, run_cmd)
655    }
656
657    fn create_container(opts: CreateContainerOpts) -> Result<ContainerId> {
658        trace!("DockerDriver::create_container({opts:?})");
659
660        let output = {
661            let c = cmd!("docker", "create", opts.image.to_string(), "bash",);
662            trace!("{c:?}");
663            c
664        }
665        .output()
666        .into_diagnostic()?;
667
668        if !output.status.success() {
669            let err_out = String::from_utf8_lossy(&output.stderr);
670            bail!(
671                "Failed to create container from image {}:\n{}",
672                opts.image,
673                err_out.trim()
674            );
675        }
676
677        Ok(ContainerId(
678            String::from_utf8(output.stdout.trim_ascii().to_vec()).into_diagnostic()?,
679        ))
680    }
681
682    fn remove_container(opts: RemoveContainerOpts) -> Result<()> {
683        trace!("DockerDriver::remove_container({opts:?})");
684
685        let output = {
686            let c = cmd!("docker", "rm", opts.container_id);
687            trace!("{c:?}");
688            c
689        }
690        .output()
691        .into_diagnostic()?;
692
693        if !output.status.success() {
694            let err_out = String::from_utf8_lossy(&output.stderr);
695            bail!(
696                "Failed to remove container {}:\n{}",
697                opts.container_id,
698                err_out.trim()
699            );
700        }
701
702        Ok(())
703    }
704}
705
706impl ImageStorageDriver for DockerDriver {
707    fn remove_image(opts: RemoveImageOpts) -> Result<()> {
708        trace!("DockerDriver::remove_image({opts:?})");
709
710        let output = {
711            let c = cmd!("docker", "rmi", opts.image.to_string());
712            trace!("{c:?}");
713            c
714        }
715        .output()
716        .into_diagnostic()?;
717
718        if !output.status.success() {
719            let err_out = String::from_utf8_lossy(&output.stderr);
720            bail!(
721                "Failed to remove the image {}:\n{}",
722                opts.image,
723                err_out.trim()
724            );
725        }
726
727        Ok(())
728    }
729
730    fn list_images(_privileged: bool) -> Result<Vec<Reference>> {
731        #[derive(Deserialize, Debug)]
732        #[serde(rename_all = "PascalCase")]
733        struct Image {
734            repository: String,
735            tag: String,
736        }
737
738        let output = {
739            let c = cmd!("docker", "images", "--format", "json");
740            trace!("{c:?}");
741            c
742        }
743        .output()
744        .into_diagnostic()?;
745
746        if !output.status.success() {
747            let err_out = String::from_utf8_lossy(&output.stderr);
748            bail!("Failed to list images:\n{}", err_out.trim());
749        }
750
751        let images: Vec<Image> = String::from_utf8_lossy(&output.stdout)
752            .lines()
753            .map(|line| serde_json::from_str::<Image>(line).into_diagnostic())
754            .collect::<Result<_>>()?;
755
756        images
757            .into_iter()
758            .filter(|image| image.repository != "<none>" && image.tag != "<none>")
759            .map(|image| {
760                format!("{}:{}", image.repository, image.tag)
761                    .parse::<Reference>()
762                    .into_diagnostic()
763                    .with_context(|| format!("While parsing {image:?}"))
764            })
765            .collect()
766    }
767}
768
769fn docker_run(opts: RunOpts, cid_file: &Path, detach: bool) -> Command {
770    let command = cmd!(
771        "docker",
772        "run",
773        "--cidfile",
774        cid_file,
775        if opts.privileged => "--privileged",
776        if opts.remove => "--rm",
777        if detach => "--detach",
778        if opts.pull => "--pull=always",
779        if let Some(user) = opts.user.as_ref() => format!("--user={user}"),
780        for RunOptsVolume { path_or_vol_name, container_path } in opts.volumes.iter() => [
781            "--volume",
782            format!("{path_or_vol_name}:{container_path}"),
783        ],
784        for RunOptsEnv { key, value } in opts.env_vars.iter() => [
785            "--env",
786            format!("{key}={value}"),
787        ],
788        opts.image,
789        for arg in opts.args.iter() => &**arg,
790    );
791    trace!("{command:?}");
792
793    command
794}