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!("{}.socket", name)), socket_content)?;
102    std::fs::write(
103        sdir.join(format!("{}-host.service", name)),
104        host_service_content,
105    )?;
106    if let Some(proxy) = dbus_proxy_content {
107        std::fs::write(sdir.join(format!("{}-proxy.service", name)), proxy)?;
108    }
109    if let Some(comp) = compositor_service_content {
110        std::fs::write(sdir.join(format!("{}-compositor.service", name)), 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!("{}.service.d", name));
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.into(),
227    ];
228
229    let output = crate::process::run_piped("podman", &args)?;
230    let _ = std::fs::remove_dir_all(std::env::temp_dir().join(format!("podbox-install-{name}")));
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 '{}' is running. Skipping port conflict checks for upgrade.",
330            name
331        );
332        return Ok(());
333    }
334
335    // Check for port conflicts (IPv4 + IPv6, TCP + UDP)
336    let conflicts = crate::ports::check_host_ports(&config.network.ports);
337    if !conflicts.is_empty() {
338        let listed = conflicts
339            .iter()
340            .map(ToString::to_string)
341            .collect::<Vec<_>>()
342            .join(", ");
343        anyhow::bail!(
344            "Port conflict: already in use on the host — {listed}. \
345             Find the process with: `ss -ltnp 'sport = :<port>'`"
346        );
347    }
348
349    // Check admin cap_preset
350    if config.security.cap_preset == crate::config::CapPreset::Admin {
351        if crate::codegen::distros::is_tty() {
352            let caps = config.security.cap_preset.caps().join(", ");
353            let confirmed = dialoguer::Confirm::with_theme(
354                &dialoguer::theme::ColorfulTheme::default(),
355            )
356            .with_prompt(format!(
357                "WARNING: CapPreset::Admin grants {caps}. Only proceed if you fully trust this container. Continue?"
358            ))
359            .default(false)
360            .interact()?;
361            if !confirmed {
362                anyhow::bail!(
363                    "Aborted — set cap_preset to a lower level or use cap_add for specific caps"
364                );
365            }
366        } else {
367            let caps = config.security.cap_preset.caps().join(", ");
368            eprintln!(
369                "Note: cap_preset = \"admin\" grants {caps}. Non-interactive mode, continuing without confirmation."
370            );
371        }
372    }
373
374    Ok(())
375}
376
377/// Install systemd service and socket files for a container.
378pub fn install(config: &Config, env: &HostEnv, xdg: &ResolvedXdgDirs, dry_run: bool) -> Result<()> {
379    let name = &config.container.name;
380    let ver = podman_version().unwrap_or(PodmanVersion {
381        major: 5,
382        minor: 5,
383        patch: 0,
384    });
385    let qdir = quadlet_dir();
386    let sdir = systemd_user_dir();
387    let context_dir = crate::build::build_context_dir(name);
388    let containerfile_path = context_dir.join("Containerfile");
389
390    let socket_content = quadlet::generate_socket(config);
391    let container_content = quadlet::generate_container(config, env, xdg);
392    let host_service_content = quadlet::generate_host_service(name);
393    let dbus_proxy_content = quadlet::generate_dbus_proxy_service(name, config);
394    let compositor_service_content = quadlet::generate_compositor_service(name, config);
395
396    let build_content = if !config.image.source().is_prebuilt() {
397        Some(quadlet::generate_build(config, &containerfile_path))
398    } else {
399        None
400    };
401
402    if dry_run {
403        if let Some(ref bc) = build_content {
404            println!("=== {}.build ===", name);
405            println!("{}", bc);
406            println!();
407        }
408        println!("=== {}.socket ===", name);
409        println!("{}", socket_content);
410        println!();
411        println!("=== {}.container ===", name);
412        println!("{}", container_content);
413        println!();
414        println!("=== {}-host.service ===", name);
415        println!("{}", host_service_content);
416        if let Some(ref proxy) = dbus_proxy_content {
417            println!();
418            println!("=== {}-proxy.service ===", name);
419            println!("{}", proxy);
420        }
421        if let Some(ref comp) = compositor_service_content {
422            println!();
423            println!("=== {}-compositor.service ===", name);
424            println!("{}", comp);
425        }
426        return Ok(());
427    }
428
429    // Acquire exclusive install lock (auto-releases on panic/crash via kernel flock)
430    let _install_lock = {
431        let lock_path = context_dir.join(".install.lock");
432        let _ = std::fs::create_dir_all(&context_dir);
433        let file = std::fs::File::create(&lock_path).with_context(|| {
434            format!("failed to create install lock at '{}'", lock_path.display())
435        })?;
436        Flock::lock(file, FlockArg::LockExclusive).map_err(|(_, e)| e)?
437    };
438
439    // Ensure .flatpak-info is written to the host build directory
440    let _ = std::fs::create_dir_all(&context_dir);
441    std::fs::write(
442        context_dir.join(".flatpak-info"),
443        "[Application]\nname=podbox\n",
444    )?;
445
446    // Pre-flight validation
447    preflight_check(config)?;
448
449    // Ensure home and runtime dirs exist
450    std::fs::create_dir_all(&config.container.home).with_context(|| {
451        format!(
452            "failed to create home dir '{}'",
453            config.container.home.display()
454        )
455    })?;
456
457    if ver.at_least(6, 0) {
458        // 6.0+: use --application with directory install.
459        remove_flat_units(name);
460        podman_quadlet_install_application(name, &container_content, build_content.as_deref())?;
461    } else if ver.at_least(5, 6) {
462        // 5.6–5.x: install individual files for flat layout.
463        remove_application_dir(name);
464        podman_quadlet_install_files(name, &container_content, build_content.as_deref())?;
465    } else {
466        // 5.5 fallback: copy files manually
467        std::fs::create_dir_all(&qdir)?;
468        if let Some(ref bc) = build_content {
469            std::fs::write(qdir.join(format!("{name}.build")), bc)?;
470        }
471        std::fs::write(qdir.join(format!("{name}.container")), container_content)?;
472        println!("Quadlet files installed to {}", qdir.display());
473    }
474
475    finalize_units(
476        name,
477        &sdir,
478        &socket_content,
479        &host_service_content,
480        dbus_proxy_content.as_deref(),
481        compositor_service_content.as_deref(),
482        config.use_wayland_proxy(),
483    )?;
484
485    // Auto-export apps and bins
486    for app in &config.integration.export.apps {
487        if let Err(e) = crate::export::export_app(name, app) {
488            eprintln!("Warning: auto-export app '{}' failed: {}", app, e);
489        }
490    }
491    for bin in &config.integration.export.bins {
492        if let Err(e) = crate::export::export_bin(name, bin) {
493            eprintln!("Warning: auto-export bin '{}' failed: {}", bin, e);
494        }
495    }
496
497    if config.lifecycle.autostart {
498        systemd::enable_linger()?;
499    }
500
501    Ok(())
502}
503
504/// Remove Quadlet and systemd files for a container.
505pub fn uninstall(name: &str) -> Result<()> {
506    let ver = podman_version().unwrap_or(PodmanVersion {
507        major: 5,
508        minor: 5,
509        patch: 0,
510    });
511    let qdir = quadlet_dir();
512    let sdir = systemd_user_dir();
513
514    if ver.at_least(5, 6) {
515        let mut removed_via_podman = false;
516
517        // Flat units: only call rm when the file exists (avoids needing --ignore).
518        for ext in ["container", "build"] {
519            let path = qdir.join(format!("{name}.{ext}"));
520            if !path.exists() {
521                continue;
522            }
523            let args: Vec<std::ffi::OsString> = vec![
524                "quadlet".into(),
525                "rm".into(),
526                format!("{name}.{ext}").into(),
527            ];
528            let output = crate::process::run_piped("podman", &args)?;
529            if output.status.success() {
530                removed_via_podman = true;
531            } else {
532                // Fall through to manual delete below.
533                let stderr = String::from_utf8_lossy(&output.stderr);
534                eprintln!("Warning: podman quadlet rm {name}.{ext} failed: {stderr}");
535            }
536        }
537
538        // Application-scoped leftovers (directory install / --application).
539        let app_dir = qdir.join(name);
540        if app_dir.is_dir() {
541            if ver.at_least(6, 0) {
542                let args: Vec<std::ffi::OsString> = vec![
543                    "quadlet".into(),
544                    "rm".into(),
545                    "--recursive".into(),
546                    name.into(),
547                ];
548                let output = crate::process::run_piped("podman", &args)?;
549                if output.status.success() {
550                    removed_via_podman = true;
551                } else {
552                    let stderr = String::from_utf8_lossy(&output.stderr);
553                    eprintln!("Warning: podman quadlet rm --recursive {name} failed: {stderr}");
554                }
555            }
556            remove_application_dir(name);
557        }
558
559        // Manual cleanup of any remaining flat files.
560        for ext in ["build", "container"] {
561            let path = qdir.join(format!("{name}.{ext}"));
562            if path.exists() {
563                std::fs::remove_file(&path)?;
564            }
565        }
566
567        if removed_via_podman {
568            println!("Quadlet files removed via podman quadlet rm.");
569        }
570    } else {
571        // 5.5 fallback: remove files manually
572        for ext in ["build", "container"] {
573            let path = qdir.join(format!("{name}.{ext}"));
574            if path.exists() {
575                std::fs::remove_file(&path)?;
576            }
577        }
578        remove_application_dir(name);
579    }
580
581    // Remove custom systemd units
582    for unit in [
583        "socket",
584        "host.service",
585        "proxy.service",
586        "compositor.service",
587    ] {
588        let path = sdir.join(format!("{name}.{unit}"));
589        if path.exists() {
590            std::fs::remove_file(&path)?;
591        }
592    }
593
594    // Remove the clean-stop drop-in directory for the generated container unit.
595    let dropin_dir = sdir.join(format!("{name}.service.d"));
596    if dropin_dir.is_dir() {
597        std::fs::remove_dir_all(&dropin_dir)?;
598    }
599
600    systemd::daemon_reload()?;
601    println!("Files for '{name}' removed.");
602
603    Ok(())
604}
605
606#[cfg(test)]
607mod tests {
608    use super::*;
609
610    #[test]
611    fn flat_and_application_paths_differ() {
612        let flat = flat_container_path("myenv");
613        let app = application_container_path("myenv");
614        assert!(flat.to_string_lossy().ends_with("myenv.container"));
615        assert!(
616            app.to_string_lossy().ends_with("myenv/myenv.container")
617                || app.to_string_lossy().ends_with("myenv\\myenv.container")
618        );
619        assert_ne!(flat, app);
620    }
621}