//! Thin async wrappers over `nixos-container`. use std::path::Path; use anyhow::{Context, Result, bail}; use tokio::process::Command; /// Sub-agent container prefix. `nixos-container` caps the total container name /// at 11 chars (it gets encoded into network interface names), so the agent /// name itself can be at most `MAX_AGENT_NAME` chars. pub const AGENT_PREFIX: &str = "h-"; pub const MAX_AGENT_NAME: usize = 9; /// Container name of the manager (a separate slot from sub-agents). pub const MANAGER_NAME: &str = "hm1nd"; /// Mount point of the per-agent runtime directory inside the container. pub const CONTAINER_RUNTIME_MOUNT: &str = "/run/hive"; pub fn container_name(name: &str) -> String { format!("{AGENT_PREFIX}{name}") } fn validate(name: &str) -> Result<()> { if name.is_empty() { bail!("agent name must not be empty"); } if name.len() > MAX_AGENT_NAME { bail!( "agent name '{name}' is too long ({} chars); max {MAX_AGENT_NAME}", name.len() ); } Ok(()) } pub async fn spawn(name: &str, agent_flake: &str, agent_dir: &Path) -> Result<()> { validate(name)?; let container = container_name(name); run(&["create", &container, "--flake", agent_flake]).await?; set_bind_flag(&container, agent_dir)?; run(&["start", &container]).await } /// `nixos-container` doesn't expose `--bind` on the CLI, but its start script /// expands `$EXTRA_NSPAWN_FLAGS` (from `/etc/nixos-containers/.conf`) /// unquoted into the `systemd-nspawn` invocation. Idempotently replace the /// `EXTRA_NSPAWN_FLAGS` line with the bind we want. fn set_bind_flag(container: &str, agent_dir: &Path) -> Result<()> { let path = format!("/etc/nixos-containers/{container}.conf"); let original = std::fs::read_to_string(&path).with_context(|| format!("read {path}"))?; let mut lines: Vec = original .lines() .filter(|line| !line.trim_start().starts_with("EXTRA_NSPAWN_FLAGS=")) .map(str::to_owned) .collect(); lines.push(format!( "EXTRA_NSPAWN_FLAGS=\"--bind={}:{CONTAINER_RUNTIME_MOUNT}\"", agent_dir.display() )); let mut content = lines.join("\n"); content.push('\n'); std::fs::write(&path, content).with_context(|| format!("write {path}"))?; tracing::info!(%path, "set EXTRA_NSPAWN_FLAGS for bind mount"); Ok(()) } pub async fn kill(name: &str) -> Result<()> { validate(name)?; let container = container_name(name); run(&["stop", &container]).await } pub async fn rebuild(name: &str, agent_flake: &str, agent_dir: &Path) -> Result<()> { validate(name)?; let container = container_name(name); set_bind_flag(&container, agent_dir)?; run(&["update", &container, "--flake", agent_flake]).await?; // Restart so any nspawn-level changes (bind mounts, networking, etc.) apply. run(&["stop", &container]).await?; run(&["start", &container]).await } pub async fn list() -> Result> { let out = Command::new("nixos-container") .arg("list") .output() .await .context("invoke nixos-container list")?; if !out.status.success() { bail!( "nixos-container list exited with status {}: {}", out.status, String::from_utf8_lossy(&out.stderr).trim() ); } Ok(String::from_utf8_lossy(&out.stdout) .lines() .map(str::trim) .filter(|line| line.starts_with(AGENT_PREFIX) || *line == MANAGER_NAME) .map(str::to_owned) .collect()) } async fn run(args: &[&str]) -> Result<()> { let out = Command::new("nixos-container") .args(args) .output() .await .with_context(|| format!("invoke nixos-container {}", args.join(" ")))?; let stdout = String::from_utf8_lossy(&out.stdout); let stderr = String::from_utf8_lossy(&out.stderr); if !stdout.trim().is_empty() { tracing::info!(target: "nixos-container", "{}", stdout.trim()); } if !stderr.trim().is_empty() { tracing::warn!(target: "nixos-container", "{}", stderr.trim()); } if !out.status.success() { bail!( "nixos-container {} failed ({}): {}", args.join(" "), out.status, stderr.trim() ); } Ok(()) }