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 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, &ref_string, );
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}