From 4beaeea7b3a20afe69d65f317c6b4406e66aaa9c Mon Sep 17 00:00:00 2001 From: damocles Date: Mon, 22 Jun 2026 19:28:45 +0200 Subject: [PATCH] meta: non-mutating verify_commit + pre-flight eval before apply --- hive-c0re/src/actions.rs | 31 +++++++++++++++++++++++ hive-c0re/src/meta.rs | 53 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 84 insertions(+) diff --git a/hive-c0re/src/actions.rs b/hive-c0re/src/actions.rs index 13d6f40d..9206a518 100644 --- a/hive-c0re/src/actions.rs +++ b/hive-c0re/src/actions.rs @@ -493,6 +493,37 @@ async fn run_apply_commit( } }; + // Pre-flight eval-verify the proposal commit against the meta flake + // WITHOUT mutating applied/main or the meta lock, so an evaluation + // error (bad nix, missing module option, unresolvable lock) fails + // fast here instead of after we've fast-forwarded main and have to + // roll it back. Skipped on first spawn: the agent has no + // `agent-` meta input to override yet (sync_agents adds it + // below). This is the reusable verify primitive the PR-based config + // flow gates its irreversible ff-push on. + if !is_first_spawn { + let proposal_sha = match lifecycle::git_rev_parse(applied_dir, &proposal_ref).await { + Ok(s) => s, + Err(e) => { + return ( + Err(anyhow::anyhow!("rev-parse {proposal_ref}: {e:#}")), + None, + is_first_spawn, + ); + } + }; + coord.set_queue_step(queue_entry_id, "verify proposal (eval)"); + if let Err(e) = + crate::meta::verify_commit(&approval.agent, applied_dir, &proposal_sha).await + { + return ( + Err(anyhow::anyhow!("verify proposal {proposal_ref}: {e:#}")), + None, + is_first_spawn, + ); + } + } + coord.set_queue_step(queue_entry_id, "plant tags"); if let Err(e) = lifecycle::git_tag(applied_dir, &format!("approved/{id}"), &proposal_ref).await { diff --git a/hive-c0re/src/meta.rs b/hive-c0re/src/meta.rs index 87ef7b5c..29a3a3c2 100644 --- a/hive-c0re/src/meta.rs +++ b/hive-c0re/src/meta.rs @@ -287,6 +287,50 @@ pub async fn lock_update_for_rebuild(name: &str) -> Result<()> { git_commit(&dir, &format!("rebuild {name}: lock update")).await } +/// Build the `--override-input` value pinning an agent's config repo to +/// an exact revision: `git+file://?rev=`. Pure so the +/// URL shape is unit-testable without a nix shellout. +fn agent_input_override(applied_dir: &Path, sha: &str) -> String { + format!("git+file://{}?rev={sha}", applied_dir.display()) +} + +/// Non-mutating "would this commit apply?" verify for the PR-based config +/// flow. Evaluates the agent's nixos configuration with its meta +/// input overridden to the exact `sha`, WITHOUT moving `applied/main` or +/// writing the real meta `flake.lock`: `--override-input` pins the input +/// at eval time only and `--no-write-lock-file` guarantees the on-disk +/// lock is never touched (a genuinely-needed lock change surfaces as an +/// error rather than a silent mutation). Confirms the flake at `sha` +/// evaluates and the meta lock resolves around it. `Ok(())` = would +/// apply; `Err` carries the eval/lock failure so the operator's approve +/// is rejected before any live-state change. +/// +/// Precondition: `sha` must be reachable in `applied_dir`'s object store +/// — the caller fetches the PR head into `applied_dir` first. Eval-only +/// (no build): catches nix / eval / module-option errors + lock +/// resolution at the same cost profile as the legacy pre-merge check; +/// the real container build still runs (and can still roll back) on the +/// actual apply, so this is the right "would this apply" gate. +pub async fn verify_commit(name: &str, applied_dir: &Path, sha: &str) -> Result<()> { + let _guard = META_LOCK.lock().await; + let dir = meta_dir(); + let input = format!("agent-{name}"); + let over = agent_input_override(applied_dir, sha); + let attr = format!(".#nixosConfigurations.{name}.config.system.build.toplevel.drvPath"); + nix( + &dir, + &[ + "eval", + &attr, + "--override-input", + &input, + &over, + "--no-write-lock-file", + ], + ) + .await +} + /// Update one or more named inputs in the meta flake and commit /// the resulting lock change with a single combined message. /// Used by the dashboard's "update meta inputs" form so the @@ -1131,6 +1175,15 @@ mod tests { } } + #[test] + fn agent_input_override_pins_exact_rev() { + let p = Path::new("/var/lib/hyperhive/agents/iris/applied"); + assert_eq!( + agent_input_override(p, "abc123def"), + "git+file:///var/lib/hyperhive/agents/iris/applied?rev=abc123def" + ); + } + #[test] fn render_flake_uses_explicit_nixpkgs_url_when_provided() { let out = render_flake(