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

@ -89,15 +89,10 @@ pub(super) fn gc_orphans(coord: &Coordinator, approvals: Vec<Approval>) -> Vec<A
let note = "agent state dir missing";
let _ = coord.approvals.mark_failed(a.id, note);
tracing::info!(id = a.id, agent = %a.agent, "auto-failed orphan approval");
let sha_short = a
.fetched_sha
.as_deref()
.map(|s| s[..s.len().min(12)].to_owned());
coord.emit_approval_resolved(crate::coordinator::ApprovalResolved {
id: a.id,
agent: a.agent.as_str(),
approval_kind: <&str>::from(a.kind),
sha_short,
status: "failed",
note: Some(note.to_owned()),
description: a.description.clone(),

View file

@ -45,7 +45,6 @@ use crate::lifecycle;
(name = "state_files", description = "proxied reads of allow-listed per-agent state files"),
(name = "state_snapshot", description = "cold-load dashboard snapshot"),
(name = "tombstones", description = "purge of retained state for destroyed agents"),
(name = "webhook", description = "forgejo webhook receivers"),
)
)]
struct ApiDoc;
@ -67,7 +66,6 @@ mod schedules;
mod state_files;
mod state_snapshot;
mod tombstones;
mod webhook;
// Run after lock bumps by the job queue (`job_queue/exec.rs`); the view
// type feeds `DashboardEvent::MetaInputsChanged` (`dashboard_events.rs`).
@ -87,11 +85,6 @@ pub(crate) use tombstones::emit_tombstones_snapshot;
#[derive(Clone)]
struct AppState {
coord: Arc<Coordinator>,
/// HMAC-SHA256 secret shared with Forgejo webhook registrations.
/// Verified on every incoming `/webhook/*` POST.
/// `None` when the secret could not be loaded at startup — all
/// `/webhook/*` requests are rejected with 503 in that case.
webhook_secret: Option<String>,
}
#[allow(
@ -101,11 +94,7 @@ struct AppState {
handler; splitting that exhaustive list across helpers would \
obscure the route map for no readability gain"
)]
pub async fn serve(
port: u16,
coord: Arc<Coordinator>,
webhook_secret: Option<String>,
) -> Result<()> {
pub async fn serve(port: u16, coord: Arc<Coordinator>) -> Result<()> {
// API-only: the gateway static-serves the dashboard dist and proxies
// non-static requests here (see `nix/host-modules/hive-gateway/vhosts.nix`).
// Unmatched paths 404.
@ -162,7 +151,6 @@ pub async fn serve(
.routes(routes!(schedules::post_schedule_resume))
.routes(routes!(schedules::post_schedule_fire_now))
.routes(routes!(schedules::post_rebuild_queue_cancel))
.routes(routes!(webhook::post_webhook_config_pr))
.routes(routes!(approvals::post_approve))
.routes(routes!(approvals::post_deny))
.routes(routes!(lifecycle_ops::post_destroy))
@ -191,10 +179,7 @@ pub async fn serve(
"/api/openapi.json",
get(move || async move { Json(api.clone()) }),
)
.with_state(AppState {
coord,
webhook_secret,
});
.with_state(AppState { coord });
// Binds loopback-only; external access via gateway.
// Rationale: docs/networking/gateway.md::Firewall posture.
let addr = SocketAddr::from(([127, 0, 0, 1], port));
@ -392,7 +377,6 @@ mod router_build_probe {
.routes(routes!(schedules::post_schedule_resume))
.routes(routes!(schedules::post_schedule_fire_now))
.routes(routes!(schedules::post_rebuild_queue_cancel))
.routes(routes!(webhook::post_webhook_config_pr))
.routes(routes!(approvals::post_approve))
.routes(routes!(approvals::post_deny))
.routes(routes!(lifecycle_ops::post_destroy))

View file

@ -131,8 +131,7 @@ struct ApprovalHistoryView {
id: i64,
agent: String,
kind: &'static str,
/// First 12 chars of the canonical sha (preferred) or
/// manager-supplied ref. None when the approval carries neither.
/// First 12 chars of the manager-supplied ref. None when it is empty.
sha_short: Option<String>,
/// `approved` / `denied` / `failed`.
status: &'static str,
@ -149,25 +148,14 @@ struct ApprovalView {
id: i64,
agent: String,
kind: &'static str,
/// First 12 chars of the reviewed PR head sha, for `MergeConfigPr`
/// only. Display-only (the short chip on the card).
sha_short: Option<String>,
/// Manager-supplied description shown on the approval card.
#[serde(skip_serializing_if = "Option::is_none")]
description: Option<String>,
/// Forge PR number, for `MergeConfigPr` only. Lets the frontend
/// build a "review PR on forge" link
/// (`{forgeBase}/agent-configs/{agent}/pulls/{pr_number}`). `None`
/// for every other kind.
#[serde(skip_serializing_if = "Option::is_none")]
pr_number: Option<u64>,
/// Raw `commit_ref` payload for `UpdateMetaInputs` (JSON-encoded
/// `Vec<String>` of input names; `"[]"` = all inputs) and
/// `SchedulePrompt` (JSON-encoded `SchedulePromptPayload`). The
/// frontend parses this to render a human-readable card body.
/// `None` for every other kind.
#[serde(skip_serializing_if = "Option::is_none")]
commit_ref: Option<String>,
commit_ref: String,
/// RFC 3339 UTC time the approval was queued. Rendered as a
/// relative time on the card so the operator can spot a stale
/// request.
@ -388,13 +376,11 @@ fn build_transient_views(
out
}
/// Render each pending approval into its dashboard view (short sha for
/// `MergeConfigPr`).
/// Project a resolved sqlite row into the lean shape the dashboard
/// history tab consumes — no `diff_html` (rendering 30 of them
/// per /api/state poll would mean 30 git diffs per refresh).
fn history_view(a: Approval) -> ApprovalHistoryView {
let displayed = a.fetched_sha.as_deref().unwrap_or(&a.commit_ref);
let displayed = a.commit_ref.as_str();
let sha_short = if displayed.is_empty() {
None
} else {
@ -421,54 +407,17 @@ fn history_view(a: Approval) -> ApprovalHistoryView {
}
fn build_approval_views(approvals: Vec<Approval>) -> Vec<ApprovalView> {
let mut out = Vec::with_capacity(approvals.len());
for a in approvals {
out.push(match a.kind {
hive_sh4re::approvals::ApprovalKind::UpdateMetaInputs => ApprovalView {
id: a.id,
agent: a.agent.to_string(),
kind: "update_meta_inputs",
sha_short: None,
description: a.description,
pr_number: None,
commit_ref: Some(a.commit_ref),
requested_at: a.requested_at,
},
hive_sh4re::approvals::ApprovalKind::SchedulePrompt => ApprovalView {
id: a.id,
agent: a.agent.to_string(),
kind: "schedule_prompt",
sha_short: None,
description: a.description,
pr_number: None,
commit_ref: Some(a.commit_ref),
requested_at: a.requested_at,
},
hive_sh4re::approvals::ApprovalKind::MergeConfigPr => {
// commit_ref = PR number; fetched_sha = the reviewed PR
// head. Show the head sha; the config diff surface lives
// on the forge PR itself.
let sha = a
.fetched_sha
.as_deref()
.map(|s| s[..s.len().min(12)].to_owned());
// Surface the PR number so the frontend can link to the
// PR on the forge. commit_ref holds the number as text.
let pr_number = a.commit_ref.parse::<u64>().ok();
ApprovalView {
id: a.id,
agent: a.agent.to_string(),
kind: "merge_config_pr",
sha_short: sha,
description: a.description,
pr_number,
commit_ref: None,
requested_at: a.requested_at,
}
}
});
}
out
approvals
.into_iter()
.map(|a| ApprovalView {
id: a.id,
agent: a.agent.to_string(),
kind: <&str>::from(a.kind),
description: a.description,
commit_ref: a.commit_ref,
requested_at: a.requested_at,
})
.collect()
}
/// `/api/jobq/graph` query string — the generic jobq/wire query shape (any

View file

@ -1,362 +0,0 @@
//! Forgejo webhook endpoints.
//!
//! - **`/webhook/config-pr`** — `pull_request` events on any `agent-configs/*`
//! repo queue a [`hive_sh4re::approvals::ApprovalKind::MergeConfigPr`] approval row
//! so the operator can review + approve the merge from the dashboard.
//!
//! There was a second endpoint here, `/webhook/knowledge`, which pulled the
//! local `internal/knowledge` clone on push. It is gone along with the
//! per-hive registration that fed it: a webhook has exactly one target URL,
//! so every hive registering one against the shared repository was
//! last-writer-wins. The swarm controller now holds the single registration
//! and addresses an event to each hive over the queue, which
//! [`crate::workers::knowledge`] documents.
//!
//! The endpoint is reached via the gateway (HTTPS, public domain URL) so
//! Forgejo's SSRF guard does not block delivery. Each delivery is verified
//! against the `X-Hub-Signature-256` HMAC header Forgejo attaches; the
//! shared secret is auto-generated at startup and persisted to
//! [`crate::paths::webhook_secret_file()`].
use axum::{
body::Bytes,
extract::State,
http::{HeaderMap, StatusCode},
response::{IntoResponse, Response},
};
use serde::Deserialize;
use super::AppState;
// ── HMAC helper ───────────────────────────────────────────────────────────────
/// Verify the `X-Hub-Signature-256` header on an incoming Forgejo webhook.
/// Returns `Err` (with a safe-to-log message) on mismatch, missing header,
/// or when the HMAC secret is unavailable (load failure at startup).
fn verify_hmac(state: &AppState, headers: &HeaderMap, body: &Bytes) -> Result<(), String> {
let secret = state
.webhook_secret
.as_deref()
.ok_or_else(|| "webhook HMAC secret unavailable; endpoint disabled".to_owned())?;
let sig = headers
.get("x-hub-signature-256")
.and_then(|v| v.to_str().ok())
.unwrap_or("");
if sig.is_empty() {
return Err("missing X-Hub-Signature-256 header".to_owned());
}
crate::webhook_secret::verify_signature(secret, body, sig).map_err(|e| e.to_string())
}
// ── config-PR webhook ──────────────────────────────────────────────────────────
/// Minimal Forgejo `pull_request`-webhook payload.
///
/// Forgejo fires this for actions: `opened`, `closed`, `reopened`,
/// `synchronized`, `assigned`, `unassigned`, `label_updated`,
/// `label_cleared`, `milestoned`, `demilestoned`, `review_requested`,
/// `review_request_removed`, `auto_merge_enabled`, `auto_merge_disabled`.
/// We only act on `opened` and `synchronized` (Forgejo spells the
/// push-update action past-tense, unlike GitHub's `synchronize`).
#[derive(Deserialize)]
pub(super) struct PrWebhookPayload {
/// What triggered this event (`opened`, `closed`, `synchronized`, …).
action: Option<String>,
/// PR index on the repo.
number: Option<u64>,
pull_request: Option<PrWebhookPr>,
repository: Option<PrWebhookRepo>,
}
#[derive(Deserialize)]
struct PrWebhookPr {
head: Option<PrWebhookHead>,
}
#[derive(Deserialize)]
struct PrWebhookHead {
sha: Option<String>,
}
#[derive(Deserialize)]
struct PrWebhookRepo {
full_name: Option<String>,
}
/// POST `/webhook/config-pr` — Forgejo `pull_request` webhook for
/// `agent-configs/*` repos.
///
/// On `opened` or `synchronized` for an `agent-configs/<agent>` PR:
/// fetches the current PR head sha, queues a `MergeConfigPr` approval row,
/// and emits the `ApprovalAdded` event so the dashboard card appears
/// immediately.
///
/// All other actions (closed, label changes, etc.) are silently ignored —
/// the operator can deny a pending approval if the PR is later closed.
///
/// Always returns HTTP 200 (even on queue failure) so Forgejo does not
/// retry the delivery. Failures are logged at `warn` level.
///
/// Expected Forgejo webhook configuration:
/// - URL: `https://<HYPERHIVE_HIVE_DOMAIN>/webhook/config-pr`
/// - Content type: `application/json`
/// - Events: "Pull Request" only
/// - Secret: auto-generated HMAC key (see [`crate::webhook_secret`])
/// - Organisation: `agent-configs` (org-level hook covers all config repos)
///
/// hive-c0re registers this hook automatically at startup via
/// [`crate::forge::ensure_config_pr_webhook`].
#[utoipa::path(
post,
path = "/webhook/config-pr",
request_body(
content = String,
content_type = "application/json",
description = "Forgejo pull_request-webhook payload, taken as raw \
bytes (not a typed extractor) so HMAC verification \
runs over the exact wire bytes before any JSON \
parsing"
),
responses(
(status = 200, description = "processed (approval queued or ignored)", body = String),
(status = 400, description = "invalid JSON payload"),
(status = 401, description = "bad or missing HMAC signature"),
(status = 503, description = "HMAC secret unavailable at startup"),
),
tag = "webhook"
)]
pub(super) async fn post_webhook_config_pr(
State(state): State<AppState>,
headers: HeaderMap,
body: Bytes,
) -> Response {
if let Err(e) = verify_hmac(&state, &headers, &body) {
tracing::warn!("webhook/config-pr: HMAC verification failed: {e}");
let status = if e.contains("unavailable") {
StatusCode::SERVICE_UNAVAILABLE
} else {
StatusCode::UNAUTHORIZED
};
return (status, e).into_response();
}
let payload = match serde_json::from_slice::<PrWebhookPayload>(&body) {
Ok(p) => p,
Err(e) => {
tracing::warn!("webhook/config-pr: JSON parse error: {e}");
return (StatusCode::BAD_REQUEST, "invalid JSON").into_response();
}
};
let action = payload.action.as_deref().unwrap_or("");
// Only act on a newly-opened PR or a new push to its branch. Forgejo's
// webhook payload spells the push-update action `synchronized` (past
// tense), NOT GitHub's `synchronize` — matching only the GitHub spelling
// silently dropped every PR-update delivery (the whole reason updates were
// "only found by poll"). Accept both so the handler is correct against
// Forgejo and stays GitHub-compatible.
if action != "opened" && action != "synchronize" && action != "synchronized" {
tracing::debug!(action, "webhook/config-pr: ignoring action");
return (StatusCode::OK, "ignored").into_response();
}
let full_name = payload
.repository
.as_ref()
.and_then(|r| r.full_name.as_deref())
.unwrap_or("");
// Expect `agent-configs/<agent>`.
let agent = match full_name.strip_prefix(&format!("{}/", crate::forge::CONFIG_ORG)) {
Some(name) if !name.is_empty() && !name.contains('/') => name,
_ => {
tracing::debug!(
full_name,
"webhook/config-pr: ignoring non-config-repo event"
);
return (StatusCode::OK, "ignored").into_response();
}
};
let pr_number = match payload.number {
Some(n) if n > 0 => n,
_ => {
tracing::warn!(full_name, "webhook/config-pr: missing or zero PR number");
return (StatusCode::OK, "ignored").into_response();
}
};
// The payload already carries the head sha — use it as an early hint for
// logging, but the canonical sha comes from `submit_merge_config_pr`'s
// fresh forge API call so we don't trust a potentially-stale payload sha.
let payload_sha = payload
.pull_request
.as_ref()
.and_then(|pr| pr.head.as_ref())
.and_then(|h| h.sha.as_deref())
.unwrap_or("<unknown>");
tracing::info!(
%full_name, %agent, %pr_number, %payload_sha, %action,
"webhook/config-pr: queuing MergeConfigPr approval"
);
// Queue the approval. The description surfaces the action and PR number
// on the dashboard card so the operator has context without opening the
// forge PR.
let description = format!("PR #{pr_number} on {full_name} ({action})");
if let Err(e) = crate::socket_server::submit_merge_config_pr(
&state.coord,
agent,
pr_number,
Some(&description),
"forge", // submitter — identifies the webhook path in the audit trail
)
.await
{
tracing::warn!(
%agent, %pr_number, error = ?e,
"webhook/config-pr: failed to queue MergeConfigPr approval"
);
}
(StatusCode::OK, "ok").into_response()
}
#[cfg(test)]
mod tests {
use axum::{
body::Bytes,
extract::State,
http::{HeaderMap, HeaderValue, StatusCode},
};
use super::{AppState, post_webhook_config_pr, verify_hmac};
const SECRET: &str = "s3cr3t";
/// The `TempDir` holds the coordinator's sqlite files; keep it alive as
/// long as the state.
fn state(webhook_secret: Option<&str>) -> (tempfile::TempDir, AppState) {
let (dir, coord) = crate::socket_server::coordinator();
let state = AppState {
coord,
webhook_secret: webhook_secret.map(str::to_owned),
};
(dir, state)
}
/// The `X-Hub-Signature-256` header Forgejo would send for `secret` + `body`.
fn signed(secret: &str, body: &[u8]) -> HeaderMap {
use std::fmt::Write as _;
use hmac::{Hmac, KeyInit, Mac};
use sha2::Sha256;
let mut mac = Hmac::<Sha256>::new_from_slice(secret.as_bytes()).unwrap();
mac.update(body);
let mut hex = String::new();
for b in mac.finalize().into_bytes() {
write!(hex, "{b:02x}").unwrap();
}
let mut headers = HeaderMap::new();
headers.insert(
"x-hub-signature-256",
HeaderValue::from_str(&format!("sha256={hex}")).unwrap(),
);
headers
}
/// With no secret loaded, nothing verifies — including a delivery signed
/// with the empty key. The message must say "unavailable": the handler
/// maps on that word to 503.
#[test]
fn verify_hmac_rejects_every_delivery_when_no_secret_is_loaded() {
let (_dir, state) = state(None);
let body = Bytes::from_static(b"{}");
for (label, headers) in [
("signed with the empty key", signed("", &body)),
("signed with some key", signed(SECRET, &body)),
("unsigned", HeaderMap::new()),
] {
let err = verify_hmac(&state, &headers, &body).expect_err(label);
assert!(err.contains("unavailable"), "{label}: {err}");
}
}
/// An absent header, an empty one and one that is not valid UTF-8 all
/// read as "no signature", and are refused before any HMAC is computed.
#[test]
fn verify_hmac_rejects_a_delivery_with_no_usable_signature_header() {
let (_dir, state) = state(Some(SECRET));
let body = Bytes::from_static(b"{}");
let mut empty = HeaderMap::new();
empty.insert("x-hub-signature-256", HeaderValue::from_static(""));
let mut not_utf8 = HeaderMap::new();
not_utf8.insert(
"x-hub-signature-256",
HeaderValue::from_bytes(b"sha256=\xff").unwrap(),
);
for (label, headers) in [
("absent", HeaderMap::new()),
("empty", empty),
("not UTF-8", not_utf8),
] {
assert_eq!(
verify_hmac(&state, &headers, &body),
Err("missing X-Hub-Signature-256 header".to_owned()),
"{label}"
);
}
}
/// The other side of both guards: a loaded secret and a matching
/// signature pass.
#[test]
fn verify_hmac_accepts_a_correctly_signed_delivery() {
let (_dir, state) = state(Some(SECRET));
let body = Bytes::from_static(b"{}");
assert_eq!(verify_hmac(&state, &signed(SECRET, &body), &body), Ok(()));
}
/// 503 means "this hive cannot verify any delivery", 401 means "this
/// delivery is not authentic".
#[tokio::test]
async fn config_pr_answers_503_when_no_secret_is_loaded() {
let (_dir, state) = state(None);
let body = Bytes::from_static(b"{}");
let headers = signed(SECRET, &body);
let resp = post_webhook_config_pr(State(state), headers, body).await;
assert_eq!(resp.status(), StatusCode::SERVICE_UNAVAILABLE);
}
#[tokio::test]
async fn config_pr_answers_401_for_a_missing_or_wrong_signature() {
let body = Bytes::from_static(b"{}");
for (label, headers) in [
("missing", HeaderMap::new()),
("wrong secret", signed("different-secret", &body)),
("other body", signed(SECRET, b"tampered")),
] {
let (_dir, state) = state(Some(SECRET));
let resp = post_webhook_config_pr(State(state), headers, body.clone()).await;
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED, "{label}");
}
}
/// A verified delivery reaches payload handling: an action the handler
/// ignores comes back 200, and an unparseable body 400, neither of which
/// is an auth status.
#[tokio::test]
async fn config_pr_lets_a_correctly_signed_delivery_through() {
for (body, expected) in [
(&br#"{"action":"closed"}"#[..], StatusCode::OK),
(&b"not json"[..], StatusCode::BAD_REQUEST),
] {
let (_dir, state) = state(Some(SECRET));
let body = Bytes::from_static(body);
let headers = signed(SECRET, &body);
let resp = post_webhook_config_pr(State(state), headers, body).await;
assert_eq!(resp.status(), expected);
}
}
}