Watch
0
0
Fork
You've already forked hyperhive
0

config PRs: remove the hive's config-PR webhook, poll and core merge

An operator's merge on the forge deploys a config PR through
swarm-controller's DeployRequest{rev}. The hive-side path that queued a
MergeConfigPr approval and merged the PR as `core` goes:

- the `/webhook/config-pr` receiver, its HMAC secret, the WebhookRegister
  boot node and the org-hook registration; the hive vhost's `/webhook/`
  location
- the 5-minute config-PR poll
- ApprovalKind::MergeConfigPr, its dashboard card, and the deploy DAG it
  drove (DeployWindow, MergeVerify, DeployApply, FinalizeDeploy,
  DeployTail), with verify_commit, the two-phase meta deploy, rollback
  refs, the PR-failure comment and forge/pr_merge.rs
- `fetched_sha`, `sha_short`/`pr_number` on approval events, and
  `sha`/`tag` on HelperEvent::ApprovalResolved: only the merge path set
  them

`config_repo`, `merged_pr_for_commit` and `post_pr_comment` move to
forge/pr_comment.rs for the merged-rev deploy's refusal comment.
Approvals v5 drops stored `merge_config_pr` rows; a test reopens a v4
database holding them.

Closes #4850
This commit is contained in:
atlas 2026-10-02 22:32:17 +02:00
commit efbfec6d01
39 changed files with 286 additions and 3199 deletions

View file

