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

@ -14,18 +14,14 @@
//!
//! **The HMAC code is not shared with `hive-c0re` because c0re's copy is
//! leaving, not staying.** Once registration moves here, hives stop
//! registering *and* receiving; c0re's routes, its secret and the gateway's
//! `/webhook/` route all go with it, and what survives there is driven by a
//! queue event, authenticated by the queue. A shared crate is right when a
//! second consumer *arrives* — here it is departing, so extracting would
//! build an abstraction in order to unwind it. The two verifiers meanwhile
//! check different hooks with different secrets and never need to agree.
//! registering *and* receiving, and c0re's routes and secret go with it. A
//! shared crate is right when a second consumer *arrives*; here it is
//! departing. The two verifiers meanwhile check different hooks with
//! different secrets and never need to agree.
//!
//! **Nothing is registered against these routes yet, on purpose.** The
//! replacement path is built and observable in full before anything takes
//! the old one away: delete a hive's registration first and the swarm's one
//! `target_url` points at a receiver that forwards nowhere — indistinguishable
//! from no activity, on both sides.
//! **These hooks are registered ALONGSIDE the per-hive ones** — see
//! [`crate::forge::Client::ensure_swarm_webhooks`], which carries why, and
//! why moving them cannot come before fan-out.
use anyhow::{Context as _, Result};
use axum::{
@ -173,7 +169,39 @@ pub(super) enum DeliveryKind {
ConfigPr,
}
/// The route prefix a registered `target_url` must point at.
///
/// ⚠️ Deliberately **not** `/webhook/knowledge` or `/webhook/config-pr`, the
/// paths the per-hive receivers use. Both hive-side registrars delete any
/// hook whose URL ends with *their* path but has a different base — see
/// `hive-c0re`'s `forge::ensure_config_pr_webhook` and
/// `workers::knowledge::ensure_webhook`. A swarm-level hook under those
/// paths would therefore be deleted by every hive on every boot, and the
/// symptom is a hook that silently stops existing. `webhook_urls_survive_the_hive_side_reapers`
/// pins that.
const ROUTE_PREFIX: &str = "/webhook/forge/";
impl DeliveryKind {
/// Every kind, for the registration sweep. An array rather than a
/// hand-written list at the call site, so adding a kind cannot leave one
/// hook unregistered.
pub(super) const ALL: [Self; 2] = [Self::Knowledge, Self::ConfigPr];
/// The `target_url` to register with Forgejo for this kind, given the
/// swarm's public base URL.
///
/// Built here rather than at the registration call site so the URL that
/// is *registered* and the route that *serves* it are the same fact in
/// one place. A trailing slash on the base is tolerated — it arrives from
/// config, and `https://swarm//webhook/...` would be a hook that 404s.
pub(super) fn target_url(self, public_base: &str) -> String {
format!(
"{}{ROUTE_PREFIX}{}",
public_base.trim_end_matches('/'),
self.as_str()
)
}
/// Parse the `{kind}` path segment. Unknown values are rejected rather
/// than accepted-and-ignored: a typo in a registered `target_url` must
/// be *observable*, and a 200 for an unrecognised path is exactly the
@ -509,4 +537,54 @@ mod tests {
"an empty value must not resolve to a relative path"
);
}
/// The URL that gets registered must match the route that serves it, and
/// a trailing slash on the configured base must not produce `//webhook`.
#[test]
fn a_target_url_is_the_route_this_module_serves() {
assert_eq!(
DeliveryKind::Knowledge.target_url("https://swarm.example"),
"https://swarm.example/webhook/forge/knowledge"
);
assert_eq!(
DeliveryKind::ConfigPr.target_url("https://swarm.example/"),
"https://swarm.example/webhook/forge/config-pr",
"a trailing slash on the base must not double up"
);
for kind in DeliveryKind::ALL {
let url = kind.target_url("https://swarm.example");
let segment = url.rsplit('/').next().expect("a last segment");
assert_eq!(
DeliveryKind::parse(segment),
Some(kind),
"the last path segment of a registered URL must parse back \
to the kind that built it"
);
}
}
/// A cross-daemon invariant with nothing else to enforce it: both
/// per-hive registrars in `hive-c0re` **delete** hooks whose URL ends
/// with their own path but carries a different base — see
/// `forge::ensure_config_pr_webhook` and `knowledge::ensure_webhook`.
///
/// While the swarm-level hooks live alongside the per-hive ones, a
/// controller URL matching either suffix would be deleted by every hive
/// on every boot: the swarm hook would simply cease to exist, with the
/// cause in a different daemon's startup sweep. Serving these under
/// `/webhook/forge/` is what avoids it, and this is the only place that
/// says so in a form that fails.
#[test]
fn webhook_urls_survive_the_hive_side_reapers() {
for suffix in ["/webhook/knowledge", "/webhook/config-pr"] {
for kind in DeliveryKind::ALL {
let url = kind.target_url("https://swarm.example");
assert!(
!url.ends_with(suffix),
"{url} ends with {suffix}, which every hive's startup \
sweep deletes as a stale hook"
);
}
}
}
}