1use std::ffi::OsString;
12use std::path::PathBuf;
13use std::sync::Arc;
14
15use qframe::widgets::TerminalSession;
16
17use crate::engine::EngineKind;
18use crate::store::Platform;
19
20use super::gates::EngineProblem;
21
22const DOCKER_SERVICE: &str = "sudo systemctl enable --now docker";
24
25const DOCKER_GROUP: &str = "sudo usermod -aG docker $USER";
27
28const PODMAN_DOCS: &str = "https://podman.io/docs/installation";
31const DOCKER_DOCS: &str = "https://docs.docker.com/engine/install/";
33
34#[derive(Debug, Clone, Copy, PartialEq, Eq)]
36pub enum PackageManager {
37 Paru,
39 Pacman,
41 Apt,
43 Dnf,
45 Zypper,
47 Brew,
49 Winget,
51}
52
53impl PackageManager {
54 fn install(self, package: &str) -> String {
56 match self {
57 Self::Paru => format!("paru -S {package}"),
58 Self::Pacman => format!("sudo pacman -S {package}"),
59 Self::Apt => format!("sudo apt install {package}"),
60 Self::Dnf => format!("sudo dnf install {package}"),
61 Self::Zypper => format!("sudo zypper install {package}"),
62 Self::Brew if package == "docker" => "brew install --cask docker".to_owned(),
64 Self::Brew => format!("brew install {package}"),
65 Self::Winget => format!("winget install {package}"),
66 }
67 }
68
69 fn package(self, kind: EngineKind) -> &'static str {
72 match (self, kind) {
73 (Self::Winget, EngineKind::Podman) => "RedHat.Podman",
74 (Self::Winget, EngineKind::Docker) => "Docker.DockerDesktop",
75 (Self::Apt, EngineKind::Docker) => "docker.io",
76 (_, EngineKind::Podman) => "podman",
77 (_, EngineKind::Docker) => "docker",
78 }
79 }
80}
81
82#[derive(Debug, Clone, PartialEq, Eq)]
87pub struct InstallHost {
88 pub platform: Platform,
90 pub manager: Option<PackageManager>,
92 pub engines: Vec<EngineKind>,
96}
97
98const ENGINES: [EngineKind; 2] = [EngineKind::Podman, EngineKind::Docker];
100
101impl InstallHost {
102 #[must_use]
104 pub fn detect() -> Self {
105 let platform = Platform::host();
106 let release = (platform == Platform::Linux).then(|| std::fs::read_to_string("/etc/os-release").ok()).flatten();
107 Self::read(platform, release.as_deref(), crate::engine::installed)
113 }
114
115 #[must_use]
120 pub fn read(platform: Platform, release: Option<&str>, installed: impl Fn(&str) -> bool) -> Self {
121 let manager = match platform {
122 Platform::MacOs => Some(PackageManager::Brew),
123 Platform::Windows => Some(PackageManager::Winget),
124 Platform::Linux => family(release.unwrap_or_default()).map(|family| match family {
127 PackageManager::Pacman if installed("paru") => PackageManager::Paru,
128 manager => manager,
129 }),
130 };
131 let engines = ENGINES.into_iter().filter(|kind| installed(kind.dialect().binary)).collect();
132 Self { platform, manager, engines }
133 }
134}
135
136#[derive(Clone)]
143pub struct Installer(Arc<Start>);
144
145type Start = dyn Fn(&str) -> std::io::Result<TerminalSession> + Send + Sync;
148
149impl std::fmt::Debug for Installer {
150 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
151 formatter.write_str("Installer")
152 }
153}
154
155impl Installer {
156 #[must_use]
160 pub fn shell() -> Self {
161 Self::new(|command| {
162 let (program, flag): (OsString, &str) = if cfg!(windows) {
163 (OsString::from("cmd"), "/C")
164 } else {
165 (std::env::var_os("SHELL").unwrap_or_else(|| "/bin/sh".into()), "-c")
166 };
167 let folder = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
168 TerminalSession::spawn(&program, &[flag, command], &folder)
169 })
170 }
171
172 #[must_use]
174 pub fn new(start: impl Fn(&str) -> std::io::Result<TerminalSession> + Send + Sync + 'static) -> Self {
175 Self(Arc::new(start))
176 }
177
178 pub fn start(&self, command: &str) -> std::io::Result<TerminalSession> {
184 (self.0)(command)
185 }
186}
187
188fn family(release: &str) -> Option<PackageManager> {
194 let mut ids = Vec::new();
195 for line in release.lines() {
196 let Some((key, value)) = line.split_once('=') else { continue };
197 if !matches!(key.trim(), "ID" | "ID_LIKE") {
198 continue;
199 }
200 let value = value.trim().trim_matches('"').trim_matches('\'');
201 ids.extend(value.split_whitespace().map(str::to_lowercase));
202 }
203 ids.iter().find_map(|id| match id.as_str() {
204 "arch" | "archlinux" | "manjaro" | "endeavouros" | "cachyos" | "garuda" => Some(PackageManager::Pacman),
205 "debian" | "ubuntu" | "linuxmint" | "pop" | "raspbian" => Some(PackageManager::Apt),
206 "fedora" | "rhel" | "centos" | "rocky" | "almalinux" => Some(PackageManager::Dnf),
207 "opensuse" | "opensuse-tumbleweed" | "opensuse-leap" | "suse" | "sles" => Some(PackageManager::Zypper),
208 _ => None,
209 })
210}
211
212#[derive(Debug, Clone, PartialEq, Eq)]
218pub enum Remedy {
219 Install {
221 command: Option<String>,
224 docs: &'static str,
226 relogin: bool,
228 },
229 Start {
231 command: Option<String>,
233 },
234 Grant {
237 command: String,
239 relogin: bool,
241 },
242 Relogin,
245 Unknown,
247}
248
249impl Remedy {
250 #[must_use]
252 pub fn runnable(&self) -> Option<&str> {
253 match self {
254 Self::Install { command, .. } | Self::Start { command } => command.as_deref(),
255 Self::Grant { command, .. } => Some(command),
256 Self::Relogin | Self::Unknown => None,
257 }
258 }
259
260 #[must_use]
262 pub fn for_problem(problem: &EngineProblem, kind: EngineKind, host: &InstallHost) -> Self {
263 match problem {
264 EngineProblem::NotInstalled | EngineProblem::NotRunnable { .. } => Self::Install {
265 command: host.manager.map(|manager| {
266 let install = manager.install(manager.package(kind));
267 match (kind, host.platform) {
271 (EngineKind::Docker, Platform::Linux) => {
272 format!("{install} && {DOCKER_SERVICE} && {DOCKER_GROUP}")
273 }
274 _ => install,
275 }
276 }),
277 docs: match kind {
278 EngineKind::Podman => PODMAN_DOCS,
279 EngineKind::Docker => DOCKER_DOCS,
280 },
281 relogin: kind == EngineKind::Docker && host.platform == Platform::Linux,
282 },
283 EngineProblem::NoPermission { in_group: true, .. } => Self::Relogin,
284 EngineProblem::NoPermission { in_group: false, .. } => {
285 Self::Grant { command: DOCKER_GROUP.to_owned(), relogin: true }
286 }
287 EngineProblem::NoIdRanges { line, .. } => Self::Grant { command: line.clone(), relogin: false },
288 EngineProblem::DaemonStopped { .. } => Self::Start {
289 command: match host.platform {
290 Platform::Linux => Some(DOCKER_SERVICE.to_owned()),
292 Platform::MacOs => Some("open -a Docker".to_owned()),
293 Platform::Windows => None,
296 },
297 },
298 EngineProblem::MachineStopped { .. } => Self::Start { command: Some("podman machine start".to_owned()) },
300 EngineProblem::Refused { .. } => Self::Unknown,
301 }
302 }
303}
304
305const FIRST_SUBORDINATE_ID: u64 = 100_000;
309const SUBORDINATE_IDS: u64 = 65_536;
311
312#[derive(Debug, Clone, PartialEq, Eq)]
318pub struct IdRanges {
319 pub user: String,
321 pub from_env: bool,
326 pub uid: u32,
328 pub subuid: String,
330 pub subgid: String,
332}
333
334impl IdRanges {
335 #[must_use]
339 pub fn here() -> Self {
340 #[cfg(unix)]
341 let uid = {
342 use std::os::unix::fs::MetadataExt;
343 std::fs::metadata("/proc/self").map(|meta| meta.uid()).unwrap_or(u32::MAX)
344 };
345 #[cfg(not(unix))]
346 let uid = u32::MAX;
347 let environment =
348 std::env::var("USER").or_else(|_| std::env::var("LOGNAME")).ok().filter(|name| !name.is_empty());
349 Self::of(
350 environment.as_deref(),
351 uid,
352 &std::fs::read_to_string("/etc/passwd").unwrap_or_default(),
353 &std::fs::read_to_string("/etc/subuid").unwrap_or_default(),
354 &std::fs::read_to_string("/etc/subgid").unwrap_or_default(),
355 )
356 }
357
358 #[must_use]
367 pub fn of(environment: Option<&str>, uid: u32, passwd: &str, subuid: &str, subgid: &str) -> Self {
368 let (user, from_env) = match environment {
369 Some(name) if !name.is_empty() => (name.to_owned(), true),
370 _ => (passwd_name(passwd, uid).unwrap_or_default(), false),
371 };
372 Self { user, from_env, uid, subuid: subuid.to_owned(), subgid: subgid.to_owned() }
373 }
374
375 #[must_use]
377 pub fn present(&self) -> bool {
378 self.has(&self.subuid) && self.has(&self.subgid)
379 }
380
381 fn has(&self, text: &str) -> bool {
382 let uid = self.uid.to_string();
383 entries(text).any(|(owner, _, count)| (owner == self.user || owner == uid) && count > 0)
384 }
385
386 #[must_use]
394 pub fn line(&self) -> String {
395 let taken = entries(&self.subuid).chain(entries(&self.subgid)).map(|(_, start, count)| start + count).max();
396 let start = taken.unwrap_or(0).max(FIRST_SUBORDINATE_ID);
397 let end = start + SUBORDINATE_IDS - 1;
398 let account = if self.from_env { "$USER" } else { self.user.as_str() };
399 format!(
400 "sudo usermod --add-subuids {start}-{end} --add-subgids {start}-{end} {account} && podman system migrate"
401 )
402 }
403}
404
405fn passwd_name(passwd: &str, uid: u32) -> Option<String> {
412 passwd.lines().find_map(|line| {
413 let mut fields = line.split(':');
414 let name = fields.next()?;
415 let _password = fields.next()?;
416 (fields.next()?.trim().parse::<u32>().ok()? == uid).then_some(name.to_owned())
417 })
418}
419
420fn entries(text: &str) -> impl Iterator<Item = (&str, u64, u64)> {
422 text.lines().filter_map(|line| {
423 let mut parts = line.trim().split(':');
424 let owner = parts.next()?;
425 let start = parts.next()?.trim().parse().ok()?;
426 let count = parts.next()?.trim().parse().ok()?;
427 (!owner.is_empty() && !owner.starts_with('#')).then_some((owner, start, count))
428 })
429}
430
431#[cfg(test)]
432mod tests {
433 use super::*;
434 use crate::engine::EngineKind;
435 use crate::store::Platform;
436
437 fn linux(id: &str, paru: bool) -> InstallHost {
439 let text = format!("NAME=\"Something\"\nID={id}\n");
440 InstallHost::read(Platform::Linux, Some(&text), |tool| paru && tool == "paru")
441 }
442
443 fn install(host: &InstallHost, kind: EngineKind) -> Option<String> {
444 match Remedy::for_problem(&EngineProblem::NotInstalled, kind, host) {
445 Remedy::Install { command, .. } => command,
446 other => panic!("a missing engine is installed, not {other:?}"),
447 }
448 }
449
450 fn ranges(subuid: &str, subgid: &str) -> IdRanges {
452 IdRanges::of(Some("ada"), 1001, "", subuid, subgid)
453 }
454
455 fn passwd() -> &'static str {
457 "root:x:0:0:root:/root:/bin/bash\nnot an account\nalice:x:1000:1000:Alice:/home/alice:/bin/bash\n"
458 }
459
460 #[test]
461 fn an_account_has_ranges_only_when_both_files_name_it() {
462 assert!(ranges("ada:100000:65536\n", "ada:100000:65536\n").present());
463 assert!(ranges("1001:100000:65536\n", "ada:100000:65536\n").present());
465 assert!(!ranges("ada:100000:65536\n", "").present(), "a group range is needed too");
466 assert!(!ranges("bob:100000:65536\n", "bob:100000:65536\n").present(), "another account's range");
467 assert!(!ranges("ada:100000:0\n", "ada:100000:0\n").present(), "a range of nothing");
468 }
469
470 #[test]
471 fn the_name_a_uid_has_in_passwd_is_found_past_a_line_that_does_not_parse() {
472 assert_eq!(passwd_name(passwd(), 1000).as_deref(), Some("alice"));
473 assert_eq!(passwd_name(passwd(), 0).as_deref(), Some("root"));
474 assert_eq!(passwd_name(passwd(), 4242), None, "a uid the file holds no line for names nobody");
475 assert_eq!(passwd_name("", 0), None, "a passwd file that could not be read names nobody");
476 }
477
478 #[test]
479 fn an_account_the_environment_leaves_unnamed_is_found_in_passwd() {
480 let ranges = IdRanges::of(None, 1000, passwd(), "alice:100000:65536\n", "alice:100000:65536\n");
484 assert_eq!(ranges.user, "alice", "the uid is the one thing qcode knows of its own account");
485 assert!(ranges.present());
486 assert_eq!(IdRanges::of(Some(""), 1000, passwd(), "", "").user, "alice", "an empty name is no name");
487 }
488
489 #[test]
490 fn the_range_added_starts_after_every_range_already_handed_out() {
491 assert_eq!(
492 ranges("", "").line(),
493 "sudo usermod --add-subuids 100000-165535 --add-subgids 100000-165535 $USER && podman system migrate"
494 );
495 let crowded = ranges("bob:100000:65536\n# a note\ncarol:165536:65536\n", "bob:100000:65536\n");
496 assert!(
497 crowded.line().contains("--add-subuids 231072-296607 --add-subgids 231072-296607"),
498 "{}",
499 crowded.line()
500 );
501 }
502
503 #[test]
504 fn the_line_names_the_account_the_way_the_shell_that_runs_it_will_know_it() {
505 let from_passwd = IdRanges::of(None, 1000, passwd(), "", "");
506 let line = from_passwd.line();
507 assert!(line.contains(" --add-subgids 100000-165535 alice &&"), "{line}");
508 assert!(!line.contains("$USER"), "a shell as bare as the one qcode was started from has none: {line}");
509 assert!(ranges("", "").line().contains("$USER"), "a name the environment gave is left to the shell");
510 }
511
512 #[test]
513 fn arch_installs_with_paru_when_it_is_there() {
514 let host = linux("arch", true);
515 assert_eq!(install(&host, EngineKind::Podman).as_deref(), Some("paru -S podman"));
516 }
517
518 #[test]
519 fn arch_falls_back_to_pacman_when_paru_is_not_installed() {
520 let host = linux("arch", false);
521 assert_eq!(install(&host, EngineKind::Podman).as_deref(), Some("sudo pacman -S podman"));
522 }
523
524 #[test]
525 fn a_distribution_is_recognised_by_what_it_is_like() {
526 let text = "ID=linuxmint\nID_LIKE=\"ubuntu debian\"\n";
528 let host = InstallHost::read(Platform::Linux, Some(text), |_| false);
529 assert_eq!(install(&host, EngineKind::Podman).as_deref(), Some("sudo apt install podman"));
530 }
531
532 #[test]
533 fn debian_installs_docker_under_the_name_debian_gives_it() {
534 let host = linux("debian", false);
535 let line = install(&host, EngineKind::Docker).expect("a line");
536 assert!(line.starts_with("sudo apt install docker.io && "), "{line}");
537 }
538
539 #[test]
540 fn docker_on_linux_is_installed_started_and_let_in_by_one_line() {
541 let host = linux("arch", true);
542 assert_eq!(
543 install(&host, EngineKind::Docker).as_deref(),
544 Some("paru -S docker && sudo systemctl enable --now docker && sudo usermod -aG docker $USER")
545 );
546 let Remedy::Install { relogin, .. } =
547 Remedy::for_problem(&EngineProblem::NotInstalled, EngineKind::Docker, &host)
548 else {
549 panic!("a missing engine is installed")
550 };
551 assert!(relogin, "the new group only holds after a new login, and the person is told so");
552 assert_eq!(install(&host, EngineKind::Podman).as_deref(), Some("paru -S podman"));
554 let mac = InstallHost::read(Platform::MacOs, None, |_| false);
556 assert_eq!(install(&mac, EngineKind::Docker).as_deref(), Some("brew install --cask docker"));
557 }
558
559 #[test]
560 fn an_account_docker_will_not_let_in_is_added_to_the_group_or_only_asked_to_log_in_again() {
561 let host = linux("arch", true);
562 let outside = EngineProblem::NoPermission { output: String::new(), in_group: false };
563 let remedy = Remedy::for_problem(&outside, EngineKind::Docker, &host);
564 assert_eq!(remedy, Remedy::Grant { command: "sudo usermod -aG docker $USER".to_owned(), relogin: true });
565 assert_eq!(remedy.runnable(), Some("sudo usermod -aG docker $USER"));
566 let inside = EngineProblem::NoPermission { output: String::new(), in_group: true };
567 assert_eq!(Remedy::for_problem(&inside, EngineKind::Docker, &host), Remedy::Relogin);
568 assert_eq!(Remedy::Relogin.runnable(), None, "no command logs anyone out");
569 }
570
571 #[test]
572 fn fedora_and_its_family_install_with_dnf() {
573 assert_eq!(install(&linux("fedora", false), EngineKind::Podman).as_deref(), Some("sudo dnf install podman"));
574 let rocky = InstallHost::read(Platform::Linux, Some("ID=rocky\nID_LIKE=\"rhel centos fedora\"\n"), |_| false);
575 assert!(
576 install(&rocky, EngineKind::Docker).is_some_and(|line| line.starts_with("sudo dnf install docker && "))
577 );
578 }
579
580 #[test]
581 fn opensuse_installs_with_zypper() {
582 let host = linux("opensuse-tumbleweed", false);
583 assert_eq!(install(&host, EngineKind::Podman).as_deref(), Some("sudo zypper install podman"));
584 }
585
586 #[test]
587 fn a_system_qcode_does_not_know_is_sent_to_the_engines_own_guide() {
588 let host = InstallHost::read(Platform::Linux, None, |_| false);
589 let Remedy::Install { command, docs, .. } =
590 Remedy::for_problem(&EngineProblem::NotInstalled, EngineKind::Podman, &host)
591 else {
592 panic!("a missing engine is installed")
593 };
594 assert_eq!(command, None, "no command is invented for a package manager nobody found");
595 assert!(docs.starts_with("https://"), "the guide is a link the person can open: {docs}");
596 }
597
598 #[test]
599 fn macos_installs_with_homebrew_and_knows_docker_is_an_application() {
600 let host = InstallHost::read(Platform::MacOs, None, |_| false);
601 assert_eq!(install(&host, EngineKind::Podman).as_deref(), Some("brew install podman"));
602 assert_eq!(install(&host, EngineKind::Docker).as_deref(), Some("brew install --cask docker"));
603 }
604
605 #[test]
606 fn windows_installs_with_winget() {
607 let host = InstallHost::read(Platform::Windows, None, |_| false);
608 assert_eq!(install(&host, EngineKind::Podman).as_deref(), Some("winget install RedHat.Podman"));
609 assert_eq!(install(&host, EngineKind::Docker).as_deref(), Some("winget install Docker.DockerDesktop"));
610 }
611
612 #[test]
613 fn a_stopped_docker_daemon_is_started_the_way_each_system_starts_it() {
614 let problem = EngineProblem::DaemonStopped { output: String::new() };
615 let linux = Remedy::for_problem(&problem, EngineKind::Docker, &linux("debian", false));
616 assert!(matches!(linux, Remedy::Start { command: Some(ref c) } if c == "sudo systemctl enable --now docker"));
617
618 let mac = InstallHost::read(Platform::MacOs, None, |_| false);
619 let mac = Remedy::for_problem(&problem, EngineKind::Docker, &mac);
620 assert!(matches!(mac, Remedy::Start { command: Some(ref c) } if c == "open -a Docker"));
621
622 let windows = InstallHost::read(Platform::Windows, None, |_| false);
625 let windows = Remedy::for_problem(&problem, EngineKind::Docker, &windows);
626 assert!(matches!(windows, Remedy::Start { command: None }));
627 }
628
629 #[test]
630 fn a_podman_machine_that_is_not_running_is_started_the_same_way_everywhere() {
631 let problem = EngineProblem::MachineStopped { output: String::new() };
632 let host = InstallHost::read(Platform::MacOs, None, |_| false);
633 let remedy = Remedy::for_problem(&problem, EngineKind::Podman, &host);
634 assert!(matches!(remedy, Remedy::Start { command: Some(ref c) } if c == "podman machine start"));
635 }
636
637 #[test]
638 fn the_engines_this_machine_already_has_are_read_with_the_rest_of_it() {
639 let both = InstallHost::read(Platform::Linux, Some("ID=arch\n"), |tool| matches!(tool, "podman" | "docker"));
640 assert_eq!(both.engines, [EngineKind::Podman, EngineKind::Docker], "podman is named before docker");
641 let docker = InstallHost::read(Platform::Linux, Some("ID=arch\n"), |tool| tool == "docker");
642 assert_eq!(docker.engines, [EngineKind::Docker]);
643 let bare = InstallHost::read(Platform::Linux, Some("ID=arch\n"), |_| false);
644 assert!(bare.engines.is_empty(), "a machine with neither engine says so");
645 }
646
647 #[test]
648 fn a_refusal_qcode_cannot_read_offers_no_command_at_all() {
649 let host = linux("arch", true);
650 let problem = EngineProblem::Refused { code: Some(125), output: "no tty".to_owned() };
651 assert!(matches!(Remedy::for_problem(&problem, EngineKind::Podman, &host), Remedy::Unknown));
652 }
653}