@ -1,7 +1,7 @@
//! Single hive-c0re-owned flake at `/var/lib/hyperhive/meta/` that
//! exports one `nixosConfiguration` per agent and drives the system-wide
//! deploy audit trail. Flow (`sync_agents`, two-phase `prepare_deploy` /
//! `finalize_deploy` / `abort_deploy`, `lock_update_hyperhive`):
//! deploy audit trail. Flow (`sync_agents`, `lock_update_for_rebuild`,
//! `lock_update_hyperhive`):
//! `docs/agent-lifecycle/approvals.md::Meta flake`.
use std::path::Path;
@ -28,16 +28,12 @@ const GIT_EMAIL: &str = "c0re@hyperhive.local";
static META_LOCK: Mutex<()> = Mutex::const_new(());
// Exclusivity for meta-repo *windows* that span multiple `META_LOCK`
// acquisitions — above all the two-phase deploy (`prepare_deploy` stages
// `flake.lock` uncommitted for the whole container build;
// `finalize_deploy` / `abort_deploy` resolve it) — is **not** a mutex in
// this module. `META_LOCK` above serializes individual git ops but cannot
// keep another op out of that staged window; that window is owned by the
// job queue instead, as `Resource::MetaWindow`, declared by every
// meta-mutating node kind (it declares `Resource::MetaWindow`). A resource can
// be held by a subtree root across its children, which a `MutexGuard`
// (bounded by one executor fn) cannot — that's what lets the deploy be
// modelled as sub-nodes rather than one opaque node.
// acquisitions is **not** a mutex in this module. `META_LOCK` above
// serializes individual git ops but cannot keep another op out of a window
// spanning several of them; that window is owned by the job queue instead,
// as `Resource::MetaWindow`, declared by every meta-mutating node kind. A
// resource can be held by a subtree root across its children, which a `MutexGuard`
// (bounded by one executor fn) cannot.
/// Where the manager sees this directory inside its container (RO bind).
pub const CONTAINER_MANAGER_META_MOUNT: &str = "/meta";
@ -231,75 +227,6 @@ pub async fn sync_agents(hive: &HiveEnv, agents: &[AgentSpec]) -> Result<()> {
Ok(())
}
/// Phase 1 of an apply-commit deploy. Updates the locked rev of
/// `agent-<name>` to whatever `applied/<name>/main` currently points
/// at and **stages** the lock so `nixos-container update --flake
/// meta#<n>` (which reads via `git+file://`) sees the new rev via
/// the index. Doesn't commit — `finalize_deploy` commits on build
/// success, `abort_deploy` drops the staged change on failure so
/// meta history only carries successful deploys.
pub async fn prepare_deploy(name: &str, node_id: Option<u64>) -> Result<()> {
let _guard = META_LOCK.lock().await;
let dir = crate::paths::meta_root();
let input = format!("agent-{name}");
// Re-lock the agent input against the LOCAL applied mirror, not the
// persistent forge URL declared in the meta flake (see the `## Meta flake`
// note in docs/agent-lifecycle/approvals.md): the deploy must build the exact reviewed
// config that `verify_commit` gated and `applied/<n>/main` was
// fast-forwarded to, and it must keep working when the forge is
// unreachable (rebuilds fire on crash-restart / meta bumps too, not just
// config PRs). `--override-input` writes the applied rev into the lock;
// the forge URL stays the declared, reviewable source of truth.
let applied = applied_override_url(&crate::paths::applied_dir(name));
nix_logged(
&dir,
&[
"flake",
"update",
&input,
"--override-input",
&input,
&applied,
],
name,
"prepare-deploy",
node_id,
)
.await?;
// Stage the new lock — git+file://'s dirty-tree fetcher reads
// index entries, so the upcoming nixos-container update sees the
// bumped rev without a commit yet.
git(&dir, &["add", "flake.lock"]).await
}
/// Phase 2-success. Commit the staged lock with the deployed tag +
/// sha as the message. No-op when the rev was already at the right
/// place (nothing staged → nothing to commit).
pub async fn finalize_deploy(name: &str, sha: &str, tag: &str) -> Result<()> {
let _guard = META_LOCK.lock().await;
let dir = crate::paths::meta_root();
if !paths_dirty(&dir, &["flake.lock"]).await? {
return Ok(());
}
let short = &sha[..sha.len().min(12)];
git_commit_paths(
&dir,
&format!("deploy {name} {tag} {short}"),
&["flake.lock"],
)
.await
}
/// Phase 2-failure. Unstage + restore the lock so meta returns to
/// the previously-committed shas. The failed proposal is still
/// captured in `applied/<n>`'s annotated `failed/<id>` tag.
pub async fn abort_deploy() -> Result<()> {
let _guard = META_LOCK.lock().await;
let dir = crate::paths::meta_root();
git(&dir, &["restore", "--staged", "flake.lock"]).await?;
git(&dir, &["restore", "flake.lock"]).await
}
/// One-shot used by the manual-rebuild path: relock just one
/// agent's input and commit the lock change if any. Single-phase
/// (no separate finalize) because rebuild has no failure-revert
@ -308,9 +235,9 @@ pub async fn lock_update_for_rebuild(name: &str) -> Result<()> {
let _guard = META_LOCK.lock().await;
let dir = crate::paths::meta_root();
let input = format!("agent-{name}");
// Re-lock from the local applied mirror, not the persistent forge URL —
// same rationale as `prepare_deploy`: build exactly `applied/<n>/main` and
// stay reproducible when the forge is unreachable.
// Re-lock from the local applied mirror, not the persistent forge URL:
// build exactly `applied/<n>/main` and stay reproducible when the forge
// is unreachable.
let applied = applied_override_url(&crate::paths::applied_dir(name));
nix(
&dir,
@ -336,13 +263,6 @@ pub async fn lock_update_for_rebuild(name: &str) -> Result<()> {
.await
}
/// Build the `--override-input` value pinning an agent's config repo to
/// an exact revision: `git+file://<applied_dir>?rev=<sha>`. 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())
}
/// `--override-input` URL re-locking an agent's config input against its
/// LOCAL applied mirror (`git+file://<applied_dir>`, current `main` head)
/// instead of the persistent forge URL declared in the meta flake. The
@ -354,51 +274,6 @@ fn applied_override_url(applied_dir: &Path) -> String {
format!("git+file://{}", 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,
node_id: Option<u64>,
) -> Result<()> {
let _guard = META_LOCK.lock().await;
let dir = crate::paths::meta_root();
let input = format!("agent-{name}");
let over = agent_input_override(applied_dir, sha);
let attr = format!(".#nixosConfigurations.{name}.config.system.build.toplevel.drvPath");
nix_logged(
&dir,
&[
"eval",
&attr,
"--override-input",
&input,
&over,
"--no-write-lock-file",
],
name,
"verify",
node_id,
)
.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
@ -447,7 +322,7 @@ pub async fn lock_update_hyperhive() -> Result<()> {
/// Write the tool-groups file for `agent` and commit it atomically
/// under `META_LOCK`. Ensures the JSON change is staged + committed
/// before the next `prepare_deploy` or `sync_agents` runs, so the
/// before the next `sync_agents` runs, so the
/// working tree is never left dirty by an untimely `PermChange` write.
pub async fn commit_tool_groups(agent: &str, groups: &[String]) -> Result<()> {
let _guard = META_LOCK.lock().await;
@ -490,8 +365,8 @@ pub async fn commit_capabilities(agent: &str, caps: &[String]) -> Result<()> {
/// Write the resource-limits file for `agent` and commit it atomically
/// under `META_LOCK`. Same rationale as `commit_tool_groups`: the
/// working tree must never be left dirty for the next `prepare_deploy`
/// or `sync_agents` to trip over.
/// working tree must never be left dirty for the next `sync_agents` to trip
/// over.
///
/// Unlike the perm files this one is never injected into the container —
/// it's a host-side cap *on* the agent — but it lives in the same repo
@ -1048,9 +923,7 @@ where
// on-disk state and boot never depends on the forge being reachable.
// The forge `agent-configs/<name>` repos stay the review/audit surface
// (config PRs land there) but are not the flake's build input. deploy +
// rebuild re-lock this input to `applied/<name>`'s current `main` head;
// `verify_commit` overrides it to a proposed `?rev=<sha>` for eval
// before that head moves.
// rebuild re-lock this input to `applied/<name>`'s current `main` head.
for spec in agents {
let _ = writeln!(
out,
@ -1459,8 +1332,7 @@ async fn git_commit(dir: &Path, message: &str) -> Result<()> {
}
/// Path-limited commit: commits ONLY the given paths, so unrelated
/// staged content — above all a `prepare_deploy`-staged `flake.lock`
/// — can never be swept into someone else's commit. Every targeted
/// staged content can never be swept into someone else's commit. Every targeted
/// meta commit (perm files, topology, lock bumps) goes through this;
/// only `sync_agents` uses the bare [`git_commit`], because its
/// staged set *is* its intentional commit set.
@ -1507,7 +1379,7 @@ fn nix_argv<'a>(args: &[&'a str]) -> Vec<&'a str> {
}
/// Run `nix <args>` in `dir` (flakes enabled), capturing combined output.
/// Shared core of [`nix`] and [`nix_logged`].
/// The core of [`nix`].
async fn nix_output(dir: &Path, args: &[&str]) -> Result<std::process::Output> {
Command::new("nix")
.current_dir(dir)
@ -1540,66 +1412,11 @@ async fn nix(dir: &Path, args: &[&str]) -> Result<()> {
nix_check(args, &out)
}
/// Like [`nix`] but records the invocation (cmdline + stdout + stderr +
/// terminal status) into a `build_logs.sqlite` row so a config-approval
/// eval/deploy step shows up on the dashboard. This is what makes a
/// failing eval-verify visible: it runs before any container build, so
/// without a row a rejected config approval leaves the operator with
/// zero build logs to look at. Best-effort logging: a missing global
/// handle or a failed `start()` just skips the row — the command still
/// runs and its exit status is still enforced.
async fn nix_logged(
dir: &Path,
args: &[&str],
agent: &str,
kind: &str,
node_id: Option<u64>,
) -> Result<()> {
let cmdline = format!("nix {}", nix_argv(args).join(" "));
let logs = crate::build_logs::global();
let log_id = logs.as_ref().and_then(|h| {
// `node_id` links the row to the queue node that ran it, so the
// dashboard can reach this log from the node instead of only from the
// agent+kind+time listing. Both call paths are queue-only today and
// pass `Some`; it stays an `Option` because these are `meta`'s public
// API and a future non-queue caller has no node to name.
h.start(agent, kind, &cmdline, node_id)
.map_err(|e| {
tracing::warn!(error = ?e, %kind, "build_logs: start failed (meta log dropped)");
})
.ok()
});
let out = nix_output(dir, args).await?;
if let (Some(h), Some(id)) = (&logs, log_id) {
for line in String::from_utf8_lossy(&out.stdout).lines() {
h.append_stdout(id, line);
}
for line in String::from_utf8_lossy(&out.stderr).lines() {
h.append_stderr(id, line);
}
h.finish(
id,
if out.status.success() {
crate::build_logs::BuildStatus::Ok
} else {
crate::build_logs::BuildStatus::Fail
},
);
}
nix_check(args, &out)
}
#[cfg(test)]
mod tests {
use super::*;
/// The regression the deploy-window bug review surfaced: a
/// path-limited commit must leave an unrelated staged file (the
/// prepare_deploy-staged `flake.lock`) untouched, so a later
/// `abort_deploy` still has something to restore.
/// A path-limited commit must leave an unrelated staged file untouched.
#[tokio::test]
async fn path_limited_commit_leaves_unrelated_staged_file_alone() {
let tmp = tempfile::tempdir().expect("tempdir");
@ -1611,7 +1428,7 @@ mod tests {
std::fs::write(dir.join("flake.lock"), "v1").expect("write");
git(dir, &["add", "-A"]).await.expect("add");
git_commit(dir, "seed").await.expect("seed commit");
// A deploy stages a new lock (uncommitted)…
// Something stages a new lock (uncommitted)…
std::fs::write(dir.join("flake.lock"), "v2-staged-by-deploy").expect("write");
git(dir, &["add", "flake.lock"]).await.expect("stage lock");
// …and a perm change commits, path-limited.
@ -1620,7 +1437,7 @@ mod tests {
git_commit_paths(dir, "set tool-groups for alice", &["tool-groups.json"])
.await
.expect("path-limited commit");
// The perm file is committed; the deploy's staged lock is not.
// The perm file is committed; the staged lock is not.
assert!(
!paths_dirty(dir, &["tool-groups.json"])
.await
@ -1641,15 +1458,6 @@ 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 applied_override_url_targets_local_mirror_main_head() {
// Deploy + rebuild re-lock against the local applied mirror's `main`