feat(#3255): receive swarm-wide forge webhooks in the controller

A Forgejo webhook has one target_url, so every hive registering the same
swarm-wide hooks is last-writer-wins rather than idempotent. The controller
is the only swarm-wide thing in the deployment, so it becomes the receiver.

It verifies the HMAC and treats the payload as opaque bytes keyed by the hook
kind in the URL path; it deliberately does not parse the payload, because the
hives' existing handlers already decide what a delivery means.

Nothing is registered against the endpoint yet. The replacement path is built
and observable before anything takes the old one away, so the swarm's single
target_url never points at a receiver that forwards nowhere.
This commit is contained in:
atlas 2026-08-17 23:10:43 +02:00 committed by mara
commit b2596097d8
4 changed files with 550 additions and 0 deletions

View file

@ -35,6 +35,7 @@ use utoipa_axum::{router::OpenApiRouter, routes};
mod auth;
mod forge;
mod status;
mod webhook;
/// Node payload for the swarm-level job graph. Named `Swarm*` rather than
/// the bare `NodeKind`/`Resource` `hive-c0re::job_queue::model` already
@ -273,6 +274,7 @@ fn socket_path() -> PathBuf {
(name = "links", description = "swarm service quick links"),
(name = "jobq", description = "the swarm-level job graph"),
(name = "agents", description = "creating agent identities at swarm level"),
(name = "webhook", description = "swarm-wide forge webhook receipt"),
)
)]
struct ApiDoc;
@ -329,6 +331,17 @@ struct AppState {
/// claimed — same "queue now, fail per-job" shape as an unreachable
/// swarm queue.
jobq: Arc<Mutex<hive_jobq::scheduler::Scheduler<SwarmNodeKind, SwarmResourceKind>>>,
/// HMAC secret for swarm-wide forge webhooks, loaded once at startup.
/// `None` when it could not be read or created — the webhook endpoint
/// then refuses every delivery with 503 rather than admitting one it
/// cannot verify. Deliberately not fatal to startup: nothing is
/// registered against that endpoint yet, and the rest of this daemon's
/// surface is unaffected. See [`webhook::load_or_generate_secret`].
///
/// `Arc<str>`, not `Arc<String>`: the value is never mutated after
/// startup, and this way `as_deref()` yields the `&str` the verifier
/// takes without a second hop through `String`.
webhook_secret: Option<Arc<str>>,
}
/// Env var the controller's NixOS module sets from
@ -756,11 +769,27 @@ async fn main() -> Result<()> {
)));
spawn_jobq_worker(Arc::clone(&jobq), deps);
// Same "log and carry on" shape as the queue/bridge/forge wiring above.
// A controller that cannot hold a webhook secret still serves every
// other route; the webhook endpoint answers 503, which is the honest
// answer rather than a silent accept.
let webhook_secret = match webhook::load_or_generate_secret() {
Ok(secret) => Some(Arc::from(secret)),
Err(e) => {
tracing::warn!(
error = %format!("{e:#}"),
"webhook secret unavailable; swarm-wide forge webhooks are off"
);
None
}
};
let state = AppState {
hives: Arc::new(load_hives()),
links: Arc::new(load_links()),
status,
jobq,
webhook_secret,
};
let (router, api) = OpenApiRouter::<AppState>::with_openapi(ApiDoc::openapi())
@ -771,6 +800,7 @@ async fn main() -> Result<()> {
.routes(routes!(get_jobq_graph))
.routes(routes!(get_jobq_rollup))
.routes(routes!(create_agent))
.routes(routes!(webhook::post_webhook_forge))
.split_for_parts();
// Just the JSON, not the UI — Swagger UI itself is nginx-hosted from
// the nix store (see the module doc comment above). `api` is