Skip to main content

podbox/
quadlet_install.rs

1//! Quadlet/systemd install and uninstall for a container definition.
2//!
3//! Slim dispatcher module; path discovery lives in [`paths`], unit writing in
4//! [`units`], and pre-install validation in [`preflight`].
5
6pub mod paths;
7pub mod preflight;
8pub mod units;
9
10pub use paths::{is_installed, list_installed_names};
11
12use anyhow::{Context, Result};
13use nix::fcntl::{Flock, FlockArg};
14
15use crate::codegen::quadlet;
16use crate::config::Config;
17use crate::env::HostEnv;
18use crate::podman::{PodmanVersion, podman_version};
19use crate::systemd;
20use crate::xdg::ResolvedXdgDirs;
21
22use paths::{quadlet_dir, systemd_user_dir};
23use preflight::preflight_check;
24use units::{
25    finalize_units, podman_quadlet_install_application, podman_quadlet_install_files,
26    remove_application_dir, remove_flat_units,
27};
28
29/// Install systemd service and socket files for a container.
30pub fn install(config: &Config, env: &HostEnv, xdg: &ResolvedXdgDirs, dry_run: bool) -> Result<()> {
31    let name = &config.container.name;
32    let ver = podman_version().unwrap_or(PodmanVersion {
33        major: 5,
34        minor: 5,
35        patch: 0,
36    });
37    let qdir = quadlet_dir();
38    let sdir = systemd_user_dir();
39    let context_dir = crate::build::build_context_dir(name);
40    let containerfile_path = context_dir.join("Containerfile");
41
42    let socket_content = quadlet::generate_socket(config);
43    let container_content = quadlet::generate_container(config, env, xdg);
44    let host_service_content = quadlet::generate_host_service(name);
45    let dbus_proxy_content = quadlet::generate_dbus_proxy_service(name, config);
46    let compositor_service_content = quadlet::generate_compositor_service(name, config);
47
48    let build_content = if !config.image.source().is_prebuilt() {
49        Some(quadlet::generate_build(config, &containerfile_path))
50    } else {
51        None
52    };
53
54    if dry_run {
55        if let Some(ref bc) = build_content {
56            println!("=== {name}.build ===");
57            println!("{bc}");
58            println!();
59        }
60        println!("=== {name}.socket ===");
61        println!("{socket_content}");
62        println!();
63        println!("=== {name}.container ===");
64        println!("{container_content}");
65        println!();
66        println!("=== {name}-host.service ===");
67        println!("{host_service_content}");
68        if let Some(ref proxy) = dbus_proxy_content {
69            println!();
70            println!("=== {name}-proxy.service ===");
71            println!("{proxy}");
72        }
73        if let Some(ref comp) = compositor_service_content {
74            println!();
75            println!("=== {name}-compositor.service ===");
76            println!("{comp}");
77        }
78        return Ok(());
79    }
80
81    // Acquire exclusive install lock (auto-releases on panic/crash via kernel flock)
82    let _install_lock = {
83        let lock_path = context_dir.join(".install.lock");
84        let _ = std::fs::create_dir_all(&context_dir);
85        let file = std::fs::File::create(&lock_path).with_context(|| {
86            format!("failed to create install lock at '{}'", lock_path.display())
87        })?;
88        Flock::lock(file, FlockArg::LockExclusive).map_err(|(_, e)| e)?
89    };
90
91    // Ensure .flatpak-info is written to the host build directory
92    let _ = std::fs::create_dir_all(&context_dir);
93    std::fs::write(
94        context_dir.join(".flatpak-info"),
95        "[Application]\nname=podbox\n",
96    )?;
97
98    // Pre-flight validation
99    preflight_check(config)?;
100
101    // Ensure home and runtime dirs exist
102    std::fs::create_dir_all(&config.container.home).with_context(|| {
103        format!(
104            "failed to create home dir '{}'",
105            config.container.home.display()
106        )
107    })?;
108
109    if ver.at_least(6, 0) {
110        // 6.0+: use --application with directory install.
111        remove_flat_units(name);
112        podman_quadlet_install_application(name, &container_content, build_content.as_deref())?;
113    } else if ver.at_least(5, 6) {
114        // 5.6–5.x: install individual files for flat layout.
115        remove_application_dir(name);
116        podman_quadlet_install_files(name, &container_content, build_content.as_deref())?;
117    } else {
118        // 5.5 fallback: copy files manually
119        std::fs::create_dir_all(&qdir)?;
120        if let Some(ref bc) = build_content {
121            std::fs::write(qdir.join(format!("{name}.build")), bc)?;
122        }
123        std::fs::write(qdir.join(format!("{name}.container")), container_content)?;
124        println!("Quadlet files installed to {}", qdir.display());
125    }
126
127    finalize_units(
128        name,
129        &sdir,
130        &socket_content,
131        &host_service_content,
132        dbus_proxy_content.as_deref(),
133        compositor_service_content.as_deref(),
134        config.use_wayland_proxy(),
135    )?;
136
137    // Auto-export apps and bins
138    for app in &config.integration.export.apps {
139        if let Err(e) = crate::export::export_app(name, app) {
140            eprintln!("Warning: auto-export app '{app}' failed: {e}");
141        }
142    }
143    for bin in &config.integration.export.bins {
144        if let Err(e) = crate::export::export_bin(name, bin) {
145            eprintln!("Warning: auto-export bin '{bin}' failed: {e}");
146        }
147    }
148
149    if config.lifecycle.autostart {
150        systemd::enable_linger()?;
151    }
152
153    Ok(())
154}
155
156/// Remove Quadlet and systemd files for a container.
157pub fn uninstall(name: &str) -> Result<()> {
158    let ver = podman_version().unwrap_or(PodmanVersion {
159        major: 5,
160        minor: 5,
161        patch: 0,
162    });
163    let qdir = quadlet_dir();
164    let sdir = systemd_user_dir();
165
166    if ver.at_least(5, 6) {
167        let mut removed_via_podman = false;
168
169        // Flat units: only call rm when the file exists (avoids needing --ignore).
170        for ext in ["container", "build"] {
171            let path = qdir.join(format!("{name}.{ext}"));
172            if !path.exists() {
173                continue;
174            }
175            let args: Vec<std::ffi::OsString> = vec![
176                "quadlet".into(),
177                "rm".into(),
178                format!("{name}.{ext}").into(),
179            ];
180            let output = crate::process::run_piped("podman", &args)?;
181            if output.status.success() {
182                removed_via_podman = true;
183            } else {
184                // Fall through to manual delete below.
185                let stderr = String::from_utf8_lossy(&output.stderr);
186                eprintln!("Warning: podman quadlet rm {name}.{ext} failed: {stderr}");
187            }
188        }
189
190        // Application-scoped leftovers (directory install / --application).
191        let app_dir = qdir.join(name);
192        if app_dir.is_dir() {
193            if ver.at_least(6, 0) {
194                let args: Vec<std::ffi::OsString> = vec![
195                    "quadlet".into(),
196                    "rm".into(),
197                    "--recursive".into(),
198                    name.into(),
199                ];
200                let output = crate::process::run_piped("podman", &args)?;
201                if output.status.success() {
202                    removed_via_podman = true;
203                } else {
204                    let stderr = String::from_utf8_lossy(&output.stderr);
205                    eprintln!("Warning: podman quadlet rm --recursive {name} failed: {stderr}");
206                }
207            }
208            remove_application_dir(name);
209        }
210
211        // Manual cleanup of any remaining flat files.
212        for ext in ["build", "container"] {
213            let path = qdir.join(format!("{name}.{ext}"));
214            if path.exists() {
215                std::fs::remove_file(&path)?;
216            }
217        }
218
219        if removed_via_podman {
220            println!("Quadlet files removed via podman quadlet rm.");
221        }
222    } else {
223        // 5.5 fallback: remove files manually
224        for ext in ["build", "container"] {
225            let path = qdir.join(format!("{name}.{ext}"));
226            if path.exists() {
227                std::fs::remove_file(&path)?;
228            }
229        }
230        remove_application_dir(name);
231    }
232
233    // Remove custom systemd units
234    for unit in [
235        "socket",
236        "host.service",
237        "proxy.service",
238        "compositor.service",
239    ] {
240        let path = sdir.join(format!("{name}.{unit}"));
241        if path.exists() {
242            std::fs::remove_file(&path)?;
243        }
244    }
245
246    // Remove the clean-stop drop-in directory for the generated container unit.
247    let dropin_dir = sdir.join(format!("{name}.service.d"));
248    if dropin_dir.is_dir() {
249        std::fs::remove_dir_all(&dropin_dir)?;
250    }
251
252    systemd::daemon_reload()?;
253    println!("Files for '{name}' removed.");
254
255    Ok(())
256}
257
258#[cfg(test)]
259mod tests {
260    use super::paths::{application_container_path, flat_container_path};
261
262    #[test]
263    fn flat_and_application_paths_differ() {
264        let flat = flat_container_path("myenv");
265        let app = application_container_path("myenv");
266        assert!(flat.to_string_lossy().ends_with("myenv.container"));
267        assert!(
268            app.to_string_lossy().ends_with("myenv/myenv.container")
269                || app.to_string_lossy().ends_with("myenv\\myenv.container")
270        );
271        assert_ne!(flat, app);
272    }
273}