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
|
|
@ -19,10 +19,14 @@ use anyhow::{Context, Result};
|
|||
use forgejo_api::structs::{
|
||||
AddCollaboratorOption, AddCollaboratorOptionPermission, ChangeFileOperation,
|
||||
ChangeFileOperationOperation, ChangeFilesOptions, CreateBranchProtectionOption,
|
||||
CreateRepoOption, RepoGetContentsQuery,
|
||||
CreateHookOption, CreateHookOptionConfig, CreateHookOptionType, CreateRepoOption,
|
||||
RepoGetContentsQuery,
|
||||
};
|
||||
use forgejo_api::{ApiErrorKind, Auth, Forgejo, ForgejoError};
|
||||
use reqwest::StatusCode;
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use crate::webhook::DeliveryKind;
|
||||
|
||||
/// The forge org that owns agent repos. Same org `hive-c0re::forge`
|
||||
/// already uses for its own single-hive `CreateRepo` path — this is
|
||||
|
|
@ -38,6 +42,20 @@ pub const AGENTS_ORG: &str = "agents";
|
|||
/// [`Client::create_repo`]'s doc comment.
|
||||
const OPERATORS_TEAM: &str = "operators";
|
||||
|
||||
/// The org owning per-agent config repos, and where the `pull_request` hook
|
||||
/// lives. Same value as `hive-c0re::forge::CONFIG_ORG` — one forge, one org.
|
||||
///
|
||||
/// ⚠️ Duplicated across the crate boundary (this crate deliberately does not
|
||||
/// depend on `hive-c0re`), so nothing makes the two fail together. The
|
||||
/// failure mode if they drift is quiet: the hook is created on an org that
|
||||
/// exists, forgejo reports it healthy, and it simply never fires.
|
||||
const CONFIG_ORG: &str = "agent-configs";
|
||||
|
||||
/// The hive-wide knowledge repo, where the `push` hook lives. Same values as
|
||||
/// `hive-c0re::workers::knowledge::{ORG, REPO}`.
|
||||
const KNOWLEDGE_ORG: &str = "internal";
|
||||
const KNOWLEDGE_REPO: &str = "knowledge";
|
||||
|
||||
/// The env vars `swarm-controller.nix`'s `forgeEnv` sets. Named here
|
||||
/// once so [`Client::from_env`] and the nix module can't drift silently
|
||||
/// — a rename on one side without the other fails loudly (env var
|
||||
|
|
@ -325,6 +343,136 @@ impl Client {
|
|||
Err(e) => Err(e).with_context(|| format!("check for {path} in {AGENTS_ORG}/{repo}")),
|
||||
}
|
||||
}
|
||||
|
||||
/// Register the swarm-wide hooks against this controller, so a real
|
||||
/// forge event reaches [`crate::webhook`] instead of the endpoint only
|
||||
/// being reachable by hand.
|
||||
///
|
||||
/// **These are registered ALONGSIDE the per-hive hooks, not instead of
|
||||
/// them.** Every hive keeps receiving and acting on its own deliveries
|
||||
/// exactly as today; the controller receives a copy and (for now) logs
|
||||
/// it. Taking the hive-side registration away is a later step, and it
|
||||
/// has to be later: fan-out swarm→hive does not exist yet, so a hook
|
||||
/// moved now would point at a receiver that forwards nowhere — silent on
|
||||
/// both sides, indistinguishable from no activity.
|
||||
///
|
||||
/// ⛔ **No stale-hook deletion arm, unlike the two per-hive registrars
|
||||
/// this otherwise mirrors.** Their arm deletes hooks matching their own
|
||||
/// path with a foreign base; copying it here would delete the hives'
|
||||
/// live hooks, which are not stale — they are the path still in
|
||||
/// production. The controller only ever adds its own.
|
||||
///
|
||||
/// Idempotent: an existing hook with the same `target_url` is left
|
||||
/// alone, so this is safe on every boot.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns the first failure. A listing failure is not fatal — it falls
|
||||
/// through to the create attempt, which is idempotent server-side by way
|
||||
/// of the already-exists fold, the same best-effort shape `hive-c0re`
|
||||
/// uses.
|
||||
pub async fn ensure_swarm_webhooks(&self, public_base: &str, secret: &str) -> Result<()> {
|
||||
for kind in DeliveryKind::ALL {
|
||||
let target_url = kind.target_url(public_base);
|
||||
let (event, scope) = match kind {
|
||||
DeliveryKind::Knowledge => (
|
||||
"push",
|
||||
HookScope::Repo {
|
||||
org: KNOWLEDGE_ORG,
|
||||
repo: KNOWLEDGE_REPO,
|
||||
},
|
||||
),
|
||||
DeliveryKind::ConfigPr => ("pull_request", HookScope::Org { org: CONFIG_ORG }),
|
||||
};
|
||||
self.ensure_hook(&scope, &target_url, event, secret)
|
||||
.await
|
||||
.with_context(|| format!("register swarm webhook {target_url}"))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// One hook, idempotently. Split out of [`Self::ensure_swarm_webhooks`]
|
||||
/// so the org-scoped and repo-scoped calls do not each grow their own
|
||||
/// copy of the list-then-create logic.
|
||||
async fn ensure_hook(
|
||||
&self,
|
||||
scope: &HookScope<'_>,
|
||||
target_url: &str,
|
||||
event: &str,
|
||||
secret: &str,
|
||||
) -> Result<()> {
|
||||
match scope.list_hook_urls(&self.api).await {
|
||||
Ok(urls) => {
|
||||
if urls.iter().any(|u| u == target_url) {
|
||||
tracing::debug!(%target_url, "swarm forge: webhook already registered");
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
// Best-effort, same as the per-hive registrars: a forge that
|
||||
// cannot be listed may still accept a create, and a
|
||||
// duplicate create is folded into success below.
|
||||
tracing::debug!(error = %e, %target_url, "swarm forge: listing hooks failed; attempting create");
|
||||
}
|
||||
}
|
||||
|
||||
let mut additional = BTreeMap::new();
|
||||
additional.insert("secret".to_owned(), secret.to_owned());
|
||||
let hook = CreateHookOption {
|
||||
active: Some(true),
|
||||
authorization_header: None,
|
||||
branch_filter: None,
|
||||
config: CreateHookOptionConfig {
|
||||
content_type: "json".to_owned(),
|
||||
url: url::Url::parse(target_url)
|
||||
.with_context(|| format!("parse webhook target url {target_url}"))?,
|
||||
additional,
|
||||
},
|
||||
events: Some(vec![event.to_owned()]),
|
||||
r#type: CreateHookOptionType::Forgejo,
|
||||
};
|
||||
match scope.create_hook(&self.api, hook).await {
|
||||
Ok(()) => {
|
||||
tracing::info!(%target_url, %event, "swarm forge: webhook registered");
|
||||
Ok(())
|
||||
}
|
||||
Err(e) if is_already_exists(&e) => {
|
||||
tracing::debug!(%target_url, "swarm forge: webhook already present");
|
||||
Ok(())
|
||||
}
|
||||
Err(e) => Err(e.into()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Where a hook lives. The knowledge hook is repo-scoped and the config-PR
|
||||
/// hook is org-scoped, mirroring exactly where the per-hive registrars put
|
||||
/// theirs — a hook on the wrong scope would never fire, and forgejo would
|
||||
/// report that as a perfectly healthy hook with no deliveries.
|
||||
enum HookScope<'a> {
|
||||
Repo { org: &'a str, repo: &'a str },
|
||||
Org { org: &'a str },
|
||||
}
|
||||
|
||||
impl HookScope<'_> {
|
||||
/// The `url` config value of every hook currently on this scope.
|
||||
async fn list_hook_urls(&self, api: &Forgejo) -> Result<Vec<String>, ForgejoError> {
|
||||
let hooks = match self {
|
||||
Self::Repo { org, repo } => api.repo_list_hooks(org, repo).all().await?,
|
||||
Self::Org { org } => api.org_list_hooks(org).send().await?,
|
||||
};
|
||||
Ok(hooks
|
||||
.iter()
|
||||
.filter_map(|h| h.config.as_ref()?.get("url").cloned())
|
||||
.collect())
|
||||
}
|
||||
|
||||
async fn create_hook(&self, api: &Forgejo, hook: CreateHookOption) -> Result<(), ForgejoError> {
|
||||
match self {
|
||||
Self::Repo { org, repo } => api.repo_create_hook(org, repo, hook).await.map(drop),
|
||||
Self::Org { org } => api.org_create_hook(org, hook).await.map(drop),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Base64-encode `content` for a forgejo content-API call — every file
|
||||
|
|
|
|||
Loading…
Reference in a new issue