hive-priv: bypass nixos-container update, apply prebuilt toplevel directly

nixos-container update's own version-compat probe runs unconditionally
before --system-path is ever honored, dying on every agent's update.
Confirmed against nixos-container.pl's actual source: past that probe,
update's own action is just nix-env --set on the per-container profile,
then a systemctl reload if the container is running. Replicate that
directly instead of going through nixos-container update at all. create
is untouched.
This commit is contained in:
damocles 2026-08-30 15:37:35 +02:00
commit 05620a4080

View file

@ -731,8 +731,7 @@ async fn exec_send_agent_snapshot_to_fd(
}
/// Shared body for `CreateContainer` / `UpdateContainer`: validate the
/// name, build the toplevel ourselves, and run
/// `nixos-container <verb> … --system-path <built>` (streaming line
/// 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
@ -747,6 +746,15 @@ async fn exec_send_agent_snapshot_to_fd(
/// 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.
async fn container_flake_action(
verb: &str,
name: &str,
@ -762,12 +770,11 @@ async fn container_flake_action(
// 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 args = [
verb,
&container_system_name(name),
"--system-path",
&toplevel,
];
let system_name = container_system_name(name);
if verb == "update" {
return swap_container_profile(&system_name, &toplevel).await;
}
let args = [verb, &system_name, "--system-path", &toplevel];
if stream {
container_run_streaming(&args, writer).await
} else {
@ -775,6 +782,74 @@ async fn container_flake_action(
}
}
/// 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.
///
/// Deliberately not stream-forwarded to `writer` — both operations here
/// are near-instant (a profile symlink swap, a `systemctl reload`), unlike
/// the multi-minute build `container_flake_action` already streams.
async fn swap_container_profile(system_name: &str, toplevel: &str) -> 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();
for line in stdout.lines() {
tracing::info!(target: "nixos-container", "{line}");
}
for line in stderr.lines() {
tracing::warn!(target: "nixos-container", "{line}");
}
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();
for line in r_stdout.lines() {
tracing::info!(target: "nixos-container", "{line}");
}
for line in r_stderr.lines() {
tracing::warn!(target: "nixos-container", "{line}");
}
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))
}
/// 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