Skip to main content

pop_common/
docker.rs

1// SPDX-License-Identifier: GPL-3.0
2
3use crate::Error;
4use std::{process::Stdio, time::Duration};
5use subxt::ext::futures::TryFutureExt;
6use tokio::{process::Command, time::timeout};
7
8/// Represents the state of Docker in the user's machine
9pub enum Docker {
10	/// Docker isn't installed
11	NotInstalled,
12	/// Docker is installed but not running
13	Installed,
14	/// Docker is already running
15	Running,
16}
17
18impl Docker {
19	/// Ensures Docker is running. If installed but not running, attempts to start it.
20	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				// Timeout reached, kill the child process
59				let _ = child.kill().await;
60				Ok(Docker::Installed)
61			},
62		}
63	}
64
65	/// Attempts to start Docker based on the platform.
66	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)] // Fine as depending on the platform it might be called or not
78	async fn try_start_macos() -> Result<(), Error> {
79		// Try start docker using Docker Desktop.
80		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)] // Fine as depending on the platform it might be called or not
93	async fn try_start_linux() -> Result<(), Error> {
94		// Check if running as root
95		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		// Try to start Docker with systemctl
104		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	/// Waits for Docker daemon to be ready (polls for up to 30 seconds)
124	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	/// Pulls a Docker image. Requires Docker to be running.
139	///
140	/// # Arguments
141	/// * `image` - The image name.
142	/// * `tag` - The image tag.
143	pub async fn pull_image(image: &str, tag: &str) -> Result<(), Error> {
144		// Check if Docker is running
145		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	/// Gets the digest of a Docker image. Requires Docker to be running.
170	/// If the image is not available locally, it will be pulled automatically.
171	///
172	/// # Arguments
173	/// * `image` - The image name.
174	/// * `tag` - The image tag.
175	pub async fn get_image_digest(image: &str, tag: &str) -> Result<String, Error> {
176		// Check if Docker is running
177		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 inspect fails, try pulling the image first
191		if !output.status.success() {
192			Self::pull_image(image, tag).await?;
193
194			// Retry inspect after pulling
195			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		// Parse the digest from the output format: [image@sha256:...]
214		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
227/// Fetches the latest tag for a Docker image from a URL.
228///
229/// # Arguments
230/// * `url` - The URL to fetch the tag from.
231pub 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			) // root user
368			.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"#) // non-root user
404			.execute(async || {
405                // Cannot assert too much about this, depending on how tests are called, args will contain different values
406                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			) // root user
423			.with_command("systemctl", 0) // systemctl succeeds
424			.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			) // root user
438			.with_command("systemctl", 1) // systemctl fails
439			.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				// Create the marker file to simulate Docker starting
467				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}