A deploy request whose rev hive-c0re refuses (fetch failed, or the rev does not descend from applied/main) no longer tries to comment on the PR that merged it. Off the forge host that attempt always failed with "forge core token absent". The refusal now logs at ERROR, message unchanged, and the new `hh-config-rev-refused` Grafana rule fires on it. Deleted with the posting: actions::post_rev_deploy_failure_to_pr, the whole forge/pr_comment.rs (config_repo, merged_pr_for_commit, post_pr_comment), and ReaderToken::value, whose only caller was merged_pr_for_commit. Refs #4851
238 lines
9.2 KiB
Rust
238 lines
9.2 KiB
Rust
//! Operations that are exposed through more than one surface (the host admin
|
|
//! socket *and* the dashboard's POST endpoints). Each function takes a
|
|
//! `&Coordinator` and the request parameters; callers stitch the response
|
|
//! shape they want (HTTP redirect vs JSON).
|
|
|
|
use std::sync::Arc;
|
|
|
|
use anyhow::{Context as _, Result, bail};
|
|
|
|
use crate::coordinator::Coordinator;
|
|
use crate::lifecycle;
|
|
|
|
/// `applied/main`, and whether `target` descends from it. Fast-forwarding
|
|
/// `main` to a `target` that does not is a rewind: it drops every commit on
|
|
/// `main` that `target` lacks.
|
|
async fn applied_main_under(applied_dir: &std::path::Path, target: &str) -> Result<(String, bool)> {
|
|
let current_main = lifecycle::git_rev_parse(applied_dir, "refs/heads/main")
|
|
.await
|
|
.map_err(|e| anyhow::anyhow!("read applied/main: {e:#}"))?;
|
|
let descends = lifecycle::git_is_ancestor(applied_dir, ¤t_main, target)
|
|
.await
|
|
.map_err(|e| anyhow::anyhow!("ancestry check {current_main}..{target}: {e:#}"))?;
|
|
Ok((current_main, descends))
|
|
}
|
|
|
|
/// What [`advance_applied_to_rev`] did to `applied/main`.
|
|
#[derive(Debug, PartialEq, Eq)]
|
|
pub(crate) enum RevAdvance {
|
|
/// `applied/main` already was the rev, so there is nothing to deploy.
|
|
AlreadyApplied,
|
|
/// `applied/main` was fast-forwarded to the rev.
|
|
Advanced,
|
|
}
|
|
|
|
/// Fast-forward `agent`'s `applied/main` to `rev`, a commit the swarm asks
|
|
/// this hive to deploy, so the next relocking rebuild builds it.
|
|
///
|
|
/// No eval-verify on this path: a config that does not evaluate fails the
|
|
/// rebuild instead, with `applied/main` already at `rev`.
|
|
///
|
|
/// # Errors
|
|
///
|
|
/// Returns an error if the forge fetch fails, if `rev` does not descend from
|
|
/// `applied/main`, or if `main` moved during the fast-forward. `main` does not
|
|
/// move in any of these.
|
|
pub(crate) async fn advance_applied_to_rev(agent: &str, rev: &str) -> Result<RevAdvance> {
|
|
advance_applied_main(
|
|
&crate::paths::applied_dir(agent),
|
|
rev,
|
|
crate::forge::fetch_forge_main(agent),
|
|
)
|
|
.await
|
|
}
|
|
|
|
/// [`advance_applied_to_rev`] with the forge fetch passed in, so it is
|
|
/// testable against a local repo.
|
|
///
|
|
/// `applied/main` is compared to `rev` twice: before the fetch, so an applied
|
|
/// rev costs no forge round-trip, and after it, because a meta-update cascade
|
|
/// fast-forwards `main` to the forge's and may do so meanwhile.
|
|
async fn advance_applied_main<T>(
|
|
applied_dir: &std::path::Path,
|
|
rev: &str,
|
|
fetch: impl std::future::Future<Output = Result<T>>,
|
|
) -> Result<RevAdvance> {
|
|
let main = lifecycle::git_rev_parse(applied_dir, "refs/heads/main")
|
|
.await
|
|
.map_err(|e| anyhow::anyhow!("read applied/main: {e:#}"))?;
|
|
if main == rev {
|
|
return Ok(RevAdvance::AlreadyApplied);
|
|
}
|
|
fetch.await.context("fetch the forge config main")?;
|
|
let (main, descends) = applied_main_under(applied_dir, rev).await?;
|
|
if main == rev {
|
|
return Ok(RevAdvance::AlreadyApplied);
|
|
}
|
|
if !descends {
|
|
bail!(
|
|
"{rev} does not descend from applied/main ({main}); fast-forwarding to it would \
|
|
discard commits"
|
|
);
|
|
}
|
|
ff_applied_main(applied_dir, rev, &main).await?;
|
|
Ok(RevAdvance::Advanced)
|
|
}
|
|
|
|
/// Fast-forward `applied/main` to `target` and sync the working tree.
|
|
///
|
|
/// Compare-and-swap, not a bare set: `main` must still be `expected_main`, the
|
|
/// sha the caller's ancestry gate ([`applied_main_under`]) ran against. A plain
|
|
/// `update-ref` moves the branch to `target` whatever it currently points at,
|
|
/// which turns "fast-forward" into "discard anything that landed in the
|
|
/// meantime" — the gate only proves `target` safe against the `main` observed
|
|
/// *then*, so this is what makes that proof still true *now*.
|
|
async fn ff_applied_main(
|
|
applied_dir: &std::path::Path,
|
|
target: &str,
|
|
expected_main: &str,
|
|
) -> Result<()> {
|
|
lifecycle::git_update_ref_cas(applied_dir, "refs/heads/main", target, expected_main)
|
|
.await
|
|
.map_err(|e| {
|
|
anyhow::anyhow!("ff main {expected_main} -> {target} (concurrent move?): {e:#}")
|
|
})?;
|
|
lifecycle::git_read_tree_reset(applied_dir, "refs/heads/main")
|
|
.await
|
|
.map_err(|e| anyhow::anyhow!("read-tree to main: {e:#}"))
|
|
}
|
|
|
|
/// Tear down a sub-agent container. By default this is non-destructive to
|
|
/// persistent state: the proposed/applied config repos and the Claude
|
|
/// credentials dir under `/var/lib/hyperhive/{agents,applied}/<name>/` are
|
|
/// kept, so recreating an agent of the same name reuses prior config + creds
|
|
/// (no re-login). The ephemeral runtime dir under `/run/hyperhive/agents/`
|
|
/// is cleared because its contents (the mcp socket) don't survive restarts
|
|
/// anyway. With `purge=true` the persistent trees are also wiped — config
|
|
/// history, claude creds, notes — there is no undo.
|
|
/// The bootstrap/root container is destroyable like any other: it's
|
|
/// imperative infra that `auto_update::ensure_root_agent` recreates on the
|
|
/// next hive-c0re startup if absent, so destroying it is transient rather
|
|
/// than something to refuse at the API.
|
|
///
|
|
/// Submits the teardown DAG and returns — it does not wait for the container to
|
|
/// go away. Same contract as every other lifecycle op (`rebuild`, `kill`,
|
|
/// `restart`, `start`): the queue owns the work, the caller gets an
|
|
/// acknowledgement. Progress is visible as real nodes on the dashboard.
|
|
pub fn destroy(coord: &Arc<Coordinator>, name: &str, purge: bool) {
|
|
tracing::info!(%name, purge, "destroy");
|
|
if let Err(e) = coord.job_queue.insert_job(|b| {
|
|
crate::job_queue::templates::destroy(b, name, purge);
|
|
Vec::new()
|
|
}) {
|
|
tracing::error!(agent = %name, error = ?e, "destroy: insert failed");
|
|
}
|
|
coord.emit_rebuild_queue_snapshot();
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::{RevAdvance, advance_applied_main};
|
|
use crate::lifecycle;
|
|
|
|
/// A repo with `main` at `b`, child of `a`, and `c`, a sibling of `b` off
|
|
/// `a`. Returns the dir and the shas `[a, b, c]`.
|
|
async fn repo() -> (tempfile::TempDir, [String; 3]) {
|
|
let dir = tempfile::tempdir().expect("tempdir");
|
|
let path = dir.path();
|
|
let commit = |msg: &'static str| async move {
|
|
lifecycle::git(
|
|
path,
|
|
&[
|
|
"-c",
|
|
"user.name=t",
|
|
"-c",
|
|
"user.email=t@t",
|
|
"commit",
|
|
"-q",
|
|
"--allow-empty",
|
|
"-m",
|
|
msg,
|
|
],
|
|
)
|
|
.await
|
|
.expect("commit");
|
|
lifecycle::git_rev_parse(path, "HEAD").await.expect("HEAD")
|
|
};
|
|
lifecycle::git(path, &["init", "-q", "--initial-branch=main"])
|
|
.await
|
|
.expect("init");
|
|
let a = commit("a").await;
|
|
let b = commit("b").await;
|
|
lifecycle::git(path, &["checkout", "-q", "-b", "side", &a])
|
|
.await
|
|
.expect("branch");
|
|
let c = commit("c").await;
|
|
lifecycle::git(path, &["checkout", "-q", "main"])
|
|
.await
|
|
.expect("checkout main");
|
|
(dir, [a, b, c])
|
|
}
|
|
|
|
async fn main_of(dir: &tempfile::TempDir) -> String {
|
|
lifecycle::git_rev_parse(dir.path(), "refs/heads/main")
|
|
.await
|
|
.expect("main")
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn the_applied_rev_is_a_no_op_without_a_fetch() {
|
|
let (dir, [_, b, _]) = repo().await;
|
|
let fetched = std::cell::Cell::new(false);
|
|
let fetch = async {
|
|
fetched.set(true);
|
|
anyhow::Ok(())
|
|
};
|
|
let outcome = advance_applied_main(dir.path(), &b, fetch).await;
|
|
assert_eq!(outcome.expect("no-op"), RevAdvance::AlreadyApplied);
|
|
assert!(!fetched.get(), "an applied rev must not reach the forge");
|
|
assert_eq!(main_of(&dir).await, b);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn a_rev_not_descending_from_main_is_refused_and_main_stays() {
|
|
let (dir, [_, b, c]) = repo().await;
|
|
let outcome = advance_applied_main(dir.path(), &c, async { anyhow::Ok(()) }).await;
|
|
let err = outcome.expect_err("a sibling of main is not a fast-forward");
|
|
assert!(
|
|
format!("{err:#}").contains("does not descend from applied/main"),
|
|
"{err:#}"
|
|
);
|
|
assert_eq!(main_of(&dir).await, b);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn a_descendant_rev_fast_forwards_main() {
|
|
let (dir, [a, b, _]) = repo().await;
|
|
lifecycle::git_update_ref(dir.path(), "refs/heads/main", &a)
|
|
.await
|
|
.expect("rewind main");
|
|
let outcome = advance_applied_main(dir.path(), &b, async { anyhow::Ok(()) }).await;
|
|
assert_eq!(outcome.expect("fast-forward"), RevAdvance::Advanced);
|
|
assert_eq!(main_of(&dir).await, b);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn a_failed_fetch_leaves_main_alone() {
|
|
let (dir, [a, b, _]) = repo().await;
|
|
lifecycle::git_update_ref(dir.path(), "refs/heads/main", &a)
|
|
.await
|
|
.expect("rewind main");
|
|
let outcome = advance_applied_main(dir.path(), &b, async {
|
|
Err::<(), _>(anyhow::anyhow!("forge down"))
|
|
})
|
|
.await;
|
|
assert!(outcome.is_err());
|
|
assert_eq!(main_of(&dir).await, a);
|
|
}
|
|
}
|