Watch
0
0
Fork
You've already forked hyperhive
0

config PRs: remove the hive's config-PR webhook, poll and core merge

An operator's merge on the forge deploys a config PR through
swarm-controller's DeployRequest{rev}. The hive-side path that queued a
MergeConfigPr approval and merged the PR as `core` goes:

- the `/webhook/config-pr` receiver, its HMAC secret, the WebhookRegister
  boot node and the org-hook registration; the hive vhost's `/webhook/`
  location
- the 5-minute config-PR poll
- ApprovalKind::MergeConfigPr, its dashboard card, and the deploy DAG it
  drove (DeployWindow, MergeVerify, DeployApply, FinalizeDeploy,
  DeployTail), with verify_commit, the two-phase meta deploy, rollback
  refs, the PR-failure comment and forge/pr_merge.rs
- `fetched_sha`, `sha_short`/`pr_number` on approval events, and
  `sha`/`tag` on HelperEvent::ApprovalResolved: only the merge path set
  them

`config_repo`, `merged_pr_for_commit` and `post_pr_comment` move to
forge/pr_comment.rs for the merged-rev deploy's refusal comment.
Approvals v5 drops stored `merge_config_pr` rows; a test reopens a v4
database holding them.

Closes #4850
This commit is contained in:
atlas 2026-10-02 22:32:17 +02:00
commit efbfec6d01
39 changed files with 286 additions and 3199 deletions

View file

