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
This commit is contained in:
parent
a88ed9f24e
commit
56f592d525
2 changed files with 82 additions and 4 deletions
|
|
@ -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
|
**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
|
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
|
on the org by an older release delivers to a route no hive serves; each
|
||||||
it in the org's webhook settings. Removing the controller's hook stops
|
hive deletes its own on startup. Removing the controller's hook stops
|
||||||
forge-UI merges from deploying until its next start recreates it.
|
forge-UI merges from deploying until its next start recreates it.
|
||||||
|
|
||||||
<!-- vale write-good.Passive = YES -->
|
<!-- vale write-good.Passive = YES -->
|
||||||
|
|
|
||||||
|
|
@ -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
|
/// 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
|
/// every step that needs an authenticated forge client. The swarm-wide
|
||||||
/// objects (orgs, the `operators` team, mirrors, `internal/docs`,
|
/// objects (orgs, the `operators` team, mirrors, `internal/docs`,
|
||||||
/// `internal/knowledge`, the `agent-configs` avatar) are the
|
/// `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;
|
// 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).
|
// no-op when CI is disabled or the runner already holds valid creds).
|
||||||
ci_runner::ensure_ci_runner_registered(token).await;
|
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<i64> {
|
||||||
|
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.
|
/// 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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
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
|
/// The remote carries **no credential**. `argv` is world-readable through
|
||||||
/// `/proc/<pid>/cmdline`, so a token spliced in here would be published to
|
/// `/proc/<pid>/cmdline`, so a token spliced in here would be published to
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue