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:
parent
a5ea015bc6
commit
efbfec6d01
39 changed files with 286 additions and 3199 deletions
|
|
@ -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`
|
||||
|
|
|
|||
Loading…
Reference in a new issue