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:
parent
a8bc084262
commit
4573865745
6 changed files with 325 additions and 13 deletions
|
|
@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue