use std::path::{Path, PathBuf};
use anyhow::{Context, Result};
use nix::fcntl::{Flock, FlockArg};
use crate::codegen::quadlet;
use crate::config::{self, Config};
use crate::env::HostEnv;
use crate::podman::{PodmanVersion, podman_version};
use crate::systemd;
use crate::xdg::ResolvedXdgDirs;
pub fn quadlet_dir() -> PathBuf {
dirs::config_dir()
.unwrap_or_else(|| config::expand_tilde("~/.config"))
.join("containers/systemd")
}
pub fn flat_container_path(name: &str) -> PathBuf {
quadlet_dir().join(format!("{name}.container"))
}
pub fn application_container_path(name: &str) -> PathBuf {
quadlet_dir().join(name).join(format!("{name}.container"))
}
pub fn is_installed(name: &str) -> bool {
container_unit_path(name).is_some()
}
pub fn container_unit_path(name: &str) -> Option<PathBuf> {
let flat = flat_container_path(name);
if flat.exists() {
return Some(flat);
}
let app = application_container_path(name);
if app.exists() {
return Some(app);
}
None
}
pub fn list_installed_names() -> Vec<String> {
let qdir = quadlet_dir();
let mut names = Vec::new();
let Ok(entries) = std::fs::read_dir(&qdir) else {
return names;
};
for entry in entries.flatten() {
let path = entry.path();
if path.extension().is_some_and(|e| e == "container") {
if let Some(stem) = path.file_stem().and_then(|s| s.to_str()) {
names.push(stem.to_string());
}
continue;
}
if path.is_dir() {
let Some(dir_name) = path.file_name().and_then(|s| s.to_str()) else {
continue;
};
let nested = path.join(format!("{dir_name}.container"));
if nested.exists() {
names.push(dir_name.to_string());
}
}
}
names.sort();
names.dedup();
names
}
fn systemd_user_dir() -> PathBuf {
dirs::config_dir()
.unwrap_or_else(|| config::expand_tilde("~/.config"))
.join("systemd/user")
}
fn write_custom_units(
name: &str,
sdir: &Path,
socket_content: &str,
host_service_content: &str,
dbus_proxy_content: Option<&str>,
compositor_service_content: Option<&str>,
) -> Result<()> {
std::fs::create_dir_all(sdir)?;
std::fs::write(sdir.join(format!("{}.socket", name)), socket_content)?;
std::fs::write(
sdir.join(format!("{}-host.service", name)),
host_service_content,
)?;
if let Some(proxy) = dbus_proxy_content {
std::fs::write(sdir.join(format!("{}-proxy.service", name)), proxy)?;
}
if let Some(comp) = compositor_service_content {
std::fs::write(sdir.join(format!("{}-compositor.service", name)), comp)?;
}
write_clean_stop_dropin(name, sdir)?;
Ok(())
}
fn write_clean_stop_dropin(name: &str, sdir: &Path) -> Result<()> {
let dir = sdir.join(format!("{}.service.d", name));
std::fs::create_dir_all(&dir)?;
std::fs::write(
dir.join("99-podbox-clean-stop.conf"),
"[Service]\nSuccessExitStatus=0 143 SIGTERM SIGINT\n",
)?;
Ok(())
}
fn finalize_units(
name: &str,
sdir: &Path,
socket_content: &str,
host_service_content: &str,
dbus_proxy_content: Option<&str>,
compositor_service_content: Option<&str>,
use_wayland_proxy: bool,
) -> Result<()> {
write_custom_units(
name,
sdir,
socket_content,
host_service_content,
dbus_proxy_content,
compositor_service_content,
)?;
println!("Systemd units installed to {}", sdir.display());
systemd::daemon_reload()?;
systemd::reset_failed(name)?;
systemd::stop_socket_and_host(name)?;
if use_wayland_proxy {
systemd::stop_compositor(name)?;
}
systemd::enable_now_socket(name)?;
Ok(())
}
fn podman_quadlet_install_files(
name: &str,
container_content: &str,
build_content: Option<&str>,
) -> Result<()> {
let tmp = std::env::temp_dir().join(format!("podbox-install-{name}"));
let _ = std::fs::remove_dir_all(&tmp);
std::fs::create_dir_all(&tmp)?;
let container_path = tmp.join(format!("{name}.container"));
std::fs::write(&container_path, container_content)?;
let mut args: Vec<std::ffi::OsString> = vec![
"quadlet".into(),
"install".into(),
"--replace".into(),
container_path.into(),
];
if let Some(bc) = build_content {
let build_path = tmp.join(format!("{name}.build"));
std::fs::write(&build_path, bc)?;
args.push(build_path.into());
}
let output = crate::process::run_piped("podman", &args)?;
let _ = std::fs::remove_dir_all(&tmp);
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
anyhow::bail!("podman quadlet install failed: {stderr}");
}
println!("Quadlet files installed via podman quadlet install.");
Ok(())
}
fn podman_quadlet_install_application(
name: &str,
container_content: &str,
build_content: Option<&str>,
) -> Result<()> {
let tmp = std::env::temp_dir().join(format!("podbox-install-{name}"));
let _ = std::fs::remove_dir_all(&tmp);
std::fs::create_dir_all(&tmp)?;
std::fs::write(tmp.join(format!("{name}.container")), container_content)?;
if let Some(bc) = build_content {
std::fs::write(tmp.join(format!("{name}.build")), bc)?;
}
let args: Vec<std::ffi::OsString> = vec![
"quadlet".into(),
"install".into(),
"--replace".into(),
"--application".into(),
name.into(),
tmp.into(),
];
let output = crate::process::run_piped("podman", &args)?;
let _ = std::fs::remove_dir_all(std::env::temp_dir().join(format!("podbox-install-{name}")));
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
anyhow::bail!("podman quadlet install --application failed: {stderr}");
}
println!("Quadlet files installed via podman quadlet install --application {name}.");
Ok(())
}
fn remove_flat_units(name: &str) {
let qdir = quadlet_dir();
for ext in ["container", "build"] {
let path = qdir.join(format!("{name}.{ext}"));
if !path.exists() {
continue;
}
let args: Vec<std::ffi::OsString> = vec![
"quadlet".into(),
"rm".into(),
format!("{name}.{ext}").into(),
];
let _ = crate::process::run_piped("podman", &args);
let _ = std::fs::remove_file(&path);
}
}
fn remove_application_dir(name: &str) {
let app_dir = quadlet_dir().join(name);
if app_dir.is_dir() {
let _ = std::fs::remove_dir_all(&app_dir);
}
}
fn preflight_check(config: &Config) -> Result<()> {
let name = &config.container.name;
if !config.container.home.exists() {
eprintln!(
" Note: home directory '{}' will be created (does not exist yet).",
config.container.home.display()
);
}
for mount in &config.container.mounts.extra {
let host_path = match mount.split_once(':') {
Some((host, _)) => host,
None => mount,
};
let path = std::path::Path::new(host_path);
if !path.exists() {
if crate::codegen::distros::is_tty() {
let prompt = format!(
"Mount path '{}' does not exist on the host. Create it?",
path.display()
);
let create =
dialoguer::Confirm::with_theme(&dialoguer::theme::ColorfulTheme::default())
.with_prompt(prompt)
.default(true)
.interact_opt()?;
if create == Some(true) {
std::fs::create_dir_all(path).with_context(|| {
format!("failed to create mount directory '{}'", path.display())
})?;
println!("✓ Directory '{}' created.", path.display());
} else {
eprintln!(
"Warning: mount path '{}' does not exist on the host. This may cause the container to fail to load.",
path.display()
);
}
} else {
eprintln!(
"Warning: mount path '{}' does not exist on the host (container '{}').",
path.display(),
name
);
}
}
}
let is_running = crate::podman::query_state(name)
.map(|state| state == crate::podman::ContainerState::Running)
.unwrap_or(false);
if is_running {
println!(
" Note: container '{}' is running. Skipping port conflict checks for upgrade.",
name
);
return Ok(());
}
let conflicts = crate::ports::check_host_ports(&config.network.ports);
if !conflicts.is_empty() {
let listed = conflicts
.iter()
.map(ToString::to_string)
.collect::<Vec<_>>()
.join(", ");
anyhow::bail!(
"Port conflict: already in use on the host — {listed}. \
Find the process with: `ss -ltnp 'sport = :<port>'`"
);
}
if config.security.cap_preset == crate::config::CapPreset::Admin {
if crate::codegen::distros::is_tty() {
let caps = config.security.cap_preset.caps().join(", ");
let confirmed = dialoguer::Confirm::with_theme(
&dialoguer::theme::ColorfulTheme::default(),
)
.with_prompt(format!(
"WARNING: CapPreset::Admin grants {caps}. Only proceed if you fully trust this container. Continue?"
))
.default(false)
.interact()?;
if !confirmed {
anyhow::bail!(
"Aborted — set cap_preset to a lower level or use cap_add for specific caps"
);
}
} else {
let caps = config.security.cap_preset.caps().join(", ");
eprintln!(
"Note: cap_preset = \"admin\" grants {caps}. Non-interactive mode, continuing without confirmation."
);
}
}
Ok(())
}
pub fn install(config: &Config, env: &HostEnv, xdg: &ResolvedXdgDirs, dry_run: bool) -> Result<()> {
let name = &config.container.name;
let ver = podman_version().unwrap_or(PodmanVersion {
major: 5,
minor: 5,
patch: 0,
});
let qdir = quadlet_dir();
let sdir = systemd_user_dir();
let context_dir = crate::build::build_context_dir(name);
let containerfile_path = context_dir.join("Containerfile");
let socket_content = quadlet::generate_socket(config);
let container_content = quadlet::generate_container(config, env, xdg);
let host_service_content = quadlet::generate_host_service(name);
let dbus_proxy_content = quadlet::generate_dbus_proxy_service(name, config);
let compositor_service_content = quadlet::generate_compositor_service(name, config);
let build_content = if !config.image.source().is_prebuilt() {
Some(quadlet::generate_build(config, &containerfile_path))
} else {
None
};
if dry_run {
if let Some(ref bc) = build_content {
println!("=== {}.build ===", name);
println!("{}", bc);
println!();
}
println!("=== {}.socket ===", name);
println!("{}", socket_content);
println!();
println!("=== {}.container ===", name);
println!("{}", container_content);
println!();
println!("=== {}-host.service ===", name);
println!("{}", host_service_content);
if let Some(ref proxy) = dbus_proxy_content {
println!();
println!("=== {}-proxy.service ===", name);
println!("{}", proxy);
}
if let Some(ref comp) = compositor_service_content {
println!();
println!("=== {}-compositor.service ===", name);
println!("{}", comp);
}
return Ok(());
}
let _install_lock = {
let lock_path = context_dir.join(".install.lock");
let _ = std::fs::create_dir_all(&context_dir);
let file = std::fs::File::create(&lock_path).with_context(|| {
format!("failed to create install lock at '{}'", lock_path.display())
})?;
Flock::lock(file, FlockArg::LockExclusive).map_err(|(_, e)| e)?
};
let _ = std::fs::create_dir_all(&context_dir);
std::fs::write(
context_dir.join(".flatpak-info"),
"[Application]\nname=podbox\n",
)?;
preflight_check(config)?;
std::fs::create_dir_all(&config.container.home).with_context(|| {
format!(
"failed to create home dir '{}'",
config.container.home.display()
)
})?;
if ver.at_least(6, 0) {
remove_flat_units(name);
podman_quadlet_install_application(name, &container_content, build_content.as_deref())?;
} else if ver.at_least(5, 6) {
remove_application_dir(name);
podman_quadlet_install_files(name, &container_content, build_content.as_deref())?;
} else {
std::fs::create_dir_all(&qdir)?;
if let Some(ref bc) = build_content {
std::fs::write(qdir.join(format!("{name}.build")), bc)?;
}
std::fs::write(qdir.join(format!("{name}.container")), container_content)?;
println!("Quadlet files installed to {}", qdir.display());
}
finalize_units(
name,
&sdir,
&socket_content,
&host_service_content,
dbus_proxy_content.as_deref(),
compositor_service_content.as_deref(),
config.use_wayland_proxy(),
)?;
for app in &config.integration.export.apps {
if let Err(e) = crate::export::export_app(name, app) {
eprintln!("Warning: auto-export app '{}' failed: {}", app, e);
}
}
for bin in &config.integration.export.bins {
if let Err(e) = crate::export::export_bin(name, bin) {
eprintln!("Warning: auto-export bin '{}' failed: {}", bin, e);
}
}
if config.lifecycle.autostart {
systemd::enable_linger()?;
}
Ok(())
}
pub fn uninstall(name: &str) -> Result<()> {
let ver = podman_version().unwrap_or(PodmanVersion {
major: 5,
minor: 5,
patch: 0,
});
let qdir = quadlet_dir();
let sdir = systemd_user_dir();
if ver.at_least(5, 6) {
let mut removed_via_podman = false;
for ext in ["container", "build"] {
let path = qdir.join(format!("{name}.{ext}"));
if !path.exists() {
continue;
}
let args: Vec<std::ffi::OsString> = vec![
"quadlet".into(),
"rm".into(),
format!("{name}.{ext}").into(),
];
let output = crate::process::run_piped("podman", &args)?;
if output.status.success() {
removed_via_podman = true;
} else {
let stderr = String::from_utf8_lossy(&output.stderr);
eprintln!("Warning: podman quadlet rm {name}.{ext} failed: {stderr}");
}
}
let app_dir = qdir.join(name);
if app_dir.is_dir() {
if ver.at_least(6, 0) {
let args: Vec<std::ffi::OsString> = vec![
"quadlet".into(),
"rm".into(),
"--recursive".into(),
name.into(),
];
let output = crate::process::run_piped("podman", &args)?;
if output.status.success() {
removed_via_podman = true;
} else {
let stderr = String::from_utf8_lossy(&output.stderr);
eprintln!("Warning: podman quadlet rm --recursive {name} failed: {stderr}");
}
}
remove_application_dir(name);
}
for ext in ["build", "container"] {
let path = qdir.join(format!("{name}.{ext}"));
if path.exists() {
std::fs::remove_file(&path)?;
}
}
if removed_via_podman {
println!("Quadlet files removed via podman quadlet rm.");
}
} else {
for ext in ["build", "container"] {
let path = qdir.join(format!("{name}.{ext}"));
if path.exists() {
std::fs::remove_file(&path)?;
}
}
remove_application_dir(name);
}
for unit in [
"socket",
"host.service",
"proxy.service",
"compositor.service",
] {
let path = sdir.join(format!("{name}.{unit}"));
if path.exists() {
std::fs::remove_file(&path)?;
}
}
let dropin_dir = sdir.join(format!("{name}.service.d"));
if dropin_dir.is_dir() {
std::fs::remove_dir_all(&dropin_dir)?;
}
systemd::daemon_reload()?;
println!("Files for '{name}' removed.");
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn flat_and_application_paths_differ() {
let flat = flat_container_path("myenv");
let app = application_container_path("myenv");
assert!(flat.to_string_lossy().ends_with("myenv.container"));
assert!(
app.to_string_lossy().ends_with("myenv/myenv.container")
|| app.to_string_lossy().ends_with("myenv\\myenv.container")
);
assert_ne!(flat, app);
}
}