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:
parent
a796c24037
commit
b2596097d8
4 changed files with 550 additions and 0 deletions
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue