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

@ -661,6 +661,47 @@ async fn get_jobq_rollup(State(state): State<AppState>) -> Json<Vec<hive_jobq_wi
Json(hive_jobq_wire::state_rollup(graph, roots))
}
/// The swarm's own public base URL, as the forge must address it.
///
/// Set by `swarm-controller.nix` **only when this host actually serves the
/// swarm vhost** that carries the `/webhook/forge/` location. Absent means
/// "the endpoint is not reachable from outside", and the right response to
/// that is to register nothing: a hook pointing at a URL nothing answers is
/// worse than no hook, because forgejo records failed deliveries against a
/// registration that looks configured.
const PUBLIC_URL_ENV: &str = "SWARM_CONTROLLER_PUBLIC_URL";
/// Register the swarm-wide forge hooks against this controller, in the
/// background.
///
/// Detached rather than awaited, and never fatal: the forge may be slow or
/// briefly down at boot, and none of the daemon's other routes depend on a
/// hook existing. Registration is idempotent, so the next restart retries.
///
/// Silently does nothing when any of the three preconditions is missing —
/// each is a legitimate deployment shape (no forge here, no state directory
/// to hold a secret, no public vhost), and each is already logged where it
/// is discovered.
fn register_swarm_webhooks(forge: Option<Arc<forge::Client>>, secret: Option<Arc<str>>) {
let Some(forge) = forge else { return };
let Some(secret) = secret else { return };
let Ok(public_url) = std::env::var(PUBLIC_URL_ENV) else {
tracing::info!(
"{PUBLIC_URL_ENV} unset; not registering swarm-wide forge webhooks (the \
endpoint is not published on this host)"
);
return;
};
tokio::spawn(async move {
if let Err(e) = forge.ensure_swarm_webhooks(&public_url, &secret).await {
tracing::warn!(
error = %format!("{e:#}"),
"registering swarm-wide forge webhooks failed; retrying on next start"
);
}
});
}
#[tokio::main]
async fn main() -> Result<()> {
tracing_subscriber::fmt()
@ -763,7 +804,7 @@ async fn main() -> Result<()> {
};
let deps = WorkerDeps {
auth,
forge: forge_client,
forge: forge_client.clone(),
};
let jobq = Arc::new(Mutex::new(hive_jobq::scheduler::Scheduler::new(
@ -787,6 +828,8 @@ async fn main() -> Result<()> {
}
};
register_swarm_webhooks(forge_client, webhook_secret.clone());
let state = AppState {
hives: Arc::new(load_hives()),
links: Arc::new(load_links()),