1use crate::Error;
4use std::{process::Stdio, time::Duration};
5use subxt::ext::futures::TryFutureExt;
6use tokio::{process::Command, time::timeout};
7
8pub enum Docker {
10 NotInstalled,
12 Installed,
14 Running,
16}
17
18impl Docker {
19 pub async fn ensure_running() -> Result<(), Error> {
21 match Self::detect_docker().await? {
22 Docker::Running => Ok(()),
23 Docker::Installed => {
24 Self::try_start().await?;
25 Self::wait_for_ready().await?;
26 Ok(())
27 },
28 Docker::NotInstalled => Err(Error::Docker(
29 "Docker is not installed. Install from: https://docs.docker.com/get-docker/"
30 .to_string(),
31 )),
32 }
33 }
34
35 async fn detect_docker() -> Result<Self, Error> {
36 let mut child = match Command::new("docker")
37 .arg("info")
38 .stdout(Stdio::null())
39 .stderr(Stdio::null())
40 .spawn()
41 {
42 Ok(c) => c,
43 Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
44 return Ok(Docker::NotInstalled);
45 },
46 Err(err) => return Err(Error::Docker(err.to_string())),
47 };
48
49 match timeout(Duration::from_secs(5), child.wait()).await {
50 Ok(Ok(status)) =>
51 if status.success() {
52 Ok(Docker::Running)
53 } else {
54 Ok(Docker::Installed)
55 },
56 Ok(Err(err)) => Err(Error::Docker(err.to_string())),
57 Err(_) => {
58 let _ = child.kill().await;
60 Ok(Docker::Installed)
61 },
62 }
63 }
64
65 async fn try_start() -> Result<(), Error> {
67 #[cfg(target_os = "macos")]
68 return Self::try_start_macos().await;
69
70 #[cfg(target_os = "linux")]
71 return Self::try_start_linux().await;
72
73 #[cfg(not(any(target_os = "macos", target_os = "linux")))]
74 Ok(())
75 }
76
77 #[allow(dead_code)] async fn try_start_macos() -> Result<(), Error> {
79 Command::new("open")
81 .args(["-a", "Docker"])
82 .spawn()
83 .map_err(|_err| {
84 Error::Docker("Failed to start Docker. Please start it manually.".to_owned())
85 })?
86 .wait()
87 .await?;
88
89 Ok(())
90 }
91
92 #[allow(dead_code)] async fn try_start_linux() -> Result<(), Error> {
94 if !crate::helpers::is_root() {
96 let args = std::env::args().skip(1).collect::<Vec<String>>().join(" ");
97 return Err(Error::Docker(format!(
98 "Docker is not running. Please run `sudo $(which pop) {}` to allow pop to initialize it, or start it manually.",
99 args
100 )));
101 }
102
103 Command::new("systemctl").args(["start", "docker"]).status().await.map_or_else(
105 |_| {
106 Err(Error::Docker(
107 "Failed to start Docker automatically. Please start it manually.".to_string(),
108 ))
109 },
110 |status| {
111 if status.success() {
112 Ok(())
113 } else {
114 Err(Error::Docker(
115 "Failed to start Docker automatically. Please start it manually."
116 .to_string(),
117 ))
118 }
119 },
120 )
121 }
122
123 async fn wait_for_ready() -> Result<(), Error> {
125 for _i in 0..30 {
126 tokio::time::sleep(Duration::from_secs(1)).await;
127
128 if matches!(Self::detect_docker().await?, Docker::Running) {
129 return Ok(());
130 }
131 }
132
133 Err(Error::Docker(
134 "Docker failed to start within 30 seconds. Please start it manually.".to_string(),
135 ))
136 }
137
138 pub async fn pull_image(image: &str, tag: &str) -> Result<(), Error> {
144 match Self::detect_docker().await? {
146 Docker::Running => {},
147 _ => return Err(Error::Docker("Docker is not running.".to_string())),
148 }
149
150 let image_with_tag = format!("{}:{}", image, tag);
151
152 let output = Command::new("docker")
153 .args(["pull", &image_with_tag])
154 .output()
155 .map_err(|e| Error::Docker(format!("Failed to pull image: {}", e)))
156 .await?;
157
158 if !output.status.success() {
159 return Err(Error::Docker(format!(
160 "Failed to pull image {}: {}",
161 image_with_tag,
162 String::from_utf8_lossy(&output.stderr)
163 )));
164 }
165
166 Ok(())
167 }
168
169 pub async fn get_image_digest(image: &str, tag: &str) -> Result<String, Error> {
176 match Self::detect_docker().await? {
178 Docker::Running => {},
179 _ => return Err(Error::Docker("Docker is not running.".to_string())),
180 }
181
182 let image_with_tag = format!("{}:{}", image, tag);
183
184 let mut output = Command::new("docker")
185 .args(["image", "inspect", "--format={{.RepoDigests}}", &image_with_tag])
186 .output()
187 .map_err(|e| Error::Docker(format!("Failed to inspect image: {}", e)))
188 .await?;
189
190 if !output.status.success() {
192 Self::pull_image(image, tag).await?;
193
194 output = Command::new("docker")
196 .args(["image", "inspect", "--format={{.RepoDigests}}", &image_with_tag])
197 .output()
198 .map_err(|e| Error::Docker(format!("Failed to inspect image: {}", e)))
199 .await?;
200
201 if !output.status.success() {
202 return Err(Error::Docker(format!(
203 "Failed to inspect image {} after pulling: {}",
204 image_with_tag,
205 String::from_utf8_lossy(&output.stderr)
206 )));
207 }
208 }
209
210 let output_str = String::from_utf8(output.stdout)
211 .map_err(|e| Error::Docker(format!("Invalid UTF-8 in docker output: {}", e)))?;
212
213 let digest = output_str
215 .trim()
216 .trim_start_matches('[')
217 .trim_end_matches(']')
218 .split('@')
219 .nth(1)
220 .ok_or_else(|| Error::Docker("Could not parse digest from docker output.".to_string()))?
221 .to_string();
222
223 Ok(digest)
224 }
225}
226
227pub async fn fetch_image_tag(url: &str) -> Result<String, Error> {
232 let response = reqwest::get(url)
233 .await
234 .map_err(|e| Error::Docker(format!("Failed to fetch image tag: {}", e)))?;
235
236 if !response.status().is_success() {
237 return Err(Error::Docker(format!(
238 "Failed to fetch image tag from {}: HTTP {}",
239 url,
240 response.status()
241 )));
242 }
243
244 let tag = response
245 .text()
246 .await
247 .map_err(|e| Error::Docker(format!("Failed to read response body: {}", e)))?;
248
249 Ok(tag.trim().to_string())
250}
251
252#[cfg(test)]
253mod tests {
254 use super::*;
255 use crate::command_mock::CommandMock;
256
257 #[tokio::test]
258 async fn detect_docker_docker_running() {
259 CommandMock::default()
260 .with_command("docker", 0)
261 .execute(async || {
262 assert!(matches!(Docker::detect_docker().await, Ok(Docker::Running)));
263 })
264 .await;
265 }
266
267 #[tokio::test]
268 async fn detect_docker_docker_installed() {
269 CommandMock::default()
270 .with_command("docker", 1)
271 .execute(async || {
272 assert!(matches!(Docker::detect_docker().await, Ok(Docker::Installed)));
273 })
274 .await;
275 }
276
277 #[tokio::test]
278 async fn detect_docker_docker_not_installed() {
279 CommandMock::default()
280 .execute_isolated(async || {
281 assert!(matches!(Docker::detect_docker().await, Ok(Docker::NotInstalled)));
282 })
283 .await;
284 }
285
286 #[tokio::test]
287 async fn detect_docker_docker_fails() {
288 CommandMock::default().with_non_permissioned_command("docker").execute_isolated(async || {
289 assert!(matches!(Docker::detect_docker().await, Err(Error::Docker(err)) if err == "Permission denied (os error 13)"));
290 }).await;
291 }
292
293 #[tokio::test]
294 async fn ensure_running_when_already_running() {
295 CommandMock::default()
296 .with_command("docker", 0)
297 .execute(async || {
298 assert!(Docker::ensure_running().await.is_ok());
299 })
300 .await;
301 }
302
303 #[tokio::test]
304 async fn ensure_running_when_not_installed() {
305 CommandMock::default().execute_isolated(async || {
306 assert!(matches!(Docker::ensure_running().await, Err(Error::Docker(err)) if err == "Docker is not installed. Install from: https://docs.docker.com/get-docker/"));
307 }).await;
308 }
309
310 #[tokio::test]
311 #[cfg(target_os = "macos")]
312 async fn ensure_running_starts_docker_on_macos() {
313 let command_mock = CommandMock::default();
314 let started_marker = command_mock.fake_path().join("docker_started");
315 let docker_script = format!(
316 r#"#!/bin/sh
317if [ -f "{}" ]; then
318 exit 0
319else
320 exit 1
321fi"#,
322 started_marker.display()
323 );
324 let open_script = format!(
325 r#"#!/bin/sh
326> "{}"
327"#,
328 started_marker.display()
329 );
330
331 command_mock
332 .with_command_script("docker", &docker_script)
333 .with_command_script("open", &open_script)
334 .execute(async || {
335 assert!(Docker::ensure_running().await.is_ok());
336 })
337 .await;
338 }
339
340 #[tokio::test]
341 #[cfg(target_os = "linux")]
342 async fn ensure_running_starts_docker_on_linux_as_root() {
343 let command_mock = CommandMock::default();
344 let started_marker = command_mock.fake_path().join("docker_started");
345 let docker_script = format!(
346 r#"#!/bin/sh
347if [ -f "{}" ]; then
348 exit 0
349else
350 exit 1
351fi"#,
352 started_marker.display()
353 );
354 let systemctl_script = format!(
355 r#"#!/bin/sh
356> "{}"
357"#,
358 started_marker.display()
359 );
360
361 command_mock
362 .with_command_script("docker", &docker_script)
363 .with_command_script(
364 "id",
365 r#"#!/bin/sh
366echo 0"#,
367 ) .with_command_script("systemctl", &systemctl_script)
369 .execute(async || {
370 assert!(Docker::ensure_running().await.is_ok());
371 })
372 .await;
373 }
374
375 #[tokio::test]
376 async fn try_start_macos_succeeds_with_open_command() {
377 CommandMock::default()
378 .with_command("open", 0)
379 .execute_sync(async || {
380 assert!(Docker::try_start_macos().await.is_ok());
381 })
382 .await;
383 }
384
385 #[tokio::test]
386 async fn try_start_macos_fails_without_open_command() {
387 CommandMock::default()
388 .execute_isolated(async || {
389 assert!(matches!(
390 Docker::try_start_macos().await,
391 Err(
392 Error::Docker(err)
393 ) if err == "Failed to start Docker. Please start it manually."
394 ));
395 })
396 .await;
397 }
398
399 #[tokio::test]
400 async fn try_start_linux_fails_when_not_root() {
401 CommandMock::default()
402 .with_command_script("id", r#"#!/bin/sh
403echo 1000"#) .execute(async || {
405 let args = std::env::args().skip(1).collect::<Vec<String>>().join(" ");
407 assert!(matches!(
408 Docker::try_start_linux().await,
409 Err(Error::Docker(err))
410 if err == format!("Docker is not running. Please run `sudo $(which pop) {}` to allow pop to initialize it, or start it manually.", args)
411 ));
412 }).await;
413 }
414
415 #[tokio::test]
416 async fn try_start_linux_succeeds_as_root_with_systemctl() {
417 CommandMock::default()
418 .with_command_script(
419 "id",
420 r#"#!/bin/sh
421echo 0"#,
422 ) .with_command("systemctl", 0) .execute(async || {
425 assert!(Docker::try_start_linux().await.is_ok());
426 })
427 .await;
428 }
429
430 #[tokio::test]
431 async fn try_start_linux_fails_as_root_when_systemctl_fails() {
432 CommandMock::default()
433 .with_command_script(
434 "id",
435 r#"#!/bin/sh
436echo 0"#,
437 ) .with_command("systemctl", 1) .execute(async || {
440 assert!(matches!(
441 Docker::try_start_linux().await,
442 Err(Error::Docker(err))
443 if err == "Failed to start Docker automatically. Please start it manually."
444 ));
445 })
446 .await;
447 }
448
449 #[tokio::test]
450 async fn wait_for_ready_succeeds_when_docker_starts() {
451 let command_mock = CommandMock::default();
452 let started_marker = command_mock.fake_path().join("docker_started");
453 let docker_script = format!(
454 r#"#!/bin/sh
455if [ -f "{}" ]; then
456 exit 0
457else
458 exit 1
459fi"#,
460 started_marker.display()
461 );
462
463 command_mock
464 .with_command_script("docker", &docker_script)
465 .execute(async || {
466 std::fs::write(&started_marker, "").unwrap();
468
469 assert!(Docker::wait_for_ready().await.is_ok());
470 })
471 .await;
472 }
473
474 #[tokio::test(start_paused = true)]
475 async fn wait_for_ready_times_out_when_docker_never_starts() {
476 CommandMock::default().with_command("docker", 1).execute(async || {
477 assert!(matches!(Docker::wait_for_ready().await, Err(Error::Docker(err)) if err == "Docker failed to start within 30 seconds. Please start it manually."));
478 }).await;
479 }
480
481 #[tokio::test]
482 async fn pull_image_succeeds_when_docker_running() {
483 CommandMock::default()
484 .with_command("docker", 0)
485 .execute(async || {
486 assert!(Docker::pull_image("test/image", "latest").await.is_ok());
487 })
488 .await;
489 }
490
491 #[tokio::test]
492 async fn pull_image_fails_when_docker_not_running() {
493 CommandMock::default()
494 .with_command("docker", 1)
495 .execute(async || {
496 assert!(matches!(
497 Docker::pull_image("test/image", "latest").await,
498 Err(Error::Docker(err)) if err == "Docker is not running."
499 ));
500 })
501 .await;
502 }
503
504 #[tokio::test]
505 async fn pull_image_fails_when_pull_command_fails() {
506 let command_mock = CommandMock::default();
507 let docker_info_script = r#"#!/bin/sh
508if [ "$1" = "info" ]; then
509 exit 0;
510else
511 exit 1;
512fi"#;
513
514 command_mock
515 .with_command_script("docker", docker_info_script)
516 .execute(async || {
517 assert!(matches!(
518 Docker::pull_image("test/image", "latest").await,
519 Err(Error::Docker(err)) if err.contains("Failed to pull image")
520 ));
521 })
522 .await;
523 }
524
525 #[tokio::test]
526 async fn get_image_digest_succeeds_with_local_image() {
527 let command_mock = CommandMock::default();
528 let docker_script = r#"#!/bin/sh
529if [ "$1" = "info" ]; then
530 exit 0
531elif [ "$1" = "image" ] && [ "$2" = "inspect" ]; then
532 echo "[test/image@sha256:abcd1234]"
533 exit 0
534fi
535exit 1"#;
536
537 command_mock
538 .with_command_script("docker", docker_script)
539 .execute(async || {
540 let result = Docker::get_image_digest("test/image", "latest").await;
541 assert!(result.is_ok());
542 assert_eq!(result.unwrap(), "sha256:abcd1234");
543 })
544 .await;
545 }
546
547 #[tokio::test]
548 async fn get_image_digest_pulls_and_succeeds_when_image_not_local() {
549 let command_mock = CommandMock::default();
550 let pulled_marker = command_mock.fake_path().join("image_pulled");
551 let docker_script = format!(
552 r#"#!/bin/sh
553if [ "$1" = "info" ]; then
554 exit 0
555elif [ "$1" = "pull" ]; then
556 > "{}"
557 exit 0
558elif [ "$1" = "image" ] && [ "$2" = "inspect" ]; then
559 if [ -f "{}" ]; then
560 echo "[test/image@sha256:abcd1234]"
561 exit 0
562 else
563 exit 1
564 fi
565fi
566exit 1"#,
567 pulled_marker.display(),
568 pulled_marker.display()
569 );
570
571 command_mock
572 .with_command_script("docker", &docker_script)
573 .execute(async || {
574 let result = Docker::get_image_digest("test/image", "latest").await;
575 assert!(result.is_ok());
576 assert_eq!(result.unwrap(), "sha256:abcd1234");
577 })
578 .await;
579 }
580
581 #[tokio::test]
582 async fn get_image_digest_fails_when_docker_not_running() {
583 CommandMock::default()
584 .with_command("docker", 1)
585 .execute(async || {
586 assert!(matches!(
587 Docker::get_image_digest("test/image", "latest").await,
588 Err(Error::Docker(err)) if err == "Docker is not running."
589 ));
590 })
591 .await;
592 }
593
594 #[tokio::test]
595 async fn get_image_digest_fails_when_image_cannot_be_pulled() {
596 let command_mock = CommandMock::default();
597 let docker_script = r#"#!/bin/sh
598if [ "$1" = "info" ]; then
599 exit 0
600elif [ "$1" = "pull" ]; then
601 exit 1
602fi
603exit 1"#;
604
605 command_mock
606 .with_command_script("docker", docker_script)
607 .execute(async || {
608 assert!(matches!(
609 Docker::get_image_digest("test/image", "nonexistent").await,
610 Err(Error::Docker(err)) if err.contains("Failed to pull image")
611 ));
612 })
613 .await;
614 }
615
616 #[tokio::test]
617 async fn get_image_digest_pulls_and_fails_if_inspect_fails_after_pulling() {
618 let command_mock = CommandMock::default();
619 let pulled_marker = command_mock.fake_path().join("image_pulled");
620 let docker_script = format!(
621 r#"#!/bin/sh
622if [ "$1" = "info" ]; then
623 exit 0
624elif [ "$1" = "pull" ]; then
625 exit 0
626elif [ "$1" = "image" ] && [ "$2" = "inspect" ]; then
627 if [ -f "{}" ]; then
628 echo "[test/image@sha256:abcd1234]"
629 exit 0
630 else
631 exit 1
632 fi
633fi
634exit 1"#,
635 pulled_marker.display()
636 );
637
638 command_mock.with_command_script("docker", &docker_script).execute(async || {
639 assert!(matches!(Docker::get_image_digest("test/image", "latest").await, Err(Error::Docker(err)) if err.contains("Failed to inspect image") && err.contains("after pulling")));
640 }).await;
641 }
642
643 #[tokio::test]
644 async fn get_image_digest_fails_when_output_has_no_at_symbol() {
645 let command_mock = CommandMock::default();
646 let docker_script = r#"#!/bin/sh
647if [ "$1" = "info" ]; then
648 exit 0
649elif [ "$1" = "image" ] && [ "$2" = "inspect" ]; then
650 echo "[test/image-no-digest]"
651 exit 0
652fi
653exit 1"#;
654
655 command_mock
656 .with_command_script("docker", docker_script)
657 .execute(async || {
658 assert!(matches!(
659 Docker::get_image_digest("test/image", "latest").await,
660 Err(Error::Docker(err)) if err == "Could not parse digest from docker output."
661 ));
662 })
663 .await;
664 }
665
666 #[tokio::test]
667 async fn get_image_digest_fails_when_output_has_invalid_utf8() {
668 let command_mock = CommandMock::default();
669 let docker_script = r#"#!/bin/sh
670if [ "$1" = "info" ]; then
671 exit 0
672elif [ "$1" = "image" ] && [ "$2" = "inspect" ]; then
673 printf '\377\376'
674 exit 0
675fi
676exit 1"#;
677
678 command_mock
679 .with_command_script("docker", docker_script)
680 .execute(async || {
681 assert!(matches!(
682 Docker::get_image_digest("test/image", "latest").await,
683 Err(Error::Docker(err)) if err.contains("Invalid UTF-8 in docker output")
684 ));
685 })
686 .await;
687 }
688
689 #[tokio::test]
690 async fn fetch_image_tag_succeeds() {
691 let mut server = mockito::Server::new_async().await;
692 let mock = server
693 .mock("GET", "/")
694 .with_status(200)
695 .with_body("1.70.0\n")
696 .create_async()
697 .await;
698
699 let result = fetch_image_tag(&server.url()).await;
700 mock.assert_async().await;
701 assert!(result.is_ok());
702 assert_eq!(result.unwrap(), "1.70.0");
703 }
704
705 #[tokio::test]
706 async fn fetch_image_tag_fails_on_http_error() {
707 let mut server = mockito::Server::new_async().await;
708 let mock = server.mock("GET", "/").with_status(404).create_async().await;
709
710 let result = fetch_image_tag(&server.url()).await;
711 mock.assert_async().await;
712 assert!(matches!(
713 result,
714 Err(Error::Docker(err)) if err.contains("Failed to fetch image tag") && err.contains("404")
715 ));
716 }
717
718 #[tokio::test]
719 async fn fetch_image_tag_fails_on_network_error() {
720 let result = fetch_image_tag("http://invalid-url-that-does-not-exist-12345.com").await;
721 assert!(matches!(
722 result,
723 Err(Error::Docker(err)) if err.contains("Failed to fetch image tag")
724 ));
725 }
726}