use std::io::Write;
use std::path::PathBuf;
use anyhow::{Context, Result};
use serde::Deserialize;
use podbox::config::Config;
use podbox::env::HostEnv;
use podbox::systemd;
use podbox::xdg::ResolvedXdgDirs;
pub(crate) fn snapshot_tag(tag: &str, name: &str) -> String {
format!("localhost/podbox-{name}:snapshot-{tag}")
}
pub(crate) fn snapshots_dir() -> PathBuf {
podbox::config::config_dir().join("snapshots")
}
pub fn run_snapshot(_config: &Config, name: &str, tag: Option<&str>) -> Result<()> {
let tag: String = match tag {
Some(t) => t.to_string(),
None => std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_or_else(|_| "0".to_string(), |d| d.as_secs().to_string()),
};
let container_name = format!("podbox-{name}");
let image_tag = snapshot_tag(&tag, name);
eprintln!("Snapshotting container '{container_name}' as '{image_tag}'...");
let output = podbox::process::run_piped(
"podman",
&podbox::process::args(&["commit", &container_name, &image_tag]),
)?;
print!("{}", String::from_utf8_lossy(&output.stdout));
let dir = snapshots_dir().join(name);
std::fs::create_dir_all(&dir)?;
let meta_path = dir.join(format!("{tag}.toml"));
let now_rfc = date_now_rfc3339();
let meta = format!("tag = \"{tag}\"\ncreated = \"{now_rfc}\"\nimage = \"{image_tag}\"\n");
std::fs::write(&meta_path, &meta)?;
println!("✓ Snapshot '{image_tag}' saved (tag: {tag})");
Ok(())
}
#[derive(Deserialize)]
struct SnapshotMeta {
tag: String,
created: String,
image: String,
}
fn list_snapshots(name: &str) -> Result<Vec<SnapshotMeta>> {
let dir = snapshots_dir().join(name);
if !dir.exists() {
return Ok(Vec::new());
}
let mut snapshots: Vec<SnapshotMeta> = Vec::new();
for entry in std::fs::read_dir(&dir)? {
let entry = entry?;
if entry.path().extension().is_some_and(|e| e == "toml") {
let content = std::fs::read_to_string(entry.path())?;
if let Ok(meta) = toml::from_str::<SnapshotMeta>(&content) {
snapshots.push(meta);
}
}
}
Ok(snapshots)
}
pub fn run_snapshot_list(name: &str) -> Result<()> {
let snapshots = list_snapshots(name)?;
if snapshots.is_empty() {
println!("No snapshots for '{name}'.");
return Ok(());
}
println!("{:<16} {:<29} IMAGE", "TAG", "CREATED");
println!("{}", "─".repeat(80));
for s in &snapshots {
println!("{:<16} {:<29} {}", s.tag, s.created, s.image);
}
Ok(())
}
pub fn run_snapshot_prune(name: &str, keep: usize, dry_run: bool) -> Result<()> {
let mut snapshots = list_snapshots(name)?;
if snapshots.len() <= keep {
if !dry_run {
println!(
"Only {} snapshot(s) exist, nothing to prune (keep={keep}).",
snapshots.len()
);
}
return Ok(());
}
snapshots.sort_by(|a, b| b.created.cmp(&a.created));
let to_remove: Vec<&SnapshotMeta> = snapshots.iter().skip(keep).collect();
println!("Pruning {} snapshot(s), keeping {}:", to_remove.len(), keep);
for s in &to_remove {
if dry_run {
println!(" Would remove: {} (image: {})", s.tag, s.image);
continue;
}
let result =
podbox::process::run_piped("podman", &podbox::process::args(&["rmi", &s.image]));
if let Err(e) = result {
eprintln!("Warning: failed to remove image '{}': {e}", s.image);
} else {
println!(" Removed image: {}", s.image);
}
let meta_path = snapshots_dir().join(name).join(format!("{}.toml", s.tag));
if meta_path.exists() {
std::fs::remove_file(&meta_path)?;
}
}
if dry_run {
println!("(dry run, no changes made)");
}
Ok(())
}
fn date_now_rfc3339() -> String {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default();
let secs = now.as_secs();
let days = secs / 86400;
let time_secs = secs % 86400;
let hours = time_secs / 3600;
let minutes = (time_secs % 3600) / 60;
let seconds = time_secs % 60;
let (year, month, day) = days_to_date(days.cast_signed());
format!("{year:04}-{month:02}-{day:02}T{hours:02}:{minutes:02}:{seconds:02}+00:00")
}
fn days_to_date(days: i64) -> (i64, u32, u32) {
let z = days + 719468;
let era = if z >= 0 { z } else { z - 146096 } / 146097;
let doe = z - era * 146097;
let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
let y = yoe + era * 400;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let d = doy - (153 * mp + 2) / 5 + 1;
let m = if mp < 10 { mp + 3 } else { mp - 9 };
let y = if m <= 2 { y + 1 } else { y };
#[allow(clippy::cast_sign_loss, clippy::cast_possible_truncation)]
(y, m as u32, d as u32)
}
pub fn run_restore(_config: &Config, name: &str, tag: &str) -> Result<()> {
let snapshot_img = snapshot_tag(tag, name);
let latest_img = format!("localhost/podbox-{name}:latest");
let exists = podbox::podman::image_exists(&snapshot_img).unwrap_or(false);
if !exists {
anyhow::bail!("Snapshot '{tag}' not found as image '{snapshot_img}'");
}
eprintln!("Stopping container 'podbox-{name}'...");
if let Err(e) = podbox::process::run_piped(
"podman",
&podbox::process::args(&["stop", &format!("podbox-{name}")]),
) {
eprintln!("Warning: failed to stop container 'podbox-{name}': {e}");
}
eprintln!("Restoring from snapshot '{snapshot_img}'...");
let output = podbox::process::run_piped(
"podman",
&podbox::process::args(&["tag", &snapshot_img, &latest_img]),
)?;
if !output.status.success() {
anyhow::bail!("Failed to tag snapshot image");
}
eprintln!("Starting container...");
if let Err(e) = podbox::process::run_piped(
"podman",
&podbox::process::args(&["start", &format!("podbox-{name}")]),
) {
eprintln!("Warning: failed to start container 'podbox-{name}': {e}");
}
println!("✓ Restored '{name}' from snapshot '{tag}'");
Ok(())
}
pub fn run_build(
config: &Config,
env: &HostEnv,
xdg: &ResolvedXdgDirs,
dry_run: bool,
rebuild: bool,
no_diff: bool,
) -> Result<()> {
podbox::build::run(config, env, xdg, dry_run, rebuild)?;
if !dry_run && config.lifecycle.quadlet {
println!("\nRun `podbox enable` to install Quadlet files.");
}
if !dry_run && !no_diff {
let name = &config.container.name;
if let Ok(state) = podbox::podman::query_state(name)
&& state == podbox::podman::ContainerState::Running
{
match podbox::diff::compute(config, name, &env.username) {
Ok(result) if result.has_drift => {
println!("\n── Package drift detected ──");
println!("{}", podbox::diff::format_report(&result));
println!("Run `podbox diff --apply` to update the TOML.");
}
Ok(_) => {}
Err(e) => eprintln!("Warning: drift check skipped ({e})"),
}
}
}
Ok(())
}
pub fn run_enable(
config: &Config,
env: &HostEnv,
xdg: &ResolvedXdgDirs,
dry_run: bool,
) -> Result<()> {
podbox::quadlet_install::install(config, env, xdg, dry_run)?;
if !dry_run {
println!("\nRun `podbox shell` to start and enter the container.");
}
Ok(())
}
pub fn run_disable(name: &str) -> Result<()> {
podbox::quadlet_install::uninstall(name)
}
pub fn run_start(
config: &Config,
env: &HostEnv,
xdg: &ResolvedXdgDirs,
name: &str,
dry_run: bool,
timeout_secs: u64,
) -> Result<()> {
if dry_run {
println!("podman start {name}");
return Ok(());
}
let local_tag = format!("localhost/podbox-{}:latest", config.image.name);
if !podbox::podman::image_exists(&local_tag).unwrap_or(false) {
println!("Image not found, building first...");
podbox::build::run(config, env, xdg, false, false)?;
}
if !podbox::quadlet_install::is_installed(name) {
println!("Quadlet files not found, installing...");
podbox::quadlet_install::install(config, env, xdg, false)?;
}
let already_running = podbox::podman::query_state(name)
.map(|s| s == podbox::podman::ContainerState::Running)
.unwrap_or(false);
if !already_running {
let conflicts = podbox::ports::check_host_ports(&config.network.ports);
if !conflicts.is_empty() {
let mut msg = String::from("Cannot start: published host port(s) already in use:\n");
for c in &conflicts {
use std::fmt::Write as _;
let _ = writeln!(msg, " - {}", c);
}
msg.push_str("\nFind the process with: `ss -ltnp 'sport = :<port>'`\n");
msg.push_str("Either stop that process or change the mapping in [network]ports.");
anyhow::bail!(msg);
}
}
println!("Starting container...");
crate::commands::ensure_running(name, false, timeout_secs)?;
println!("Container '{name}' is running!");
Ok(())
}
pub fn run_stop(config: &Config, name: &str, dry_run: bool) -> Result<()> {
if dry_run {
if config.lifecycle.quadlet && systemd::is_available() {
println!("systemctl --user stop {name}");
} else {
println!("podman stop {name}");
}
return Ok(());
}
if config.lifecycle.quadlet && systemd::is_available() {
systemd::stop_unit(name)
} else {
let args = podbox::process::args(&["stop", name]);
podbox::process::spawn_interactive("podman", &args).map(|_| ())
}
}
pub fn run_update(
config: &Config,
env: &HostEnv,
xdg: &ResolvedXdgDirs,
name: &str,
dry_run: bool,
no_restart: bool,
) -> Result<()> {
if dry_run {
println!("podbox update: pull/rebuild and restart {name}");
println!(" build::run(config, env, xdg, dry_run: true, rebuild: true)");
if !no_restart {
if config.lifecycle.quadlet && systemd::is_available() {
println!(" systemctl --user restart {name}");
} else {
println!(" podman restart {name}");
}
}
return Ok(());
}
println!("Updating '{name}'...");
podbox::build::run(config, env, xdg, false, true)?;
if no_restart {
println!("Image updated. Restart skipped (--no-restart).");
return Ok(());
}
println!("Restarting container...");
if config.lifecycle.quadlet && systemd::is_available() {
systemd::reset_failed(name)?;
systemd::restart_unit(name)?;
} else {
let args = podbox::process::args(&["restart", name]);
podbox::process::spawn_interactive("podman", &args)?;
}
println!("Update complete.");
Ok(())
}
pub fn run_remove(
config: &Config,
name: &str,
dry_run: bool,
all: bool,
force: bool,
remove_config: bool,
) -> Result<()> {
if dry_run {
println!("podman stop {name}");
println!("podman rm -f {name}");
if config.lifecycle.quadlet {
println!("quadlet_install::uninstall({name})");
println!("systemctl --user reset-failed {name}.service");
}
if remove_config {
println!(
"rm {}.toml",
podbox::config::config_dir().join(name).display()
);
}
if all {
println!("rm -rf {}", config.container.home.display());
}
return Ok(());
}
if !force {
print!("Remove container '{name}'? [y/N] ");
std::io::stdout().flush()?;
let mut input = String::new();
std::io::stdin().read_line(&mut input)?;
if !input.trim().eq_ignore_ascii_case("y") {
println!("Cancelled.");
return Ok(());
}
}
if let Err(e) = podbox::process::run_piped("podman", &podbox::process::args(&["stop", name])) {
eprintln!("Warning: failed to stop container '{name}': {e}");
}
if let Err(e) =
podbox::process::run_piped("podman", &podbox::process::args(&["rm", "-f", name]))
{
eprintln!("Warning: failed to remove container '{name}': {e}");
}
if config.lifecycle.quadlet {
if let Err(e) = systemd::stop_unit(name) {
eprintln!("Warning: failed to stop systemd unit '{name}': {e}");
}
if let Err(e) = podbox::quadlet_install::uninstall(name) {
eprintln!("Warning: failed to uninstall Quadlet files for '{name}': {e}");
}
if let Err(e) = systemd::reset_failed(name) {
eprintln!("Warning: failed to reset failed state for '{name}': {e}");
}
}
if remove_config {
let config_path = podbox::config::config_dir().join(format!("{name}.toml"));
if config_path.exists() {
std::fs::remove_file(&config_path)?;
println!("Config '{}' removed.", config_path.display());
}
}
println!("Container '{name}' removed.");
if all {
let home = &config.container.home;
if home.exists() {
if !force {
print!("Remove home directory '{}'? [y/N] ", home.display());
std::io::stdout().flush()?;
let mut input = String::new();
std::io::stdin().read_line(&mut input)?;
if !input.trim().eq_ignore_ascii_case("y") {
println!("Home directory kept.");
return Ok(());
}
}
let status = std::process::Command::new("podman")
.args(["unshare", "rm", "-rf"])
.arg(home)
.status()
.context("failed to run podman unshare")?;
if !status.success() {
anyhow::bail!(
"Failed to delete home directory '{}' via podman unshare (sub-UID files need rootless namespace)",
home.display()
);
}
println!("Home directory '{}' removed.", home.display());
}
}
Ok(())
}
fn find_stale_containers() -> Vec<String> {
let config_dir = podbox::config::config_dir();
let mut stale = Vec::new();
for name in podbox::quadlet_install::list_installed_names() {
let config_path = config_dir.join(format!("{name}.toml"));
if !config_path.exists() {
stale.push(name);
}
}
stale
}
pub fn run_remove_stale(dry_run: bool, force: bool) -> Result<()> {
let stale = find_stale_containers();
if stale.is_empty() {
println!("No stale containers found.");
return Ok(());
}
println!("Orphaned Quadlet runtimes found:");
for name in &stale {
println!(" {name} (no config TOML)");
}
if !force {
print!("Remove these? [y/N] ");
std::io::stdout().flush()?;
let mut input = String::new();
std::io::stdin().read_line(&mut input)?;
if !input.trim().eq_ignore_ascii_case("y") {
println!("Cancelled.");
return Ok(());
}
}
for name in &stale {
if dry_run {
println!("Would remove: {name}");
continue;
}
if let Err(e) = podbox::quadlet_install::uninstall(name) {
eprintln!("Warning: failed to uninstall '{name}': {e}");
}
if let Err(e) =
podbox::process::run_piped("podman", &podbox::process::args(&["rm", "-f", name]))
{
eprintln!("Warning: failed to remove container '{name}': {e}");
}
if let Err(e) = systemd::reset_failed(name) {
eprintln!("Warning: failed to reset failed state for '{name}': {e}");
}
println!("✓ Stale runtime files for '{name}' removed");
}
Ok(())
}