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:
parent
a5ea015bc6
commit
efbfec6d01
39 changed files with 286 additions and 3199 deletions
|
|
@ -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]
|
||||
|
|
|
|||
Loading…
Reference in a new issue