mod quadlet;
mod service;
pub use quadlet::{install_quadlet, rebuild_quadlet, uninstall_quadlet};
pub use service::{render_service_unit, ServiceUnitOpts};
use std::io;
use std::path::{Path, PathBuf};
use std::process::{Command, Output};
use crate::ComposeError;
pub trait SystemCtl {
fn systemctl(&self, args: &[&str]) -> io::Result<Output>;
fn loginctl(&self, args: &[&str]) -> io::Result<Output>;
}
pub struct RealSystemCtl;
impl SystemCtl for RealSystemCtl {
fn systemctl(&self, args: &[&str]) -> io::Result<Output> {
Command::new("systemctl").arg("--user").args(args).output()
}
fn loginctl(&self, args: &[&str]) -> io::Result<Output> {
Command::new("loginctl").args(args).output()
}
}
pub fn max_stop_grace_secs(file: &crate::compose::types::ComposeFile) -> Option<u64> {
file.services
.values()
.filter_map(|s| s.stop_grace_period.as_deref())
.filter_map(crate::size::parse_duration_secs)
.max()
}
#[non_exhaustive]
#[derive(Default)]
pub struct InstallOptions {
pub unit: ServiceUnitOpts,
pub no_start: bool,
pub dry_run: bool,
}
impl InstallOptions {
pub fn new(unit: ServiceUnitOpts) -> Self {
Self {
unit,
no_start: false,
dry_run: false,
}
}
pub fn with_no_start(mut self, no_start: bool) -> Self {
self.no_start = no_start;
self
}
pub fn with_dry_run(mut self, dry_run: bool) -> Self {
self.dry_run = dry_run;
self
}
}
fn config_home() -> PathBuf {
if let Some(x) = std::env::var_os("XDG_CONFIG_HOME").filter(|s| !s.is_empty()) {
return PathBuf::from(x);
}
match std::env::var_os("HOME").filter(|s| !s.is_empty()) {
Some(home) => PathBuf::from(home).join(".config"),
None => PathBuf::from(".config"),
}
}
fn unit_dir() -> PathBuf {
config_home().join("systemd").join("user")
}
fn unit_file_name(project: &str) -> String {
format!("podup-{project}.service")
}
fn unit_path(project: &str) -> PathBuf {
unit_dir().join(unit_file_name(project))
}
fn current_user() -> Option<String> {
std::env::var("USER")
.ok()
.or_else(|| std::env::var("LOGNAME").ok())
.filter(|s| !s.is_empty())
}
fn quadlet_units_present(project: &str) -> Vec<PathBuf> {
let dir = config_home().join("containers").join("systemd");
let prefix = format!("{project}-");
let mut found = Vec::new();
if let Ok(entries) = std::fs::read_dir(&dir) {
for entry in entries.flatten() {
let name = entry.file_name();
let name = name.to_string_lossy();
if name.starts_with(&prefix) && name.ends_with(".container") {
found.push(entry.path());
}
}
}
found.sort();
found
}
fn linger_enabled<S: SystemCtl>(sc: &S, user: &str) -> bool {
match sc.loginctl(&["show-user", user, "--value", "--property=Linger"]) {
Ok(out) if out.status.success() => String::from_utf8_lossy(&out.stdout)
.trim()
.eq_ignore_ascii_case("yes"),
_ => false,
}
}
fn linger_warning<S: SystemCtl>(sc: &S) -> Option<String> {
let user = current_user()?;
if linger_enabled(sc, &user) {
return None;
}
Some(format!(
"linger is not enabled for {user}; the stack will not start at boot until you run:\n \
loginctl enable-linger {user}"
))
}
fn runtime_dir_warning() -> Option<String> {
let present = std::env::var_os("XDG_RUNTIME_DIR").is_some_and(|s| !s.is_empty());
if present {
return None;
}
Some(
"XDG_RUNTIME_DIR is not set; `systemctl --user` needs an active user session. \
Open one (e.g. `machinectl shell <user>@`) or export XDG_RUNTIME_DIR before retrying."
.to_string(),
)
}
fn emit_guards<S: SystemCtl>(sc: &S) {
for warning in [linger_warning(sc), runtime_dir_warning()]
.into_iter()
.flatten()
{
tracing::warn!("{warning}");
}
}
fn checked(res: io::Result<Output>, what: &str) -> crate::Result<()> {
let out = res.map_err(|e| {
ComposeError::Autostart(format!("failed to run `systemctl --user {what}`: {e}"))
})?;
if out.status.success() {
return Ok(());
}
let stderr = String::from_utf8_lossy(&out.stderr);
Err(ComposeError::Autostart(format!(
"`systemctl --user {what}` failed: {}",
stderr.trim()
)))
}
pub fn install<S: SystemCtl>(sc: &S, opts: &InstallOptions) -> crate::Result<()> {
let project = &opts.unit.project;
let path = unit_path(project);
let quadlet = quadlet_units_present(project);
if !quadlet.is_empty() {
let names: Vec<String> = quadlet.iter().map(|p| p.display().to_string()).collect();
return Err(ComposeError::Autostart(format!(
"quadlet autostart units for project '{project}' already exist:\n {}\n\
remove them before installing service mode (quadlet autostart is tracked by #993).",
names.join("\n ")
)));
}
service::validate_unit_opts(&opts.unit).map_err(ComposeError::Autostart)?;
let unit_text = render_service_unit(&opts.unit);
let unit_name = unit_file_name(project);
emit_guards(sc);
if opts.dry_run {
print!("{unit_text}");
println!("\n# would write {}", path.display());
println!("# would run: systemctl --user daemon-reload");
if opts.no_start {
println!("# (--no-start) would not enable or start the unit");
} else {
println!("# would run: systemctl --user enable --now {unit_name}");
}
return Ok(());
}
let dir = unit_dir();
std::fs::create_dir_all(&dir)
.map_err(|e| ComposeError::Autostart(format!("cannot create {}: {e}", dir.display())))?;
std::fs::write(&path, unit_text.as_bytes())
.map_err(|e| ComposeError::Autostart(format!("cannot write {}: {e}", path.display())))?;
eprintln!("podup: wrote {}", path.display());
checked(sc.systemctl(&["daemon-reload"]), "daemon-reload")?;
if opts.no_start {
eprintln!("podup: installed {unit_name} (not enabled; --no-start)");
} else {
checked(
sc.systemctl(&["enable", "--now", &unit_name]),
&format!("enable --now {unit_name}"),
)?;
eprintln!("podup: enabled and started {unit_name}");
}
Ok(())
}
fn unit_is_known<S: SystemCtl>(sc: &S, unit: &str) -> bool {
sc.systemctl(&["is-active", "--quiet", unit])
.map(|o| o.status.code() != Some(4))
.unwrap_or(true)
}
pub fn uninstall<S: SystemCtl>(sc: &S, project: &str) -> crate::Result<()> {
let unit_name = unit_file_name(project);
let path = unit_path(project);
if unit_is_known(sc, &unit_name) {
checked(
sc.systemctl(&["disable", "--now", &unit_name]),
"disable --now",
)?;
}
if path.exists() {
std::fs::remove_file(&path).map_err(|e| {
ComposeError::Autostart(format!("cannot remove {}: {e}", path.display()))
})?;
eprintln!("podup: removed {}", path.display());
} else {
eprintln!(
"podup: no unit file at {} (already removed)",
path.display()
);
}
checked(sc.systemctl(&["daemon-reload"]), "daemon-reload")?;
Ok(())
}
pub enum InstalledMode {
Service,
Quadlet,
None,
}
pub fn installed_mode(project: &str) -> InstalledMode {
if unit_path(project).exists() {
InstalledMode::Service
} else if !quadlet_units_present(project).is_empty() {
InstalledMode::Quadlet
} else {
InstalledMode::None
}
}
pub struct StatusReport {
pub unit_path: PathBuf,
pub unit_exists: bool,
pub unit_mode: Option<u32>,
pub is_active: String,
pub is_enabled: String,
pub linger: bool,
pub runtime_dir: bool,
}
#[cfg(unix)]
fn file_mode(path: &Path) -> Option<u32> {
use std::os::unix::fs::PermissionsExt;
std::fs::metadata(path).ok().map(|m| m.permissions().mode())
}
#[cfg(not(unix))]
fn file_mode(_path: &Path) -> Option<u32> {
None
}
fn query<S: SystemCtl>(sc: &S, arg: &str, unit_name: &str) -> String {
match sc.systemctl(&[arg, unit_name]) {
Ok(out) => {
let s = String::from_utf8_lossy(&out.stdout).trim().to_string();
if s.is_empty() {
"unknown".to_string()
} else {
s
}
}
Err(e) => format!("unknown ({e})"),
}
}
pub fn collect_status<S: SystemCtl>(sc: &S, project: &str) -> StatusReport {
let unit_name = unit_file_name(project);
let path = unit_path(project);
let unit_exists = path.exists();
StatusReport {
unit_mode: if unit_exists { file_mode(&path) } else { None },
unit_exists,
unit_path: path,
is_active: query(sc, "is-active", &unit_name),
is_enabled: query(sc, "is-enabled", &unit_name),
linger: current_user().is_some_and(|u| linger_enabled(sc, &u)),
runtime_dir: std::env::var_os("XDG_RUNTIME_DIR").is_some_and(|s| !s.is_empty()),
}
}
pub fn status<S: SystemCtl>(sc: &S, project: &str) -> crate::Result<()> {
let r = collect_status(sc, project);
let row = |label: &str, value: &str| {
crate::ui::print_labelled(label, value);
};
row("unit", &r.unit_path.display().to_string());
row("installed", if r.unit_exists { "yes" } else { "no" });
if let Some(mode) = r.unit_mode {
row("mode", &format!("{:04o}", mode & 0o7777));
}
row("active", &r.is_active);
row("enabled", &r.is_enabled);
row("linger", if r.linger { "enabled" } else { "disabled" });
crate::ui::print_labelled_with(
"session",
if r.runtime_dir {
"XDG_RUNTIME_DIR set"
} else {
"XDG_RUNTIME_DIR unset (systemctl --user needs a user session)"
},
Some(r.runtime_dir),
);
Ok(())
}
#[cfg(all(test, unix))]
mod tests;