@ -5,16 +5,12 @@
//! No-op when `hive-forge` isn't running. Full design: `docs/integrations/forge.md`.
mod ci_runner;
pub mod config_pr_poll;
mod pr_merge;
mod pr_comment;
mod reconcile;
mod repos;
mod users;
pub use pr_merge::{
ForgeMergeError, config_repo, fetch_pr_head_into_applied, merge_config_pr_ff,
merged_pr_for_commit, post_pr_comment, pr_head_sha, pr_is_open,
};
pub use pr_comment::{config_repo, merged_pr_for_commit, post_pr_comment};
pub use reconcile::{fetch_forge_main, reconcile_config_apply, reconcile_config_status};
pub use repos::{
clone_config_into_proposed, ensure_config_repo, ensure_meta_remote, ensure_repo,
@ -92,15 +88,14 @@ pub(crate) fn core_auth_header(token: &str) -> String {
}
/// Forgejo org grouping every agent's config repo. Core is a site admin
/// and reads + writes every repo here. As of the agent-config-PR flow each
/// agent is a **write collaborator on its own** `agent-configs/<name>` repo —
/// the editable PR surface it pushes config-change branches to — but `main` is
/// branch-protected core-only, so only hive-c0re's verify-and-ff-push merge
/// handler lands on it (operator approval required; the agent can't push
/// `main` or self-merge). The repos remain private, so an agent still can't
/// reach *another* agent's config. `main` is fast-forward-only — hive-c0re
/// never force-pushes; the `push_config` mirror runs best-effort until the
/// PR-merge flow retires it.
/// and reads + writes every repo here. Each agent is a **write collaborator
/// on its own** `agent-configs/<name>` repo — the editable PR surface it
/// pushes config-change branches to. `main`'s branch protection is
/// swarm-controller's: merge and approval allowlisted to the `operators` team,
/// so the agent can't push `main` or self-merge, and an operator's merge
/// deploys through swarm-controller. The repos remain private, so an agent
/// still can't reach *another* agent's config. hive-c0re never force-pushes;
/// the `push_config` mirror runs best-effort.
pub(crate) const CONFIG_ORG: &str = "agent-configs";
/// Forgejo org hosting the operator-curated shared docs/skills repo
/// that every agent gets read-only access to. Agents use it as a
@ -403,183 +398,49 @@ pub async fn ensure_all() {
}
}
/// Ensure a Forgejo `pull_request` org-webhook for `agent-configs` targets
/// hive-c0re's `/webhook/config-pr` and signs with `webhook_secret`. A hook
/// already at that URL is kept if [`crate::webhook_secret::is_registered`],
/// else deleted and recreated (Forgejo's edit-hook API ignores `secret`).
///
/// `hive_domain` is the public domain name of the hive; the webhook URL is
/// `https://<hive_domain>/webhook/config-pr` (routed through the gateway,
/// avoiding the Forgejo SSRF guard that blocks loopback delivery).
///
/// `webhook_secret` is the HMAC secret Forgejo will attach as
/// `X-Hub-Signature-256` on each delivery; hive-c0re verifies this header
/// in the dashboard webhook handler (`post_webhook_config_pr`).
///
/// An org-level hook covers every repo in `agent-configs` automatically,
/// so no per-repo setup is needed as new agents are provisioned.
///
/// Called at startup. The knowledge repo's one hook is the controller's
/// now; this one has not moved yet. No-op when the core token is absent
/// (forge not yet provisioned).
///
/// # Errors
///
/// Returns an error if:
/// - `hive_domain` produces a URL that `url::Url::parse` rejects.
/// - Deleting the hook being replaced, or `org_create_hook`, fails (transport
/// error, auth failure, or the `agent-configs` org does not exist).
/// - The HTTP call times out (10 s limit).
///
/// Listing failures are treated as best-effort: they fall through to the
/// create attempt rather than surfacing an error.
pub async fn ensure_config_pr_webhook(
core_token: &str,
hive_domain: &str,
webhook_secret: &str,
) -> Result<()> {
use forgejo_api::structs::{CreateHookOption, CreateHookOptionConfig, CreateHookOptionType};
use std::collections::BTreeMap;
const HTTP_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10);
let target_url = format!("https://{hive_domain}/webhook/config-pr");
let client = api(core_token)?;
// List existing org hooks — skip creation if ours is already there.
// Best-effort: a listing failure falls through to the create attempt.
let listed = tokio::time::timeout(HTTP_TIMEOUT, client.org_list_hooks(CONFIG_ORG).send())
.await
.map_err(anyhow::Error::from)
.and_then(|r| r.map_err(anyhow::Error::from));
let listed_ok = listed.is_ok();
match listed {
Ok(hooks) => {
let secret_registered = crate::webhook_secret::is_registered(webhook_secret);
if config_pr_hook_is_current(&hooks, &target_url, secret_registered) {
tracing::debug!(%target_url, "forge: config-pr webhook already configured");
return Ok(());
}
for h in &hooks {
let hook_url = hook_url(h);
let Some(id) = h.id else { continue };
if hook_url == target_url {
// A failed delete must not fall through to create: the
// old hook would stay beside the new one, and a later
// pass sees the URL present and never removes it.
tracing::info!(
hook_url,
org = CONFIG_ORG,
"forge: replacing config-pr webhook (secret not the registered one)"
);
tokio::time::timeout(
HTTP_TIMEOUT,
client.org_delete_hook(CONFIG_ORG, id).send(),
)
.await
.map_err(anyhow::Error::from)
.and_then(|r| r.map_err(anyhow::Error::from))
.with_context(|| {
format!("delete config-pr webhook {id} to replace its secret")
})?;
} else if hook_url.ends_with("/webhook/config-pr") {
// Our path on a different base (e.g. old loopback hooks
// from before the SSRF-bypass migration).
tracing::info!(
hook_url,
org = CONFIG_ORG,
"forge: deleting stale config-pr webhook (wrong base)"
);
let _ = tokio::time::timeout(
HTTP_TIMEOUT,
client.org_delete_hook(CONFIG_ORG, id).send(),
)
.await;
}
}
}
Err(e) => {
tracing::debug!(error = %e, "forge: listing config-pr hooks failed; attempting create");
}
}
let mut additional = BTreeMap::new();
additional.insert("secret".to_owned(), webhook_secret.to_owned());
let hook = CreateHookOption {
active: Some(true),
authorization_header: None,
branch_filter: None,
config: CreateHookOptionConfig {
content_type: "json".to_owned(),
url: Url::parse(&target_url).context("parse config-pr webhook target url")?,
additional,
},
events: Some(vec!["pull_request".to_owned()]),
r#type: CreateHookOptionType::Forgejo,
};
tokio::time::timeout(HTTP_TIMEOUT, client.org_create_hook(CONFIG_ORG, hook))
.await
.map_err(anyhow::Error::from)
.and_then(|r| r.map_err(anyhow::Error::from))
.with_context(|| format!("create config-pr webhook on org {CONFIG_ORG}"))?;
tracing::info!(%target_url, "forge: config-pr webhook created on org {CONFIG_ORG}");
// Only a listed pass knows no hook with an older secret survived beside
// this one; an unlisted pass leaves the record stale so the next
// registration lists and replaces again.
if listed_ok && let Err(e) = crate::webhook_secret::record_registered(webhook_secret) {
tracing::warn!(error = ?e, "forge: recording the config-pr webhook secret failed");
}
Ok(())
}
/// The `url` a Forgejo hook delivers to, or `""` when it carries none.
fn hook_url(hook: &forgejo_api::structs::Hook) -> &str {
hook.config
.as_ref()
.and_then(|c| c.get("url"))
.map_or("", String::as_str)
}
/// Whether the config-PR hook needs no work: one targets `target_url`, and
/// the current secret is the one it was registered with.
fn config_pr_hook_is_current(
hooks: &[forgejo_api::structs::Hook],
target_url: &str,
secret_registered: bool,
) -> bool {
secret_registered && hooks.iter().any(|h| hook_url(h) == target_url)
}
#[cfg(test)]
mod tests {
use super::{config_pr_hook_is_current, describe_forge_admin};
use super::{describe_forge_admin, git_url_with_base};
const TARGET: &str = "https://hive.example/webhook/config-pr";
fn hook(url: &str) -> forgejo_api::structs::Hook {
serde_json::from_value(serde_json::json!({ "id": 1, "config": { "url": url } }))
.expect("hook json")
/// The remote carries **no credential**. `argv` is world-readable through
/// `/proc/<pid>/cmdline`, so a token spliced in here would be published to
/// every local user for the life of the git child.
///
/// Deliberately not via `forge_git_url`, which reads `HIVE_FORGE_URL` —
/// setting that here would race every other test in this binary.
#[test]
fn forge_git_url_carries_no_credential() {
let url = git_url_with_base("http://forge.example.test", "a/iris");
assert_eq!(url, "http://forge.example.test/a/iris.git");
assert!(!url.contains('@'), "no userinfo: {url}");
}
#[test]
fn a_hook_at_the_target_with_the_registered_secret_is_kept() {
assert!(config_pr_hook_is_current(&[hook(TARGET)], TARGET, true));
fn forge_git_url_preserves_https() {
// The scheme is carried through rather than assumed: a swarm
// whose forge is behind TLS must not be downgraded to http.
let url = git_url_with_base("https://forge.example.test", "a/iris");
assert!(url.starts_with("https://"), "https must survive: {url}");
}
/// Forgejo can't be asked which secret a hook signs with, so a hook at
/// the right URL is not enough: without the matching record it is
/// replaced, or every delivery fails HMAC.
/// The credential goes in an `Authorization` header instead — decodable
/// back to `core:<token>`, so the swap is genuinely equivalent auth and
/// not a silent downgrade to anonymous.
#[test]
fn a_hook_at_the_target_with_a_changed_secret_is_replaced() {
assert!(!config_pr_hook_is_current(&[hook(TARGET)], TARGET, false));
}
#[test]
fn a_hook_on_another_base_is_not_ours() {
let stale = hook("http://127.0.0.1:7000/webhook/config-pr");
assert!(!config_pr_hook_is_current(&[stale], TARGET, true));
assert!(!config_pr_hook_is_current(&[], TARGET, true));
fn core_auth_header_is_basic_core_token() {
use base64::Engine as _;
let header = super::core_auth_header("s3cret");
let b64 = header
.strip_prefix("Authorization: Basic ")
.expect("basic auth header");
let decoded = base64::engine::general_purpose::STANDARD
.decode(b64)
.expect("valid base64");
assert_eq!(String::from_utf8_lossy(&decoded), "core:s3cret");
assert!(
!header.contains("s3cret"),
"token not in the clear: {header}"
);
}
#[test]