From 56f592d525140c4c3856cadf72b9b5f696d8b46c Mon Sep 17 00:00:00 2001 From: atlas Date: Fri, 2 Oct 2026 23:10:35 +0200 Subject: [PATCH] config PRs: each hive removes its own stale config-PR org hook on boot The hook a hive registered on the agent-configs org points at its /webhook/config-pr route, which no hive serves any more, so every config-repo event fails delivery to it. The forge sweep now deletes it. Only a hook whose URL equals this hive's own exactly is removed. The same path on another base belongs to another hive and is left alone. A forge error is logged and boot continues; once the hook is gone the step is a no-op. Closes #4850 --- docs/swarm/README.md | 4 +- hive-c0re/src/forge/mod.rs | 82 +++++++++++++++++++++++++++++++++++++- 2 files changed, 82 insertions(+), 4 deletions(-) diff --git a/docs/swarm/README.md b/docs/swarm/README.md index 34e7e3c1..81f5fc61 100644 --- a/docs/swarm/README.md +++ b/docs/swarm/README.md @@ -269,8 +269,8 @@ would take delivery away from the first rather than add a recipient. **The `agent-configs` org is on it too.** The controller's hook is the only one that acts on config PRs. A hive's `/webhook/config-pr` hook left -on the org by an older release delivers to a route no hive serves; delete -it in the org's webhook settings. Removing the controller's hook stops +on the org by an older release delivers to a route no hive serves; each +hive deletes its own on startup. Removing the controller's hook stops forge-UI merges from deploying until its next start recreates it. diff --git a/hive-c0re/src/forge/mod.rs b/hive-c0re/src/forge/mod.rs index 99dd19eb..f017b758 100644 --- a/hive-c0re/src/forge/mod.rs +++ b/hive-c0re/src/forge/mod.rs @@ -251,7 +251,8 @@ pub async fn sync_agent(name: &str, core_token: Option<&str>) -> bool { } /// The `core_token.is_some()` half of [`ensure_all`]: the meta repo, the -/// local knowledge clone, the core avatar, and CI runner registration — +/// local knowledge clone, the core avatar, CI runner registration, and +/// removing this hive's own config-PR hook — /// every step that needs an authenticated forge client. The swarm-wide /// objects (orgs, the `operators` team, mirrors, `internal/docs`, /// `internal/knowledge`, the `agent-configs` avatar) are the @@ -291,6 +292,63 @@ async fn ensure_all_orgs_and_repos(token: &str) { // Register the hive-ci Actions runner (off the container's boot path; // no-op when CI is disabled or the runner already holds valid creds). ci_runner::ensure_ci_runner_registered(token).await; + if let Err(e) = remove_config_pr_webhook(token).await { + tracing::warn!(error = ?e, "forge: remove_config_pr_webhook failed"); + } +} + +/// Delete this hive's own `/webhook/config-pr` hook from the +/// `agent-configs` org. No hive serves that route, so such a hook fails on +/// every config-repo event; swarm-controller's hook is the one that acts on +/// config PRs. +/// +/// Only a hook whose URL equals this hive's exactly is removed, never one +/// matching by path: the same path on another base is another hive's hook. +/// Idempotent, and a no-op when `HYPERHIVE_HIVE_DOMAIN` is unset. +async fn remove_config_pr_webhook(core_token: &str) -> Result<()> { + const HTTP_TIMEOUT: Duration = Duration::from_secs(10); + + let Some(domain) = std::env::var("HYPERHIVE_HIVE_DOMAIN") + .ok() + .filter(|v| !v.is_empty()) + else { + return Ok(()); + }; + let own_url = config_pr_hook_url(&domain); + let client = api(core_token)?; + let hooks = tokio::time::timeout(HTTP_TIMEOUT, client.org_list_hooks(CONFIG_ORG).send()) + .await + .map_err(anyhow::Error::from) + .and_then(|r| r.map_err(anyhow::Error::from)) + .with_context(|| format!("list webhooks for org {CONFIG_ORG}"))?; + for id in hook_ids_at(&hooks, &own_url) { + tokio::time::timeout(HTTP_TIMEOUT, client.org_delete_hook(CONFIG_ORG, id).send()) + .await + .map_err(anyhow::Error::from) + .and_then(|r| r.map_err(anyhow::Error::from)) + .with_context(|| format!("delete webhook {id} on org {CONFIG_ORG}"))?; + tracing::info!(%own_url, id, "forge: removed this hive's config-pr webhook"); + } + Ok(()) +} + +/// The URL this hive's config-PR hook was registered under. +fn config_pr_hook_url(hive_domain: &str) -> String { + format!("https://{hive_domain}/webhook/config-pr") +} + +/// Ids of the hooks whose URL is exactly `url`. +fn hook_ids_at(hooks: &[forgejo_api::structs::Hook], url: &str) -> Vec { + hooks + .iter() + .filter(|h| { + h.config + .as_ref() + .and_then(|c| c.get("url")) + .is_some_and(|u| u == url) + }) + .filter_map(|h| h.id) + .collect() } /// Poll the local Forgejo API until it answers, with a timeout of 1 minute. @@ -400,7 +458,27 @@ pub async fn ensure_all() { #[cfg(test)] mod tests { - use super::{describe_forge_admin, git_url_with_base}; + use super::{config_pr_hook_url, describe_forge_admin, git_url_with_base, hook_ids_at}; + + fn hook(id: i64, url: &str) -> forgejo_api::structs::Hook { + serde_json::from_value(serde_json::json!({ "id": id, "config": { "url": url } })) + .expect("hook json") + } + + /// The same path on another base is another hive's hook, and removing it + /// would cut that hive off. Only an exact URL match is this hive's. + #[test] + fn only_this_hives_own_config_pr_hook_is_removed() { + let own = config_pr_hook_url("hive.example"); + let hooks = [ + hook(1, &own), + hook(2, "https://other-hive.example/webhook/config-pr"), + hook(3, "http://127.0.0.1:7000/webhook/config-pr"), + hook(4, "https://swarm.example/webhook/forge/config-pr"), + ]; + assert_eq!(hook_ids_at(&hooks, &own), vec![1]); + assert!(hook_ids_at(&hooks[1..], &own).is_empty()); + } /// The remote carries **no credential**. `argv` is world-readable through /// `/proc//cmdline`, so a token spliced in here would be published to