372 lines
14 KiB
Rust
372 lines
14 KiB
Rust
|
|
//! 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<String>,
|
||
|
|
pub memory_mb: Option<u32>,
|
||
|
|
pub cap_drop_all: bool,
|
||
|
|
pub cap_add: Vec<String>,
|
||
|
|
pub bind_mounts: Vec<BindMount>,
|
||
|
|
pub extra_podman_args: Vec<String>,
|
||
|
|
pub depends_on: Vec<String>,
|
||
|
|
}
|
||
|
|
|
||
|
|
impl QuadletUnit {
|
||
|
|
/// File name on disk: `<name>.container`. Quadlet translates this
|
||
|
|
/// into a `<name>.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<PathBuf> {
|
||
|
|
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/<name>.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<bool> {
|
||
|
|
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"
|
||
|
|
);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|