//! Render and lifecycle Quadlet `.container` units for companion UI //! containers (archy-bitcoin-ui, archy-lnd-ui, archy-electrs-ui). //! //! Why Quadlet: companions used to run as fire-and-forget `tokio::spawn` //! blocks from `install.rs`. If archipelago crashed mid-spawn or the //! kernel reaped a parent cgroup, companions vanished from `podman ps` //! entirely and only a manual `podman run` brought them back. Putting the //! unit on disk and letting systemd own start/restart removes that whole //! class of failure: the daemon is now systemd, archipelago is just the //! provisioner. //! //! Design constraints kept this module small on purpose: //! //! - **Single responsibility**: render → write → enable → disable. We do //! NOT pull images here — the caller is expected to have the image //! present locally (companions either build from `/opt/archipelago/docker/` //! or are pre-pulled by `install_companion_image`). The quadlet unit //! declares `Pull=never` so a missing image surfaces immediately //! instead of silently retrying behind systemd's restart loop. //! - **Atomic writes**: `tempfile + rename` so a partially-written unit //! is never visible to systemd. A daemon-reload during a rolling //! update can't see half a file. //! - **Idempotent**: `write_if_changed` compares bytes before touching //! the file. No daemon-reload, no service-restart cascade if the //! rendered bytes match what's on disk. //! - **systemctl --user only**: archipelago runs as uid=1000 with //! linger enabled. We never touch the system bus from here. //! //! See `docs/rust-orchestrator-migration.md` and the failure-mode log in //! `feedback_container_lifecycle_failure_modes.md` for the incident //! that motivated the move. use anyhow::{anyhow, Context, Result}; use std::fmt::Write as _; use std::path::{Path, PathBuf}; use tokio::fs; use tokio::process::Command; /// Default rootless quadlet directory. Resolved per-user at runtime via /// `unit_dir()`. Tests pass an explicit dir. pub const DEFAULT_REL_UNIT_DIR: &str = ".config/containers/systemd"; #[derive(Debug, Clone)] pub struct BindMount { pub host: PathBuf, pub container: PathBuf, pub read_only: bool, } #[derive(Debug, Clone, Default)] #[allow(dead_code)] // Bridge is reserved for Phase 5 per-app network isolation. pub enum NetworkMode { #[default] Host, /// A user-defined podman network — quadlet creates the container /// attached to it. The network must already exist (orchestrator's /// `ensure_container_network` handles that on every reconcile tick). Bridge(String), } /// One Quadlet `.container` unit. Field set is deliberately small — /// add a new field only when a companion actually needs it. #[derive(Debug, Clone)] pub struct QuadletUnit { pub name: String, pub description: String, pub image: String, pub network: NetworkMode, pub user: Option, pub memory_mb: Option, pub cap_drop_all: bool, pub cap_add: Vec, pub bind_mounts: Vec, pub extra_podman_args: Vec, pub depends_on: Vec, } impl QuadletUnit { /// File name on disk: `.container`. Quadlet translates this /// into a `.service` unit at daemon-reload time. pub fn unit_filename(&self) -> String { format!("{}.container", self.name) } /// systemd service name created by Quadlet for this unit. pub fn service_name(&self) -> String { format!("{}.service", self.name) } /// Render the canonical Quadlet unit text. Pure function — no I/O. pub fn render(&self) -> String { let mut s = String::with_capacity(512); let _ = writeln!(s, "# Generated by archipelago. DO NOT EDIT."); let _ = writeln!(s, "# Edits are overwritten on the next reconcile."); let _ = writeln!(s); let _ = writeln!(s, "[Unit]"); let _ = writeln!(s, "Description={}", self.description); let _ = writeln!(s, "After=network-online.target"); let _ = writeln!(s, "Wants=network-online.target"); for dep in &self.depends_on { let _ = writeln!(s, "Requires={dep}"); let _ = writeln!(s, "After={dep}"); } let _ = writeln!(s); let _ = writeln!(s, "[Container]"); let _ = writeln!(s, "ContainerName={}", self.name); let _ = writeln!(s, "Image={}", self.image); // Pull=never: companions are pre-pulled or built. A missing image // must surface as a unit start failure, not a silent retry storm. let _ = writeln!(s, "Pull=never"); match &self.network { NetworkMode::Host => { let _ = writeln!(s, "Network=host"); } NetworkMode::Bridge(net) => { let _ = writeln!(s, "Network={net}"); } } if let Some(user) = &self.user { let _ = writeln!(s, "User={user}"); } if self.cap_drop_all { let _ = writeln!(s, "DropCapability=ALL"); } for cap in &self.cap_add { let _ = writeln!(s, "AddCapability={cap}"); } if let Some(mb) = self.memory_mb { let _ = writeln!(s, "PodmanArgs=--memory={mb}m"); } for bm in &self.bind_mounts { let mode = if bm.read_only { ":ro,Z" } else { ":Z" }; let _ = writeln!( s, "Volume={}:{}{}", bm.host.display(), bm.container.display(), mode ); } for arg in &self.extra_podman_args { let _ = writeln!(s, "PodmanArgs={arg}"); } let _ = writeln!(s); let _ = writeln!(s, "[Service]"); // Always restart with a 10s backoff. RestartSec keeps a // crash-loop from saturating the journal. let _ = writeln!(s, "Restart=always"); let _ = writeln!(s, "RestartSec=10"); let _ = writeln!(s); let _ = writeln!(s, "[Install]"); let _ = writeln!(s, "WantedBy=default.target"); s } } /// Resolve the per-user quadlet dir under $HOME. Created if missing. pub async fn unit_dir() -> Result { let home = std::env::var_os("HOME") .map(PathBuf::from) .ok_or_else(|| anyhow!("HOME not set; cannot locate quadlet unit dir"))?; let dir = home.join(DEFAULT_REL_UNIT_DIR); fs::create_dir_all(&dir) .await .with_context(|| format!("create_dir_all {}", dir.display()))?; Ok(dir) } /// Atomically write `unit` into `dir/.container` if the bytes /// differ from what's already there. Returns true if the file changed. pub async fn write_if_changed(unit: &QuadletUnit, dir: &Path) -> Result { let path = dir.join(unit.unit_filename()); let new_bytes = unit.render(); if let Ok(old) = fs::read_to_string(&path).await { if old == new_bytes { return Ok(false); } } fs::create_dir_all(dir) .await .with_context(|| format!("create_dir_all {}", dir.display()))?; let tmp = path.with_extension("container.tmp"); fs::write(&tmp, new_bytes.as_bytes()) .await .with_context(|| format!("write tmp {}", tmp.display()))?; fs::rename(&tmp, &path) .await .with_context(|| format!("rename {} -> {}", tmp.display(), path.display()))?; Ok(true) } /// Reload the user systemd manager. Required after any quadlet write /// or removal so systemd picks up the generated `.service` translation. pub async fn daemon_reload_user() -> Result<()> { let status = Command::new("systemctl") .args(["--user", "daemon-reload"]) .status() .await .context("spawn systemctl --user daemon-reload")?; if !status.success() { return Err(anyhow!("systemctl --user daemon-reload exited {status}")); } Ok(()) } /// Enable + start a quadlet-generated service. `enable --now` makes it /// survive reboots and starts it immediately. pub async fn enable_now(service: &str) -> Result<()> { // Quadlet-generated units cannot be `enable`d directly because the // .service file lives under /run, not /etc — `enable` would refuse // ("transient or generated"). The unit's `[Install] WantedBy` is // honoured at daemon-reload, so we just start it. let status = Command::new("systemctl") .args(["--user", "start", service]) .status() .await .with_context(|| format!("spawn systemctl --user start {service}"))?; if !status.success() { return Err(anyhow!("systemctl --user start {service} exited {status}")); } Ok(()) } /// Stop + remove a quadlet unit and its on-disk file. Best-effort: /// errors stop only the destructive write at the failing step so a /// partial removal doesn't leave a quadlet file pointing at a service /// that systemd no longer knows about. pub async fn disable_remove(unit_name: &str, dir: &Path) -> Result<()> { let svc = format!("{unit_name}.service"); // Stop first; ignore failure (unit may already be down). let _ = Command::new("systemctl") .args(["--user", "stop", &svc]) .status() .await; let path = dir.join(format!("{unit_name}.container")); if fs::try_exists(&path).await.unwrap_or(false) { fs::remove_file(&path) .await .with_context(|| format!("remove {}", path.display()))?; } daemon_reload_user().await.ok(); // Defensive: kill the actual container too, in case quadlet left it. let _ = Command::new("podman") .args(["rm", "-f", unit_name]) .status() .await; Ok(()) } /// Is the quadlet-generated service currently active? pub async fn is_active(service: &str) -> bool { Command::new("systemctl") .args(["--user", "is-active", "--quiet", service]) .status() .await .map(|s| s.success()) .unwrap_or(false) } #[cfg(test)] mod tests { use super::*; use tempfile::tempdir; fn sample_unit() -> QuadletUnit { QuadletUnit { name: "archy-bitcoin-ui".into(), description: "Bitcoin RPC UI proxy".into(), image: "146.59.87.168:3000/lfg2025/bitcoin-ui:latest".into(), network: NetworkMode::Host, user: Some("0:0".into()), memory_mb: Some(128), cap_drop_all: true, cap_add: vec![ "CHOWN".into(), "DAC_OVERRIDE".into(), "NET_BIND_SERVICE".into(), "SETUID".into(), "SETGID".into(), ], bind_mounts: vec![BindMount { host: PathBuf::from("/var/lib/archipelago/bitcoin-ui/nginx.conf"), container: PathBuf::from("/etc/nginx/conf.d/default.conf"), read_only: true, }], extra_podman_args: vec![], depends_on: vec![], } } #[test] fn render_contains_required_directives() { let s = sample_unit().render(); assert!(s.contains("[Container]")); assert!(s.contains("ContainerName=archy-bitcoin-ui")); assert!(s.contains("Image=146.59.87.168:3000/lfg2025/bitcoin-ui:latest")); assert!(s.contains("Pull=never")); assert!(s.contains("Network=host")); assert!(s.contains("DropCapability=ALL")); assert!(s.contains("AddCapability=CHOWN")); assert!(s.contains("AddCapability=NET_BIND_SERVICE")); assert!(s.contains("PodmanArgs=--memory=128m")); assert!(s.contains( "Volume=/var/lib/archipelago/bitcoin-ui/nginx.conf:/etc/nginx/conf.d/default.conf:ro,Z" )); assert!(s.contains("[Service]")); assert!(s.contains("Restart=always")); assert!(s.contains("WantedBy=default.target")); } #[test] fn render_bridge_network_emits_network_name() { let mut u = sample_unit(); u.network = NetworkMode::Bridge("archy-bitcoin-ui-net".into()); let s = u.render(); assert!(s.contains("Network=archy-bitcoin-ui-net")); assert!(!s.contains("Network=host")); } #[test] fn unit_filename_and_service_name_are_consistent() { let u = sample_unit(); assert_eq!(u.unit_filename(), "archy-bitcoin-ui.container"); assert_eq!(u.service_name(), "archy-bitcoin-ui.service"); } #[tokio::test] async fn write_if_changed_writes_first_time_then_noops() { let dir = tempdir().unwrap(); let u = sample_unit(); let changed = write_if_changed(&u, dir.path()).await.unwrap(); assert!(changed, "first write must report changed"); let on_disk = tokio::fs::read_to_string(dir.path().join(u.unit_filename())) .await .unwrap(); assert!(on_disk.starts_with("# Generated by archipelago")); let changed2 = write_if_changed(&u, dir.path()).await.unwrap(); assert!(!changed2, "second write with identical bytes must no-op"); } #[tokio::test] async fn write_if_changed_rewrites_when_field_changes() { let dir = tempdir().unwrap(); let mut u = sample_unit(); write_if_changed(&u, dir.path()).await.unwrap(); u.memory_mb = Some(256); let changed = write_if_changed(&u, dir.path()).await.unwrap(); assert!(changed, "field change must trigger rewrite"); let on_disk = tokio::fs::read_to_string(dir.path().join(u.unit_filename())) .await .unwrap(); assert!(on_disk.contains("PodmanArgs=--memory=256m")); } #[tokio::test] async fn write_if_changed_atomic_rename_leaves_no_tmp() { let dir = tempdir().unwrap(); write_if_changed(&sample_unit(), dir.path()).await.unwrap(); let mut entries = tokio::fs::read_dir(dir.path()).await.unwrap(); while let Some(e) = entries.next_entry().await.unwrap() { assert!( !e.file_name().to_string_lossy().ends_with(".tmp"), "atomic rename must leave no .tmp residue" ); } } }