feat(#3255): register the swarm-wide forge hooks against the controller

The endpoint landed inert: nothing pointed at it, so the only way to see
it work was to mint an HMAC by hand. Register the two swarm-wide hooks
at startup so a real forge event produces a journal line.

Registered ALONGSIDE the per-hive hooks, not instead of them. Every hive
keeps receiving and acting on its own deliveries; the controller gets a
copy and logs it. Moving the registration is a later step and has to be:
fan-out swarm->hive does not exist yet, so a hook moved now would point
at a receiver that forwards nowhere, silently on both sides.

Deliberately no stale-hook deletion arm, unlike the two per-hive
registrars this otherwise mirrors: theirs delete hooks matching their own
path with a foreign base, and the hives' hooks are not stale.

The route prefix is what keeps this safe. Both hive-side registrars
delete any hook ending in /webhook/knowledge or /webhook/config-pr with a
different base, so a swarm hook under those paths would be deleted by
every hive on every boot. Serving them under /webhook/forge/ avoids it,
and a test pins it -- there is nothing else that can.

SWARM_CONTROLLER_PUBLIC_URL is set only where the swarm vhost is served,
because a hook whose target_url nothing answers is worse than no hook.
This commit is contained in:
atlas 2026-08-18 10:07:49 +02:00 committed by mara
commit 4573865745
6 changed files with 325 additions and 13 deletions

View file

@ -19,10 +19,14 @@ use anyhow::{Context, Result};
use forgejo_api::structs::{
AddCollaboratorOption, AddCollaboratorOptionPermission, ChangeFileOperation,
ChangeFileOperationOperation, ChangeFilesOptions, CreateBranchProtectionOption,
CreateRepoOption, RepoGetContentsQuery,
CreateHookOption, CreateHookOptionConfig, CreateHookOptionType, CreateRepoOption,
RepoGetContentsQuery,
};
use forgejo_api::{ApiErrorKind, Auth, Forgejo, ForgejoError};
use reqwest::StatusCode;
use std::collections::BTreeMap;
use crate::webhook::DeliveryKind;
/// The forge org that owns agent repos. Same org `hive-c0re::forge`
/// already uses for its own single-hive `CreateRepo` path — this is
@ -38,6 +42,20 @@ pub const AGENTS_ORG: &str = "agents";
/// [`Client::create_repo`]'s doc comment.
const OPERATORS_TEAM: &str = "operators";
/// The org owning per-agent config repos, and where the `pull_request` hook
/// lives. Same value as `hive-c0re::forge::CONFIG_ORG` — one forge, one org.
///
/// ⚠️ Duplicated across the crate boundary (this crate deliberately does not
/// depend on `hive-c0re`), so nothing makes the two fail together. The
/// failure mode if they drift is quiet: the hook is created on an org that
/// exists, forgejo reports it healthy, and it simply never fires.
const CONFIG_ORG: &str = "agent-configs";
/// The hive-wide knowledge repo, where the `push` hook lives. Same values as
/// `hive-c0re::workers::knowledge::{ORG, REPO}`.
const KNOWLEDGE_ORG: &str = "internal";
const KNOWLEDGE_REPO: &str = "knowledge";
/// The env vars `swarm-controller.nix`'s `forgeEnv` sets. Named here
/// once so [`Client::from_env`] and the nix module can't drift silently
/// — a rename on one side without the other fails loudly (env var
@ -325,6 +343,136 @@ impl Client {
Err(e) => Err(e).with_context(|| format!("check for {path} in {AGENTS_ORG}/{repo}")),
}
}
/// Register the swarm-wide hooks against this controller, so a real
/// forge event reaches [`crate::webhook`] instead of the endpoint only
/// being reachable by hand.
///
/// **These are registered ALONGSIDE the per-hive hooks, not instead of
/// them.** Every hive keeps receiving and acting on its own deliveries
/// exactly as today; the controller receives a copy and (for now) logs
/// it. Taking the hive-side registration away is a later step, and it
/// has to be later: fan-out swarm→hive does not exist yet, so a hook
/// moved now would point at a receiver that forwards nowhere — silent on
/// both sides, indistinguishable from no activity.
///
/// ⛔ **No stale-hook deletion arm, unlike the two per-hive registrars
/// this otherwise mirrors.** Their arm deletes hooks matching their own
/// path with a foreign base; copying it here would delete the hives'
/// live hooks, which are not stale — they are the path still in
/// production. The controller only ever adds its own.
///
/// Idempotent: an existing hook with the same `target_url` is left
/// alone, so this is safe on every boot.
///
/// # Errors
///
/// Returns the first failure. A listing failure is not fatal — it falls
/// through to the create attempt, which is idempotent server-side by way
/// of the already-exists fold, the same best-effort shape `hive-c0re`
/// uses.
pub async fn ensure_swarm_webhooks(&self, public_base: &str, secret: &str) -> Result<()> {
for kind in DeliveryKind::ALL {
let target_url = kind.target_url(public_base);
let (event, scope) = match kind {
DeliveryKind::Knowledge => (
"push",
HookScope::Repo {
org: KNOWLEDGE_ORG,
repo: KNOWLEDGE_REPO,
},
),
DeliveryKind::ConfigPr => ("pull_request", HookScope::Org { org: CONFIG_ORG }),
};
self.ensure_hook(&scope, &target_url, event, secret)
.await
.with_context(|| format!("register swarm webhook {target_url}"))?;
}
Ok(())
}
/// One hook, idempotently. Split out of [`Self::ensure_swarm_webhooks`]
/// so the org-scoped and repo-scoped calls do not each grow their own
/// copy of the list-then-create logic.
async fn ensure_hook(
&self,
scope: &HookScope<'_>,
target_url: &str,
event: &str,
secret: &str,
) -> Result<()> {
match scope.list_hook_urls(&self.api).await {
Ok(urls) => {
if urls.iter().any(|u| u == target_url) {
tracing::debug!(%target_url, "swarm forge: webhook already registered");
return Ok(());
}
}
Err(e) => {
// Best-effort, same as the per-hive registrars: a forge that
// cannot be listed may still accept a create, and a
// duplicate create is folded into success below.
tracing::debug!(error = %e, %target_url, "swarm forge: listing hooks failed; attempting create");
}
}
let mut additional = BTreeMap::new();
additional.insert("secret".to_owned(), secret.to_owned());
let hook = CreateHookOption {
active: Some(true),
authorization_header: None,
branch_filter: None,
config: CreateHookOptionConfig {
content_type: "json".to_owned(),
url: url::Url::parse(target_url)
.with_context(|| format!("parse webhook target url {target_url}"))?,
additional,
},
events: Some(vec![event.to_owned()]),
r#type: CreateHookOptionType::Forgejo,
};
match scope.create_hook(&self.api, hook).await {
Ok(()) => {
tracing::info!(%target_url, %event, "swarm forge: webhook registered");
Ok(())
}
Err(e) if is_already_exists(&e) => {
tracing::debug!(%target_url, "swarm forge: webhook already present");
Ok(())
}
Err(e) => Err(e.into()),
}
}
}
/// Where a hook lives. The knowledge hook is repo-scoped and the config-PR
/// hook is org-scoped, mirroring exactly where the per-hive registrars put
/// theirs — a hook on the wrong scope would never fire, and forgejo would
/// report that as a perfectly healthy hook with no deliveries.
enum HookScope<'a> {
Repo { org: &'a str, repo: &'a str },
Org { org: &'a str },
}
impl HookScope<'_> {
/// The `url` config value of every hook currently on this scope.
async fn list_hook_urls(&self, api: &Forgejo) -> Result<Vec<String>, ForgejoError> {
let hooks = match self {
Self::Repo { org, repo } => api.repo_list_hooks(org, repo).all().await?,
Self::Org { org } => api.org_list_hooks(org).send().await?,
};
Ok(hooks
.iter()
.filter_map(|h| h.config.as_ref()?.get("url").cloned())
.collect())
}
async fn create_hook(&self, api: &Forgejo, hook: CreateHookOption) -> Result<(), ForgejoError> {
match self {
Self::Repo { org, repo } => api.repo_create_hook(org, repo, hook).await.map(drop),
Self::Org { org } => api.org_create_hook(org, hook).await.map(drop),
}
}
}
/// Base64-encode `content` for a forgejo content-API call — every file