From 05620a4080be07e4f66bd55ad2dfd4722b6fdcb8 Mon Sep 17 00:00:00 2001 From: damocles Date: Sun, 30 Aug 2026 15:37:35 +0200 Subject: [PATCH] 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. --- hive-priv/src/main.rs | 91 +++++++++++++++++++++++++++++++++++++++---- 1 file changed, 83 insertions(+), 8 deletions(-) diff --git a/hive-priv/src/main.rs b/hive-priv/src/main.rs index 45800558..a31b1cf8 100644 --- a/hive-priv/src/main.rs +++ b/hive-priv/src/main.rs @@ -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 … --system-path ` (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..config.system.build.toplevel` /// flake attr path — same construction `hive-c0re`'s own /// `lifecycle::prebuild_toplevel` uses, kept here as a pure function so