Skip to main content

podbox/
quadlet_install.rs

1use std::path::{Path, PathBuf};
2
3use anyhow::{Context, Result};
4use nix::fcntl::{Flock, FlockArg};
5
6use crate::codegen::quadlet;
7use crate::config::{self, Config};
8use crate::env::HostEnv;
9use crate::podman::{PodmanVersion, podman_version};
10use crate::systemd;
11use crate::xdg::ResolvedXdgDirs;
12
13/// Directory for user Quadlet source files.
14pub fn quadlet_dir() -> PathBuf {
15    dirs::config_dir()
16        .unwrap_or_else(|| config::expand_tilde("~/.config"))
17        .join("containers/systemd")
18}
19
20/// Flat install path: `~/.config/containers/systemd/<name>.container`.
21pub fn flat_container_path(name: &str) -> PathBuf {
22    quadlet_dir().join(format!("{name}.container"))
23}
24
25/// Application-scoped install path (Podman 6 directory/`--application` layout):
26/// `~/.config/containers/systemd/<name>/<name>.container`.
27pub fn application_container_path(name: &str) -> PathBuf {
28    quadlet_dir().join(name).join(format!("{name}.container"))
29}
30
31/// True if a `.container` Quadlet exists in either flat or application layout.
32pub fn is_installed(name: &str) -> bool {
33    container_unit_path(name).is_some()
34}
35
36/// Path to the installed `.container` unit, if any (flat preferred, then app dir).
37pub fn container_unit_path(name: &str) -> Option<PathBuf> {
38    let flat = flat_container_path(name);
39    if flat.exists() {
40        return Some(flat);
41    }
42    let app = application_container_path(name);
43    if app.exists() {
44        return Some(app);
45    }
46    None
47}
48
49/// Names of installed `.container` units under the Quadlet dir (flat + one app level).
50pub fn list_installed_names() -> Vec<String> {
51    let qdir = quadlet_dir();
52    let mut names = Vec::new();
53
54    let Ok(entries) = std::fs::read_dir(&qdir) else {
55        return names;
56    };
57
58    for entry in entries.flatten() {
59        let path = entry.path();
60        if path.extension().is_some_and(|e| e == "container") {
61            if let Some(stem) = path.file_stem().and_then(|s| s.to_str()) {
62                names.push(stem.to_string());
63            }
64            continue;
65        }
66        // Application subdir: <name>/<name>.container
67        if path.is_dir() {
68            let Some(dir_name) = path.file_name().and_then(|s| s.to_str()) else {
69                continue;
70            };
71            let nested = path.join(format!("{dir_name}.container"));
72            if nested.exists() {
73                names.push(dir_name.to_string());
74            }
75        }
76    }
77
78    names.sort();
79    names.dedup();
80    names
81}
82
83/// Directory for user systemd unit files.
84fn systemd_user_dir() -> PathBuf {
85    dirs::config_dir()
86        .unwrap_or_else(|| config::expand_tilde("~/.config"))
87        .join("systemd/user")
88}
89
90/// Write custom systemd units (socket, host-service, optional dbus-proxy
91/// and compositor) to sdir.
92fn write_custom_units(
93    name: &str,
94    sdir: &Path,
95    socket_content: &str,
96    host_service_content: &str,
97    dbus_proxy_content: Option<&str>,
98    compositor_service_content: Option<&str>,
99) -> Result<()> {
100    std::fs::create_dir_all(sdir)?;
101    std::fs::write(sdir.join(format!("{name}.socket")), socket_content)?;
102    std::fs::write(
103        sdir.join(format!("{name}-host.service")),
104        host_service_content,
105    )?;
106    if let Some(proxy) = dbus_proxy_content {
107        std::fs::write(sdir.join(format!("{name}-proxy.service")), proxy)?;
108    }
109    if let Some(comp) = compositor_service_content {
110        std::fs::write(sdir.join(format!("{name}-compositor.service")), comp)?;
111    }
112    write_clean_stop_dropin(name, sdir)?;
113    Ok(())
114}
115
116/// Container service units are generated by Quadlet, which cannot express
117/// `SuccessExitStatus`. When the guest's idle timer fires, the host stops the
118/// unit via `systemctl stop`; systemd SIGTERMs the container, the main process
119/// exits 143, and the unit would otherwise land in `failed`. A drop-in marks
120/// SIGTERM (and a graceful 0 exit) as a clean stop so idle shutdown settles in
121/// `inactive` instead of `failed`.
122fn write_clean_stop_dropin(name: &str, sdir: &Path) -> Result<()> {
123    let dir = sdir.join(format!("{name}.service.d"));
124    std::fs::create_dir_all(&dir)?;
125    std::fs::write(
126        dir.join("99-podbox-clean-stop.conf"),
127        "[Service]\nSuccessExitStatus=0 143 SIGTERM SIGINT\n",
128    )?;
129    Ok(())
130}
131
132/// Activate custom units after Quadlet files are in place.
133fn finalize_units(
134    name: &str,
135    sdir: &Path,
136    socket_content: &str,
137    host_service_content: &str,
138    dbus_proxy_content: Option<&str>,
139    compositor_service_content: Option<&str>,
140    use_wayland_proxy: bool,
141) -> Result<()> {
142    write_custom_units(
143        name,
144        sdir,
145        socket_content,
146        host_service_content,
147        dbus_proxy_content,
148        compositor_service_content,
149    )?;
150    println!("Systemd units installed to {}", sdir.display());
151
152    systemd::daemon_reload()?;
153    systemd::reset_failed(name)?;
154    systemd::stop_socket_and_host(name)?;
155    if use_wayland_proxy {
156        systemd::stop_compositor(name)?;
157    }
158    systemd::enable_now_socket(name)?;
159    Ok(())
160}
161
162/// Install `.container` (+ optional `.build`) via `podman quadlet install` using
163/// **file** arguments, not a directory.
164///
165/// Keeps the flat layout (`…/systemd/<name>.container`) for Podman 5.6–5.x.
166fn podman_quadlet_install_files(
167    name: &str,
168    container_content: &str,
169    build_content: Option<&str>,
170) -> Result<()> {
171    let tmp = std::env::temp_dir().join(format!("podbox-install-{name}"));
172    let _ = std::fs::remove_dir_all(&tmp);
173    std::fs::create_dir_all(&tmp)?;
174
175    let container_path = tmp.join(format!("{name}.container"));
176    std::fs::write(&container_path, container_content)?;
177
178    let mut args: Vec<std::ffi::OsString> = vec![
179        "quadlet".into(),
180        "install".into(),
181        "--replace".into(),
182        container_path.into(),
183    ];
184
185    if let Some(bc) = build_content {
186        let build_path = tmp.join(format!("{name}.build"));
187        std::fs::write(&build_path, bc)?;
188        args.push(build_path.into());
189    }
190
191    let output = crate::process::run_piped("podman", &args)?;
192    let _ = std::fs::remove_dir_all(&tmp);
193    if !output.status.success() {
194        let stderr = String::from_utf8_lossy(&output.stderr);
195        anyhow::bail!("podman quadlet install failed: {stderr}");
196    }
197    println!("Quadlet files installed via podman quadlet install.");
198    Ok(())
199}
200
201/// Install `.container` (+ optional `.build`) via `podman quadlet install` using
202/// **directory** arguments with `--application` for Podman 6.x.
203///
204/// Podman 6 requires `--application` when the source is a directory. The units
205/// end up at `…/systemd/<name>/<name>.container`.
206fn podman_quadlet_install_application(
207    name: &str,
208    container_content: &str,
209    build_content: Option<&str>,
210) -> Result<()> {
211    let tmp = std::env::temp_dir().join(format!("podbox-install-{name}"));
212    let _ = std::fs::remove_dir_all(&tmp);
213    std::fs::create_dir_all(&tmp)?;
214
215    std::fs::write(tmp.join(format!("{name}.container")), container_content)?;
216    if let Some(bc) = build_content {
217        std::fs::write(tmp.join(format!("{name}.build")), bc)?;
218    }
219
220    let args: Vec<std::ffi::OsString> = vec![
221        "quadlet".into(),
222        "install".into(),
223        "--replace".into(),
224        "--application".into(),
225        name.into(),
226        tmp.clone().into(),
227    ];
228
229    let output = crate::process::run_piped("podman", &args)?;
230    let _ = std::fs::remove_dir_all(&tmp);
231    if !output.status.success() {
232        let stderr = String::from_utf8_lossy(&output.stderr);
233        anyhow::bail!("podman quadlet install --application failed: {stderr}");
234    }
235    println!("Quadlet files installed via podman quadlet install --application {name}.");
236    Ok(())
237}
238
239/// Best-effort removal of leftover flat unit files (`.container`, `.build`).
240///
241/// Called before a `--application` install to avoid dual installs from old
242/// flat layouts.
243fn remove_flat_units(name: &str) {
244    let qdir = quadlet_dir();
245    for ext in ["container", "build"] {
246        let path = qdir.join(format!("{name}.{ext}"));
247        if !path.exists() {
248            continue;
249        }
250        // Best-effort podman quadlet rm first, then manual delete as fallback.
251        let args: Vec<std::ffi::OsString> = vec![
252            "quadlet".into(),
253            "rm".into(),
254            format!("{name}.{ext}").into(),
255        ];
256        let _ = crate::process::run_piped("podman", &args);
257        // Manual fallback in case podman rm failed.
258        let _ = std::fs::remove_file(&path);
259    }
260}
261
262/// Best-effort removal of leftover application-scoped install dirs.
263fn remove_application_dir(name: &str) {
264    let app_dir = quadlet_dir().join(name);
265    if app_dir.is_dir() {
266        let _ = std::fs::remove_dir_all(&app_dir);
267    }
268}
269
270/// Validate that mount paths referenced in extra mounts exist on the host.
271fn preflight_check(config: &Config) -> Result<()> {
272    let name = &config.container.name;
273
274    // Check home directory
275    if !config.container.home.exists() {
276        eprintln!(
277            "  Note: home directory '{}' will be created (does not exist yet).",
278            config.container.home.display()
279        );
280    }
281
282    // Parse extra mounts and check host paths
283    for mount in &config.container.mounts.extra {
284        let host_path = match mount.split_once(':') {
285            Some((host, _)) => host,
286            None => mount,
287        };
288        let path = std::path::Path::new(host_path);
289        if !path.exists() {
290            if crate::codegen::distros::is_tty() {
291                let prompt = format!(
292                    "Mount path '{}' does not exist on the host. Create it?",
293                    path.display()
294                );
295                let create =
296                    dialoguer::Confirm::with_theme(&dialoguer::theme::ColorfulTheme::default())
297                        .with_prompt(prompt)
298                        .default(true)
299                        .interact_opt()?;
300                if create == Some(true) {
301                    std::fs::create_dir_all(path).with_context(|| {
302                        format!("failed to create mount directory '{}'", path.display())
303                    })?;
304                    println!("✓ Directory '{}' created.", path.display());
305                } else {
306                    eprintln!(
307                        "Warning: mount path '{}' does not exist on the host. This may cause the container to fail to load.",
308                        path.display()
309                    );
310                }
311            } else {
312                eprintln!(
313                    "Warning: mount path '{}' does not exist on the host (container '{}').",
314                    path.display(),
315                    name
316                );
317            }
318        }
319    }
320
321    // Intelligently check if container is running
322    let is_running = crate::podman::query_state(name)
323        .map(|state| state == crate::podman::ContainerState::Running)
324        .unwrap_or(false);
325
326    // Only run port bind tests if the container is stopped
327    if is_running {
328        println!(
329            "  Note: container '{name}' is running. Skipping port conflict checks for upgrade."
330        );
331        return Ok(());
332    }
333
334    // Check for port conflicts (IPv4 + IPv6, TCP + UDP)
335    let conflicts = crate::ports::check_host_ports(&config.network.ports);
336    if !conflicts.is_empty() {
337        let listed = conflicts
338            .iter()
339            .map(ToString::to_string)
340            .collect::<Vec<_>>()
341            .join(", ");
342        anyhow::bail!(
343            "Port conflict: already in use on the host — {listed}. \
344             Find the process with: `ss -ltnp 'sport = :<port>'`"
345        );
346    }
347
348    // Check admin cap_preset
349    if config.security.cap_preset == crate::config::CapPreset::Admin {
350        if crate::codegen::distros::is_tty() {
351            let caps = config.security.cap_preset.caps().join(", ");
352            let confirmed = dialoguer::Confirm::with_theme(
353                &dialoguer::theme::ColorfulTheme::default(),
354            )
355            .with_prompt(format!(
356                "WARNING: CapPreset::Admin grants {caps}. Only proceed if you fully trust this container. Continue?"
357            ))
358            .default(false)
359            .interact()?;
360            if !confirmed {
361                anyhow::bail!(
362                    "Aborted — set cap_preset to a lower level or use cap_add for specific caps"
363                );
364            }
365        } else {
366            let caps = config.security.cap_preset.caps().join(", ");
367            eprintln!(
368                "Note: cap_preset = \"admin\" grants {caps}. Non-interactive mode, continuing without confirmation."
369            );
370        }
371    }
372
373    Ok(())
374}
375
376/// Install systemd service and socket files for a container.
377pub fn install(config: &Config, env: &HostEnv, xdg: &ResolvedXdgDirs, dry_run: bool) -> Result<()> {
378    let name = &config.container.name;
379    let ver = podman_version().unwrap_or(PodmanVersion {
380        major: 5,
381        minor: 5,
382        patch: 0,
383    });
384    let qdir = quadlet_dir();
385    let sdir = systemd_user_dir();
386    let context_dir = crate::build::build_context_dir(name);
387    let containerfile_path = context_dir.join("Containerfile");
388
389    let socket_content = quadlet::generate_socket(config);
390    let container_content = quadlet::generate_container(config, env, xdg);
391    let host_service_content = quadlet::generate_host_service(name);
392    let dbus_proxy_content = quadlet::generate_dbus_proxy_service(name, config);
393    let compositor_service_content = quadlet::generate_compositor_service(name, config);
394
395    let build_content = if !config.image.source().is_prebuilt() {
396        Some(quadlet::generate_build(config, &containerfile_path))
397    } else {
398        None
399    };
400
401    if dry_run {
402        if let Some(ref bc) = build_content {
403            println!("=== {name}.build ===");
404            println!("{bc}");
405            println!();
406        }
407        println!("=== {name}.socket ===");
408        println!("{socket_content}");
409        println!();
410        println!("=== {name}.container ===");
411        println!("{container_content}");
412        println!();
413        println!("=== {name}-host.service ===");
414        println!("{host_service_content}");
415        if let Some(ref proxy) = dbus_proxy_content {
416            println!();
417            println!("=== {name}-proxy.service ===");
418            println!("{proxy}");
419        }
420        if let Some(ref comp) = compositor_service_content {
421            println!();
422            println!("=== {name}-compositor.service ===");
423            println!("{comp}");
424        }
425        return Ok(());
426    }
427
428    // Acquire exclusive install lock (auto-releases on panic/crash via kernel flock)
429    let _install_lock = {
430        let lock_path = context_dir.join(".install.lock");
431        let _ = std::fs::create_dir_all(&context_dir);
432        let file = std::fs::File::create(&lock_path).with_context(|| {
433            format!("failed to create install lock at '{}'", lock_path.display())
434        })?;
435        Flock::lock(file, FlockArg::LockExclusive).map_err(|(_, e)| e)?
436    };
437
438    // Ensure .flatpak-info is written to the host build directory
439    let _ = std::fs::create_dir_all(&context_dir);
440    std::fs::write(
441        context_dir.join(".flatpak-info"),
442        "[Application]\nname=podbox\n",
443    )?;
444
445    // Pre-flight validation
446    preflight_check(config)?;
447
448    // Ensure home and runtime dirs exist
449    std::fs::create_dir_all(&config.container.home).with_context(|| {
450        format!(
451            "failed to create home dir '{}'",
452            config.container.home.display()
453        )
454    })?;
455
456    if ver.at_least(6, 0) {
457        // 6.0+: use --application with directory install.
458        remove_flat_units(name);
459        podman_quadlet_install_application(name, &container_content, build_content.as_deref())?;
460    } else if ver.at_least(5, 6) {
461        // 5.6–5.x: install individual files for flat layout.
462        remove_application_dir(name);
463        podman_quadlet_install_files(name, &container_content, build_content.as_deref())?;
464    } else {
465        // 5.5 fallback: copy files manually
466        std::fs::create_dir_all(&qdir)?;
467        if let Some(ref bc) = build_content {
468            std::fs::write(qdir.join(format!("{name}.build")), bc)?;
469        }
470        std::fs::write(qdir.join(format!("{name}.container")), container_content)?;
471        println!("Quadlet files installed to {}", qdir.display());
472    }
473
474    finalize_units(
475        name,
476        &sdir,
477        &socket_content,
478        &host_service_content,
479        dbus_proxy_content.as_deref(),
480        compositor_service_content.as_deref(),
481        config.use_wayland_proxy(),
482    )?;
483
484    // Auto-export apps and bins
485    for app in &config.integration.export.apps {
486        if let Err(e) = crate::export::export_app(name, app) {
487            eprintln!("Warning: auto-export app '{app}' failed: {e}");
488        }
489    }
490    for bin in &config.integration.export.bins {
491        if let Err(e) = crate::export::export_bin(name, bin) {
492            eprintln!("Warning: auto-export bin '{bin}' failed: {e}");
493        }
494    }
495
496    if config.lifecycle.autostart {
497        systemd::enable_linger()?;
498    }
499
500    Ok(())
501}
502
503/// Remove Quadlet and systemd files for a container.
504pub fn uninstall(name: &str) -> Result<()> {
505    let ver = podman_version().unwrap_or(PodmanVersion {
506        major: 5,
507        minor: 5,
508        patch: 0,
509    });
510    let qdir = quadlet_dir();
511    let sdir = systemd_user_dir();
512
513    if ver.at_least(5, 6) {
514        let mut removed_via_podman = false;
515
516        // Flat units: only call rm when the file exists (avoids needing --ignore).
517        for ext in ["container", "build"] {
518            let path = qdir.join(format!("{name}.{ext}"));
519            if !path.exists() {
520                continue;
521            }
522            let args: Vec<std::ffi::OsString> = vec![
523                "quadlet".into(),
524                "rm".into(),
525                format!("{name}.{ext}").into(),
526            ];
527            let output = crate::process::run_piped("podman", &args)?;
528            if output.status.success() {
529                removed_via_podman = true;
530            } else {
531                // Fall through to manual delete below.
532                let stderr = String::from_utf8_lossy(&output.stderr);
533                eprintln!("Warning: podman quadlet rm {name}.{ext} failed: {stderr}");
534            }
535        }
536
537        // Application-scoped leftovers (directory install / --application).
538        let app_dir = qdir.join(name);
539        if app_dir.is_dir() {
540            if ver.at_least(6, 0) {
541                let args: Vec<std::ffi::OsString> = vec![
542                    "quadlet".into(),
543                    "rm".into(),
544                    "--recursive".into(),
545                    name.into(),
546                ];
547                let output = crate::process::run_piped("podman", &args)?;
548                if output.status.success() {
549                    removed_via_podman = true;
550                } else {
551                    let stderr = String::from_utf8_lossy(&output.stderr);
552                    eprintln!("Warning: podman quadlet rm --recursive {name} failed: {stderr}");
553                }
554            }
555            remove_application_dir(name);
556        }
557
558        // Manual cleanup of any remaining flat files.
559        for ext in ["build", "container"] {
560            let path = qdir.join(format!("{name}.{ext}"));
561            if path.exists() {
562                std::fs::remove_file(&path)?;
563            }
564        }
565
566        if removed_via_podman {
567            println!("Quadlet files removed via podman quadlet rm.");
568        }
569    } else {
570        // 5.5 fallback: remove files manually
571        for ext in ["build", "container"] {
572            let path = qdir.join(format!("{name}.{ext}"));
573            if path.exists() {
574                std::fs::remove_file(&path)?;
575            }
576        }
577        remove_application_dir(name);
578    }
579
580    // Remove custom systemd units
581    for unit in [
582        "socket",
583        "host.service",
584        "proxy.service",
585        "compositor.service",
586    ] {
587        let path = sdir.join(format!("{name}.{unit}"));
588        if path.exists() {
589            std::fs::remove_file(&path)?;
590        }
591    }
592
593    // Remove the clean-stop drop-in directory for the generated container unit.
594    let dropin_dir = sdir.join(format!("{name}.service.d"));
595    if dropin_dir.is_dir() {
596        std::fs::remove_dir_all(&dropin_dir)?;
597    }
598
599    systemd::daemon_reload()?;
600    println!("Files for '{name}' removed.");
601
602    Ok(())
603}
604
605#[cfg(test)]
606mod tests {
607    use super::*;
608
609    #[test]
610    fn flat_and_application_paths_differ() {
611        let flat = flat_container_path("myenv");
612        let app = application_container_path("myenv");
613        assert!(flat.to_string_lossy().ends_with("myenv.container"));
614        assert!(
615            app.to_string_lossy().ends_with("myenv/myenv.container")
616                || app.to_string_lossy().ends_with("myenv\\myenv.container")
617        );
618        assert_ne!(flat, app);
619    }
620}