No behaviour change: a pure move plus pub(crate) visibility. PrivRequest, hive-priv-sock and every validation rule are untouched; the 58 tests move next to the code they test, unchanged. Why (#5476): the 28 root ops shared one 4.3k-line file, so no op family could be reviewed on its own and every new root op landed there by default. main.rs keeps the socket loop and the dispatch table. container (lifecycle), container_config (nspawn flags, systemd drop-ins, daemon-reload), storage (btrfs subvolumes, quotas, snapshots, du), files (pause marker, socket dir, legacy tmpfiles) and infra (gateway nginx, forge admin, infra containers) each carry their own validation; shared holds the agent-name checks, the credential-name charset and StagedFile, plus the tests' scratch helpers (one counter crate-wide, so test dirs stay unique). hive-priv/README.md is in the diff because it named src/main.rs as the home of container_flake_action and now describes the module layout.
798 lines
33 KiB
Rust
798 lines
33 KiB
Rust
//! Container lifecycle ops: `StartContainer`, `StopContainer`,
|
|
//! `KillContainer`, `CreateContainer`, `UpdateContainer`, `DestroyContainer`
|
|
//! and `ListContainers`. Every op takes a logical agent name and reaches only
|
|
//! the `h-`-prefixed container [`container_system_name`] derives from it.
|
|
//!
|
|
//! `CreateContainer` / `UpdateContainer` build the toplevel here
|
|
//! ([`nix_build_toplevel`]) and stream progress to the caller as
|
|
//! [`PrivEvent::Line`]s when asked to.
|
|
|
|
use anyhow::{Context as _, Result, anyhow, bail};
|
|
use hive_priv_sock::{META_DIR, PrivEvent, PrivStream, PrivStreamLine};
|
|
use tokio::io::{AsyncWriteExt, BufReader};
|
|
use tokio::net::unix::OwnedWriteHalf;
|
|
use tokio::process::Command;
|
|
|
|
use crate::shared::{container_system_name, validate_agent_name};
|
|
|
|
/// Write one `PrivEvent::Line` to the client. Best-effort: a write
|
|
/// failure is logged but doesn't abort the running subprocess.
|
|
async fn write_line_event(writer: &mut OwnedWriteHalf, stream: PrivStream, data: &str) {
|
|
let event = PrivEvent::Line(PrivStreamLine {
|
|
stream,
|
|
data: data.to_owned(),
|
|
});
|
|
if let Ok(mut json) = serde_json::to_string(&event) {
|
|
json.push('\n');
|
|
if let Err(e) = writer.write_all(json.as_bytes()).await {
|
|
tracing::warn!(error = %e, "write_line_event: write failed");
|
|
}
|
|
}
|
|
}
|
|
|
|
/// `StartContainer`: clear any start-limit lockout left by earlier failures
|
|
/// so a now-correct start isn't blocked (`nixos-container start` does not do
|
|
/// this itself). Best-effort — if the unit doesn't exist yet (first-time
|
|
/// create), `reset-failed` is a no-op and the start proceeds regardless.
|
|
pub(crate) async fn start_container(name: &str) -> Result<(String, String)> {
|
|
validate_agent_name(name)?;
|
|
let machine = container_system_name(name);
|
|
// Silenced: a first-time create means the unit doesn't exist yet, and
|
|
// `systemctl`'s own "Unit ... not loaded" complaint for that case would
|
|
// otherwise land in the journal at error priority on every such start —
|
|
// routine, not a signal, since the result is already discarded below.
|
|
let _ = Command::new("systemctl")
|
|
.args(["reset-failed", &format!("container@{machine}.service")])
|
|
.stdout(std::process::Stdio::null())
|
|
.stderr(std::process::Stdio::null())
|
|
.status()
|
|
.await;
|
|
container_run(&["start", &machine]).await
|
|
}
|
|
|
|
/// `StopContainer`.
|
|
pub(crate) async fn stop_container(name: &str) -> Result<(String, String)> {
|
|
validate_agent_name(name)?;
|
|
stop_and_release(&container_system_name(name)).await
|
|
}
|
|
|
|
/// `KillContainer`: `nixos-container` has no kill verb, so this uses
|
|
/// `machinectl` to send `SIGKILL` to every process in the container — the
|
|
/// right semantics for a forced shutdown after a graceful stop has already
|
|
/// been attempted.
|
|
pub(crate) async fn kill_container(name: &str) -> Result<(String, String)> {
|
|
validate_agent_name(name)?;
|
|
let machine = container_system_name(name);
|
|
machinectl_run(&["kill", &machine, "--signal=SIGKILL"]).await
|
|
}
|
|
|
|
/// `DestroyContainer`.
|
|
pub(crate) async fn destroy_container(name: &str) -> Result<(String, String)> {
|
|
validate_agent_name(name)?;
|
|
container_run(&["destroy", &container_system_name(name)]).await
|
|
}
|
|
|
|
/// Shared body for `CreateContainer` / `UpdateContainer`: validate the
|
|
/// name, build the toplevel ourselves, and apply it (streaming line
|
|
/// events to `writer` when `stream` is set).
|
|
///
|
|
/// **Both verbs build explicitly now — not just `update`.** The first cut
|
|
/// of this fix only rewrote `update`, reasoning that `create` was already
|
|
/// safe: it wraps its whole action in an exclusive `flock` before calling
|
|
/// `nixos-container`'s own `buildFlake()`, and once `update` stopped
|
|
/// writing to `buildFlake()`'s shared `.tmp` out-link, concurrent
|
|
/// `create`s were the only remaining writers — mutually excluded by that
|
|
/// lock. True, but it leaves `create`'s safety resting on an internal
|
|
/// implementation detail of a script we don't own (its current locking
|
|
/// behavior, which could change upstream without notice) instead of on
|
|
/// something we control. Building here for both verbs removes `buildFlake()`
|
|
/// from the picture entirely — there's no shared `.tmp` left to race on,
|
|
/// so there's nothing left to reason about staying in sync with.
|
|
///
|
|
/// **`update` no longer calls `nixos-container update` at all.** That
|
|
/// action's own version-compat probe runs unconditionally before
|
|
/// `--system-path` is ever honored, dying on every agent's update.
|
|
/// [`swap_container_profile`] replicates exactly what `update`'s own
|
|
/// action does *past* that probe (confirmed against its source): `nix-env
|
|
/// --set` the per-container profile, then `systemctl reload` if the
|
|
/// container is running. `create` is untouched — it isn't the failing
|
|
/// verb.
|
|
pub(crate) async fn container_flake_action(
|
|
verb: &str,
|
|
name: &str,
|
|
stream: bool,
|
|
writer: &mut OwnedWriteHalf,
|
|
) -> Result<(String, String)> {
|
|
validate_agent_name(name)?;
|
|
// The build is the multi-minute phase of this operation — give it the
|
|
// same live-line treatment `container_run_streaming` gives
|
|
// `nixos-container` itself when the caller asked for it. Without
|
|
// this, moving the build out of the streamed `nixos-container` call
|
|
// (which is the whole point of this fix) would silently regress every
|
|
// UI that shows build progress: nothing until the longest phase
|
|
// finishes, then everything at once.
|
|
let toplevel = nix_build_toplevel(name, stream.then_some(&mut *writer)).await?;
|
|
let system_name = container_system_name(name);
|
|
if verb == "update" {
|
|
return swap_container_profile(&system_name, &toplevel, stream, writer).await;
|
|
}
|
|
let args = [verb, &system_name, "--system-path", &toplevel];
|
|
if stream {
|
|
container_run_streaming(&args, writer).await
|
|
} else {
|
|
container_run(&args).await
|
|
}
|
|
}
|
|
|
|
/// Apply a prebuilt toplevel to an existing container directly, without
|
|
/// going through `nixos-container update` (see `container_flake_action`'s
|
|
/// doc comment for why). Mirrors `nixos-container.pl`'s own `update`
|
|
/// action verbatim, past its version probe: point the per-container `nix-
|
|
/// env` profile at the new toplevel, then reload the container unit *if*
|
|
/// it's currently running — the container's own next start already reads
|
|
/// from the profile, so a stopped container needs nothing further.
|
|
///
|
|
/// Both steps are near-instant in the happy case, but still stream-
|
|
/// forwarded like the rest of this operation: a failure here (e.g. a
|
|
/// wedged `systemctl reload`) is exactly when the caller most wants the
|
|
/// live line in the build log, not just a summary error afterward.
|
|
async fn swap_container_profile(
|
|
system_name: &str,
|
|
toplevel: &str,
|
|
stream: bool,
|
|
writer: &mut OwnedWriteHalf,
|
|
) -> Result<(String, String)> {
|
|
let profile = format!("/nix/var/nix/profiles/per-container/{system_name}/system");
|
|
let set_out = Command::new("nix-env")
|
|
.args(["-p", &profile, "--set", toplevel])
|
|
.output()
|
|
.await
|
|
.context("invoke nix-env --set")?;
|
|
let mut stdout = String::from_utf8_lossy(&set_out.stdout).into_owned();
|
|
let mut stderr = String::from_utf8_lossy(&set_out.stderr).into_owned();
|
|
log_and_forward(&stdout, &stderr, stream, writer).await;
|
|
if !set_out.status.success() {
|
|
bail!(
|
|
"nix-env -p {profile} --set failed ({}): {}",
|
|
set_out.status,
|
|
stderr.trim()
|
|
);
|
|
}
|
|
|
|
let unit = format!("container@{system_name}");
|
|
let state_out = Command::new("systemctl")
|
|
.args(["show", "--property=ActiveState", "--value", &unit])
|
|
.output()
|
|
.await
|
|
.context("query container ActiveState")?;
|
|
let active = String::from_utf8_lossy(&state_out.stdout).trim() == "active";
|
|
if active {
|
|
let reload_out = Command::new("systemctl")
|
|
.args(["reload", &unit])
|
|
.output()
|
|
.await
|
|
.context("invoke systemctl reload")?;
|
|
let r_stdout = String::from_utf8_lossy(&reload_out.stdout).into_owned();
|
|
let r_stderr = String::from_utf8_lossy(&reload_out.stderr).into_owned();
|
|
log_and_forward(&r_stdout, &r_stderr, stream, writer).await;
|
|
if !reload_out.status.success() {
|
|
bail!(
|
|
"systemctl reload {unit} failed ({}): {}",
|
|
reload_out.status,
|
|
r_stderr.trim()
|
|
);
|
|
}
|
|
stdout.push_str(&r_stdout);
|
|
stderr.push_str(&r_stderr);
|
|
}
|
|
Ok((stdout, stderr))
|
|
}
|
|
|
|
/// Log a command's captured stdout/stderr the same way every other
|
|
/// `nixos-container`-adjacent shellout in this file does, and — when
|
|
/// `stream` is set — forward each line to the client as a live
|
|
/// [`PrivEvent::Line`] too, so a caller watching the build log sees these
|
|
/// lines exactly like any other step's, not just a summary on failure.
|
|
async fn log_and_forward(stdout: &str, stderr: &str, stream: bool, writer: &mut OwnedWriteHalf) {
|
|
for line in stdout.lines() {
|
|
tracing::info!(target: "nixos-container", "{line}");
|
|
if stream {
|
|
write_line_event(writer, PrivStream::Stdout, line).await;
|
|
}
|
|
}
|
|
for line in stderr.lines() {
|
|
tracing::warn!(target: "nixos-container", "{line}");
|
|
if stream {
|
|
write_line_event(writer, PrivStream::Stderr, line).await;
|
|
}
|
|
}
|
|
}
|
|
|
|
/// The explicit `nixosConfigurations.<name>.config.system.build.toplevel`
|
|
/// flake attr path — same construction `hive-c0re`'s own
|
|
/// `lifecycle::prebuild_toplevel` uses, kept here as a pure function so
|
|
/// the exact string shape is unit-tested without needing to run `nix`.
|
|
fn toplevel_attr(name: &str) -> String {
|
|
format!("{META_DIR}#nixosConfigurations.{name}.config.system.build.toplevel")
|
|
}
|
|
|
|
/// Wall-clock bound on [`nix_build_toplevel`]. Four times CI's observed
|
|
/// cold-cache `nix flake check` (~15 min, `.forgejo/workflows/ci.yml`), which
|
|
/// compiles this workspace's crates, the part of an agent toplevel no binary
|
|
/// cache holds; the rebuild path has usually warmed this exact attr already
|
|
/// (`hive-c0re`'s `prebuild_toplevel`). A stalled download is bounded by nix's
|
|
/// own `stalled-download-timeout`; this covers the rest, such as a wedged
|
|
/// nix-daemon holding the caller's build slot forever.
|
|
const NIX_BUILD_TIMEOUT: std::time::Duration = std::time::Duration::from_hours(1);
|
|
|
|
/// How a [`run_bounded`] child ended.
|
|
enum BoundedRun {
|
|
Exited {
|
|
status: std::process::ExitStatus,
|
|
stdout: String,
|
|
stderr: String,
|
|
},
|
|
/// Still running at the limit; its whole process group was killed.
|
|
TimedOut,
|
|
}
|
|
|
|
/// Run `cmd` to completion or until `limit`, draining both pipes: stdout is
|
|
/// captured silently, stderr is logged and, when `writer` is set, forwarded
|
|
/// live as [`PrivEvent::Line`]s.
|
|
///
|
|
/// The child leads its own process group and a timeout `SIGKILL`s the whole
|
|
/// group, so a grandchild still holding the pipes (or the daemon connection)
|
|
/// dies with it instead of outliving the error.
|
|
async fn run_bounded(
|
|
mut cmd: Command,
|
|
limit: std::time::Duration,
|
|
mut writer: Option<&mut OwnedWriteHalf>,
|
|
) -> std::io::Result<BoundedRun> {
|
|
use tokio::io::AsyncBufReadExt as _;
|
|
|
|
let mut child = cmd
|
|
.stdout(std::process::Stdio::piped())
|
|
.stderr(std::process::Stdio::piped())
|
|
.process_group(0)
|
|
.spawn()?;
|
|
// Group leader, so its pid is the pgid.
|
|
let pgid = child.id().and_then(|pid| i32::try_from(pid).ok());
|
|
|
|
let stdout = child.stdout.take().expect("stdout piped");
|
|
let stderr = child.stderr.take().expect("stderr piped");
|
|
let run = async {
|
|
let mut stdout_lines = BufReader::new(stdout).lines();
|
|
let mut stderr_lines = BufReader::new(stderr).lines();
|
|
let mut stdout_buf = String::new();
|
|
let mut stderr_buf = String::new();
|
|
|
|
// ⚠️ Both pipes are drained concurrently even though only one is
|
|
// streamed: reading stderr alone would let stdout fill its pipe
|
|
// buffer and deadlock the child on a build with enough stdout output
|
|
// to fill it.
|
|
//
|
|
// ⚠️ Each stream's EOF is tracked separately rather than breaking on
|
|
// the first `None`: `next_line()` on a closed stream returns
|
|
// `Ok(None)` immediately and forever, so a loop that keeps polling a
|
|
// finished stream spins hot until the other one ends too.
|
|
let mut stdout_done = false;
|
|
let mut stderr_done = false;
|
|
while !(stdout_done && stderr_done) {
|
|
tokio::select! {
|
|
line = stdout_lines.next_line(), if !stdout_done => {
|
|
match line {
|
|
// Captured, never streamed — this is the store path.
|
|
Ok(Some(l)) => {
|
|
stdout_buf.push_str(&l);
|
|
stdout_buf.push('\n');
|
|
}
|
|
Ok(None) => stdout_done = true,
|
|
Err(e) => {
|
|
tracing::warn!(error = %e, "nix build stdout read error");
|
|
stdout_done = true;
|
|
}
|
|
}
|
|
}
|
|
line = stderr_lines.next_line(), if !stderr_done => {
|
|
match line {
|
|
// Streamed as it arrives, so the dashboard and
|
|
// `journalctl -f` show build progress live.
|
|
Ok(Some(l)) => {
|
|
tracing::info!(target: "nix-build-toplevel", "{l}");
|
|
if let Some(w) = writer.as_deref_mut() {
|
|
write_line_event(w, PrivStream::Stderr, &l).await;
|
|
}
|
|
if !stderr_buf.is_empty() {
|
|
stderr_buf.push('\n');
|
|
}
|
|
stderr_buf.push_str(&l);
|
|
}
|
|
Ok(None) => stderr_done = true,
|
|
Err(e) => {
|
|
tracing::warn!(error = %e, "nix build stderr read error");
|
|
stderr_done = true;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
let status = child.wait().await?;
|
|
Ok::<_, std::io::Error>(BoundedRun::Exited {
|
|
status,
|
|
stdout: stdout_buf,
|
|
stderr: stderr_buf,
|
|
})
|
|
};
|
|
if let Ok(exited) = tokio::time::timeout(limit, run).await {
|
|
return exited;
|
|
}
|
|
if let Some(pgid) = pgid {
|
|
// SAFETY: plain `kill(2)`; `-pgid` targets the child's own process
|
|
// group, which `process_group(0)` made it lead.
|
|
unsafe {
|
|
libc::kill(-pgid, libc::SIGKILL);
|
|
}
|
|
}
|
|
child.wait().await?;
|
|
Ok(BoundedRun::TimedOut)
|
|
}
|
|
|
|
/// Build `nixosConfigurations.<name>.config.system.build.toplevel` and
|
|
/// return the resulting store path, so `create`/`update` can hand
|
|
/// `nixos-container` an explicit `--system-path` instead of letting its
|
|
/// own `buildFlake()` build to a racy shared out-link. See "Container
|
|
/// toplevel builds" in this crate's README for the full story — the
|
|
/// concurrency bug this closes (the "agent container gets closure of
|
|
/// other agent" mystery bug) and why stdout/stderr are drained
|
|
/// concurrently but handled asymmetrically (stderr streamed live,
|
|
/// stdout captured and required to be exactly one line).
|
|
///
|
|
/// `writer` is `None` for the non-streaming call shape (`stream: false`);
|
|
/// stderr still logs to journald either way, just without the
|
|
/// `PrivEvent::Line` forwarding. Bounded by [`NIX_BUILD_TIMEOUT`].
|
|
async fn nix_build_toplevel(name: &str, writer: Option<&mut OwnedWriteHalf>) -> Result<String> {
|
|
let attr = toplevel_attr(name);
|
|
let mut cmd = Command::new("nix");
|
|
cmd.args([
|
|
"--extra-experimental-features",
|
|
"nix-command flakes",
|
|
"build",
|
|
"--no-link",
|
|
"--print-out-paths",
|
|
&attr,
|
|
]);
|
|
let (status, stdout_buf, stderr_buf) = match run_bounded(cmd, NIX_BUILD_TIMEOUT, writer)
|
|
.await
|
|
.with_context(|| format!("run nix build {attr}"))?
|
|
{
|
|
BoundedRun::Exited {
|
|
status,
|
|
stdout,
|
|
stderr,
|
|
} => (status, stdout, stderr),
|
|
BoundedRun::TimedOut => bail!(
|
|
"nix build {attr} timed out after {}s; killed it",
|
|
NIX_BUILD_TIMEOUT.as_secs()
|
|
),
|
|
};
|
|
|
|
// ⚠️ Success is decided by the exit status, not by "we parsed a
|
|
// path" — a build can print to stdout and still fail.
|
|
if !status.success() {
|
|
bail!(
|
|
"nix build {attr} failed ({status}): {}",
|
|
stderr_buf.lines().last().unwrap_or("").trim()
|
|
);
|
|
}
|
|
single_output_path(&stdout_buf)
|
|
.map(str::to_owned)
|
|
.map_err(|count| {
|
|
anyhow!("nix build {attr} produced {count} output path(s), expected exactly 1: {stdout_buf:?}")
|
|
})
|
|
}
|
|
|
|
/// Parse `nix build --print-out-paths`' stdout down to the single output
|
|
/// path this function's caller expects. `--print-out-paths` prints one
|
|
/// line *per output*, not one line total (`nix build --no-link
|
|
/// --print-out-paths nixpkgs#openssl` prints two: `…-bin`, `…-man`);
|
|
/// `config.system.build.toplevel` is single-output today, so this is one
|
|
/// line in practice — but a bare whole-buffer `.trim()` would silently
|
|
/// hand a multi-line string on to `--system-path` the day that ever
|
|
/// changes, the same corrupted-argument failure this function exists to
|
|
/// avoid. Trims and drops empty lines *before* counting, so a lone
|
|
/// `"\n"` (or trailing whitespace on the real line) can't be mistaken
|
|
/// for a present-but-blank path — see the unit tests below for the exact
|
|
/// table this closes. `Err` carries the surviving line count, for the
|
|
/// caller's error message.
|
|
fn single_output_path(stdout: &str) -> Result<&str, usize> {
|
|
let lines: Vec<&str> = stdout
|
|
.lines()
|
|
.map(str::trim)
|
|
.filter(|l| !l.is_empty())
|
|
.collect();
|
|
match lines[..] {
|
|
[path] => Ok(path),
|
|
_ => Err(lines.len()),
|
|
}
|
|
}
|
|
|
|
/// Invoke `nixos-container` with the given args, log output to journald.
|
|
pub(crate) async fn container_run(args: &[&str]) -> Result<(String, String)> {
|
|
let out = Command::new("nixos-container")
|
|
.args(args)
|
|
.output()
|
|
.await
|
|
.context("invoke nixos-container")?;
|
|
let stdout = String::from_utf8_lossy(&out.stdout).into_owned();
|
|
let stderr = String::from_utf8_lossy(&out.stderr).into_owned();
|
|
// `list` is a read-only enumeration called on the hot path (dashboard
|
|
// rescan, forge + boot sweeps) — its stdout is the return value, not
|
|
// progress, so logging every container name on every call floods the
|
|
// journal. Log stdout only for the mutating ops, where each line is
|
|
// genuine progress. stderr is always logged (errors matter regardless).
|
|
if args.first() != Some(&"list") {
|
|
for line in stdout.lines() {
|
|
tracing::info!(target: "nixos-container", "{line}");
|
|
}
|
|
}
|
|
for line in stderr.lines() {
|
|
tracing::warn!(target: "nixos-container", "{line}");
|
|
}
|
|
if !out.status.success() {
|
|
bail!(
|
|
"nixos-container {} failed ({}): {}",
|
|
args.join(" "),
|
|
out.status,
|
|
stderr.trim()
|
|
);
|
|
}
|
|
Ok((stdout, stderr))
|
|
}
|
|
|
|
/// Invoke `machinectl` with the given args, log output to journald.
|
|
/// Used for operations that nixos-container doesn't expose (e.g. sending
|
|
/// signals to running containers).
|
|
async fn machinectl_run(args: &[&str]) -> Result<(String, String)> {
|
|
let out = Command::new("machinectl")
|
|
.args(args)
|
|
.output()
|
|
.await
|
|
.context("invoke machinectl")?;
|
|
let stdout = String::from_utf8_lossy(&out.stdout).into_owned();
|
|
let stderr = String::from_utf8_lossy(&out.stderr).into_owned();
|
|
for line in stdout.lines() {
|
|
tracing::info!(target: "machinectl", "{line}");
|
|
}
|
|
for line in stderr.lines() {
|
|
tracing::warn!(target: "machinectl", "{line}");
|
|
}
|
|
if !out.status.success() {
|
|
bail!(
|
|
"machinectl {} failed ({}): {}",
|
|
args.join(" "),
|
|
out.status,
|
|
stderr.trim()
|
|
);
|
|
}
|
|
Ok((stdout, stderr))
|
|
}
|
|
|
|
/// How long to wait for machined to drop a machine's registration after a
|
|
/// shutdown has been asked for. Generous: a container with slow-stopping
|
|
/// units legitimately takes a while, and escalating early would SIGKILL a
|
|
/// shutdown that was going to finish on its own.
|
|
const NAME_RELEASE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(20);
|
|
|
|
/// Poll cadence while waiting for the registration to go away.
|
|
const NAME_RELEASE_POLL: std::time::Duration = std::time::Duration::from_millis(500);
|
|
|
|
/// Cap on a single registration probe. The probe is a D-Bus round trip to
|
|
/// machined; if machined itself is wedged the call would otherwise sit on
|
|
/// the D-Bus method timeout, which is far longer than the whole stop
|
|
/// sequence should take.
|
|
const MACHINE_PROBE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(5);
|
|
|
|
/// True when machined still holds a registration for `machine`.
|
|
///
|
|
/// This asks machined the same question the registration itself answers —
|
|
/// `machinectl show` resolves the name through `GetMachine`, the very lookup
|
|
/// that makes a later boot fail with `Failed to register machine: already
|
|
/// exists`. So a positive answer here is exactly the condition that breaks
|
|
/// the next start, not a proxy for it.
|
|
///
|
|
/// Deliberately NOT the container's systemd unit state: the unit can be
|
|
/// `inactive` while the registration is still held, and that gap is the
|
|
/// whole bug this probe exists to catch.
|
|
///
|
|
/// Errors and timeouts answer "still registered". Being wrong that way costs
|
|
/// a redundant SIGKILL to something already gone; being wrong the other way
|
|
/// hands back a stop that silently leaked the name.
|
|
async fn machine_registered(machine: &str) -> bool {
|
|
let probe = Command::new("machinectl")
|
|
.args(["show", machine, "--property=Name"])
|
|
// Don't leave a probe behind when the timeout below fires.
|
|
.kill_on_drop(true)
|
|
.output();
|
|
match tokio::time::timeout(MACHINE_PROBE_TIMEOUT, probe).await {
|
|
Ok(Ok(out)) => out.status.success(),
|
|
Ok(Err(e)) => {
|
|
tracing::warn!(%machine, error = %e, "machinectl show failed to run; assuming still registered");
|
|
true
|
|
}
|
|
Err(_) => {
|
|
tracing::warn!(%machine, "machinectl show timed out; assuming still registered");
|
|
true
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Poll [`machine_registered`] until the name is free or the timeout expires.
|
|
/// Returns true once the registration is gone.
|
|
async fn wait_name_released(machine: &str) -> bool {
|
|
let deadline = tokio::time::Instant::now() + NAME_RELEASE_TIMEOUT;
|
|
loop {
|
|
if !machine_registered(machine).await {
|
|
return true;
|
|
}
|
|
if tokio::time::Instant::now() >= deadline {
|
|
return false;
|
|
}
|
|
tokio::time::sleep(NAME_RELEASE_POLL).await;
|
|
}
|
|
}
|
|
|
|
/// Stop `machine` and only report success once machined has actually released
|
|
/// the name.
|
|
///
|
|
/// `nixos-container stop` exiting 0 does not mean the machine is gone. A
|
|
/// process that sits in the machine's cgroup without being a child of the
|
|
/// container's init — a shell exec'd in from outside, say — never receives
|
|
/// the shutdown's SIGTERM if it has been stopped with SIGSTOP, so the
|
|
/// registration outlives the "successful" stop. Every later start of that
|
|
/// container then fails with `Failed to register machine: already exists`,
|
|
/// and machined re-persists the stale record across its own restart, so
|
|
/// there is no cleaning it up after the fact. The only place to catch it is
|
|
/// here, in the stop.
|
|
///
|
|
/// So: ask for the stop, wait for the name, and fail loudly if it is still
|
|
/// held — a caller that is told the stop worked will go on to start the
|
|
/// container and hit the confusing registration error instead of this one.
|
|
///
|
|
/// This used to escalate to `machinectl kill --signal=SIGKILL` here. Dropped
|
|
/// per real incident data: the case this guards is a container genuinely
|
|
/// wedged (e.g. a root-login process survived the stop), and SIGKILL
|
|
/// doesn't recover that in practice — only a host-level reboot has.
|
|
/// Pretending a kill attempt handled it hides a condition that needs a human
|
|
/// to look at the host, so this now just reports the failure instead of
|
|
/// quietly (and ineffectually) trying to force it.
|
|
///
|
|
/// The verify-then-fail lives in the helper rather than at a call site so
|
|
/// that every stop gets it: dashboard, reconcile, destroy, cold-start
|
|
/// fallback. The start path already distrusts its own exit code the same way;
|
|
/// this is the missing half of that pair.
|
|
async fn stop_and_release(machine: &str) -> Result<(String, String)> {
|
|
let stop = container_run(&["stop", machine]).await;
|
|
if wait_name_released(machine).await {
|
|
// Happy path, and also the path where a stop that reported failure
|
|
// nonetheless brought the machine down. Either way the caller gets
|
|
// the original result untouched.
|
|
return stop;
|
|
}
|
|
|
|
tracing::error!(
|
|
%machine,
|
|
"stop finished but machined still holds the registration — the \
|
|
container is likely wedged (a process outside its init tree \
|
|
survived the stop); this needs a host-level look, not another \
|
|
stop attempt"
|
|
);
|
|
bail!(
|
|
"stop {machine}: machined still holds the machine name after {}s. \
|
|
This container is likely wedged and starting it again will fail to \
|
|
register — needs host-level intervention (a reboot has been the \
|
|
only reliable fix in practice).",
|
|
NAME_RELEASE_TIMEOUT.as_secs()
|
|
)
|
|
}
|
|
|
|
/// Invoke `nixos-container` with the given args and forward output lines
|
|
/// to the caller as `PrivEvent::Line` messages in real time, logging each
|
|
/// line to journald as it arrives. Returns `(String::new(), String::new())`
|
|
/// on success (all output was streamed); the error string includes stderr
|
|
/// tail on failure.
|
|
async fn container_run_streaming(
|
|
args: &[&str],
|
|
writer: &mut OwnedWriteHalf,
|
|
) -> Result<(String, String)> {
|
|
use tokio::io::AsyncBufReadExt as _;
|
|
use tokio::process::Command;
|
|
|
|
let mut child = Command::new("nixos-container")
|
|
.args(args)
|
|
.stdout(std::process::Stdio::piped())
|
|
.stderr(std::process::Stdio::piped())
|
|
.spawn()
|
|
.context("invoke nixos-container (streaming)")?;
|
|
|
|
let stdout = child.stdout.take().expect("stdout piped");
|
|
let stderr = child.stderr.take().expect("stderr piped");
|
|
|
|
let mut stdout_lines = BufReader::new(stdout).lines();
|
|
let mut stderr_lines = BufReader::new(stderr).lines();
|
|
|
|
// Collect stderr for the error message; stream both to the client.
|
|
let mut stderr_buf = String::new();
|
|
|
|
// Drive stdout and stderr concurrently. `tokio::select!` interleaves
|
|
// them without bias — both streams drain at roughly the same rate
|
|
// as the subprocess produces output.
|
|
loop {
|
|
tokio::select! {
|
|
line = stdout_lines.next_line() => {
|
|
match line {
|
|
Ok(Some(l)) => {
|
|
tracing::info!(target: "nixos-container", "{l}");
|
|
write_line_event(writer, PrivStream::Stdout, &l).await;
|
|
}
|
|
Ok(None) => break,
|
|
Err(e) => {
|
|
tracing::warn!(error = %e, "nixos-container stdout read error");
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
line = stderr_lines.next_line() => {
|
|
match line {
|
|
Ok(Some(l)) => {
|
|
tracing::warn!(target: "nixos-container", "{l}");
|
|
write_line_event(writer, PrivStream::Stderr, &l).await;
|
|
if !stderr_buf.is_empty() {
|
|
stderr_buf.push('\n');
|
|
}
|
|
stderr_buf.push_str(&l);
|
|
}
|
|
Ok(None) => {}
|
|
Err(e) => {
|
|
tracing::warn!(error = %e, "nixos-container stderr read error");
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Drain any remaining stderr after stdout closed.
|
|
while let Ok(Some(l)) = stderr_lines.next_line().await {
|
|
tracing::warn!(target: "nixos-container", "{l}");
|
|
write_line_event(writer, PrivStream::Stderr, &l).await;
|
|
if !stderr_buf.is_empty() {
|
|
stderr_buf.push('\n');
|
|
}
|
|
stderr_buf.push_str(&l);
|
|
}
|
|
|
|
let status = child.wait().await.context("wait nixos-container")?;
|
|
if !status.success() {
|
|
// Only the last stderr line is embedded — the full stderr was
|
|
// already forwarded line-by-line as PrivEvent::Line messages and
|
|
// is captured in build_logs.sqlite by the caller. Keeping the
|
|
// error message short avoids bloating the anyhow chain.
|
|
bail!(
|
|
"nixos-container {} failed ({}): {}",
|
|
args.join(" "),
|
|
status,
|
|
stderr_buf.lines().last().unwrap_or("").trim()
|
|
);
|
|
}
|
|
Ok((String::new(), String::new()))
|
|
}
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::{BoundedRun, run_bounded, single_output_path, toplevel_attr};
|
|
use crate::shared::test_support::scratch;
|
|
|
|
/// Pins the exact attr path we hand to `nix build` — it has to match
|
|
/// `hive-c0re`'s own `lifecycle::prebuild_toplevel` construction, since
|
|
/// that step's whole point is warming the store for this later build.
|
|
/// A drifted attr path defeats the cache-warming silently — no error,
|
|
/// just a slower `update`.
|
|
#[test]
|
|
fn toplevel_attr_matches_prebuild_toplevels_construction() {
|
|
assert_eq!(
|
|
toplevel_attr("atlas"),
|
|
"/var/lib/hyperhive/meta#nixosConfigurations.atlas.config.system.build.toplevel"
|
|
);
|
|
}
|
|
|
|
/// `--print-out-paths`' exact per-shape table (measured against real
|
|
/// `rustc` semantics, not just reasoned about) — a lone `"\n"` and a
|
|
/// trailing-space path are the two shapes a bare `.lines().collect()`
|
|
/// gets wrong, both catchable only by trimming + dropping empties
|
|
/// before counting rather than after.
|
|
#[test]
|
|
fn single_output_path_rejects_blank_and_trims_whitespace() {
|
|
assert_eq!(single_output_path(""), Err(0));
|
|
assert_eq!(single_output_path("\n"), Err(0));
|
|
assert_eq!(single_output_path("\n\n"), Err(0));
|
|
assert_eq!(single_output_path("/nix/store/abc\n"), Ok("/nix/store/abc"));
|
|
assert_eq!(
|
|
single_output_path("/nix/store/abc \n"),
|
|
Ok("/nix/store/abc")
|
|
);
|
|
assert_eq!(
|
|
single_output_path("/nix/store/abc\r\n"),
|
|
Ok("/nix/store/abc")
|
|
);
|
|
assert_eq!(
|
|
single_output_path("/nix/store/abc\n/nix/store/def\n"),
|
|
Err(2)
|
|
);
|
|
}
|
|
|
|
/// Whether `pid` is gone, or only a zombie awaiting its reaper: either
|
|
/// way it is no longer running.
|
|
fn is_dead(pid: &str) -> bool {
|
|
match std::fs::read_to_string(format!("/proc/{pid}/stat")) {
|
|
Err(_) => true,
|
|
// The state letter follows the parenthesised command name.
|
|
Ok(stat) => stat
|
|
.rsplit_once(") ")
|
|
.is_some_and(|(_, rest)| rest.starts_with('Z')),
|
|
}
|
|
}
|
|
|
|
/// A child still running at the limit comes back `TimedOut`, and the kill
|
|
/// reaches its whole process group: the backgrounded grandchild dies too,
|
|
/// not just the shell that started it.
|
|
#[tokio::test]
|
|
async fn a_build_past_its_limit_is_killed_with_its_group() {
|
|
use std::time::{Duration, Instant};
|
|
let dir = scratch();
|
|
let pidfile = dir.join("grandchild");
|
|
let mut cmd = tokio::process::Command::new("sh");
|
|
cmd.arg("-c")
|
|
.arg(format!("sleep 600 & echo $! > {}; wait", pidfile.display()));
|
|
|
|
let started = Instant::now();
|
|
let run = run_bounded(cmd, Duration::from_secs(2), None)
|
|
.await
|
|
.unwrap();
|
|
assert!(matches!(run, BoundedRun::TimedOut), "must time out");
|
|
assert!(
|
|
started.elapsed() < Duration::from_mins(1),
|
|
"the limit did not bound the run"
|
|
);
|
|
|
|
let pid = std::fs::read_to_string(&pidfile).unwrap();
|
|
let pid = pid.trim();
|
|
let deadline = Instant::now() + Duration::from_secs(5);
|
|
while !is_dead(pid) && Instant::now() < deadline {
|
|
tokio::time::sleep(Duration::from_millis(50)).await;
|
|
}
|
|
assert!(is_dead(pid), "grandchild {pid} outlived the timeout");
|
|
std::fs::remove_dir_all(&dir).ok();
|
|
}
|
|
|
|
/// The control: a child that exits inside its limit is `Exited`, with its
|
|
/// status and both streams kept apart.
|
|
#[tokio::test]
|
|
async fn a_build_inside_its_limit_returns_its_output() {
|
|
let mut cmd = tokio::process::Command::new("sh");
|
|
cmd.arg("-c")
|
|
.arg("echo progress >&2; echo /nix/store/abc-toplevel");
|
|
match run_bounded(cmd, std::time::Duration::from_mins(1), None)
|
|
.await
|
|
.unwrap()
|
|
{
|
|
BoundedRun::Exited {
|
|
status,
|
|
stdout,
|
|
stderr,
|
|
} => {
|
|
assert!(status.success());
|
|
assert_eq!(stdout, "/nix/store/abc-toplevel\n");
|
|
assert_eq!(stderr, "progress");
|
|
}
|
|
BoundedRun::TimedOut => panic!("a fast child must not time out"),
|
|
}
|
|
}
|
|
}
|