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
|
|
@ -137,8 +137,6 @@ export function applyApprovalAdded(ev) {
|
||||||
id: ev.id,
|
id: ev.id,
|
||||||
agent: ev.agent,
|
agent: ev.agent,
|
||||||
kind: ev.approval_kind,
|
kind: ev.approval_kind,
|
||||||
sha_short: ev.sha_short || null,
|
|
||||||
pr_number: ev.pr_number ?? null,
|
|
||||||
description: ev.description || null,
|
description: ev.description || null,
|
||||||
// The ApprovalAdded event carries no requested_at; a live-added
|
// The ApprovalAdded event carries no requested_at; a live-added
|
||||||
// approval was queued just now, so client-now is accurate — and
|
// approval was queued just now, so client-now is accurate — and
|
||||||
|
|
@ -163,7 +161,6 @@ export function applyApprovalResolved(ev) {
|
||||||
id: ev.id,
|
id: ev.id,
|
||||||
agent: ev.agent,
|
agent: ev.agent,
|
||||||
kind: ev.approval_kind,
|
kind: ev.approval_kind,
|
||||||
sha_short: ev.sha_short || null,
|
|
||||||
status: ev.status,
|
status: ev.status,
|
||||||
resolved_at: ev.resolved_at,
|
resolved_at: ev.resolved_at,
|
||||||
note: ev.note || null,
|
note: ev.note || null,
|
||||||
|
|
@ -223,16 +220,8 @@ export function renderApprovals() {
|
||||||
root.append(el("p", { class: "empty" }, "queue empty"));
|
root.append(el("p", { class: "empty" }, "queue empty"));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// forge link base — only when the hive-forge container is up.
|
|
||||||
const fs = window.__hyperhive_state;
|
|
||||||
// state.forge_public_url (set from services.hyperhive.forge.publicUrl)
|
|
||||||
// or null — never guessed from "<hostname>:3000". The PR-link builder
|
|
||||||
// below already gates on forgeBase being truthy.
|
|
||||||
const forgeBase = (fs && fs.forge_present && fs.forge_public_url) || null;
|
|
||||||
|
|
||||||
const ul = el("ul", { class: "approvals" });
|
const ul = el("ul", { class: "approvals" });
|
||||||
for (const a of pending) {
|
for (const a of pending) {
|
||||||
const isMergePr = a.kind === "merge_config_pr";
|
|
||||||
const isUpdateMeta = a.kind === "update_meta_inputs";
|
const isUpdateMeta = a.kind === "update_meta_inputs";
|
||||||
const isSchedule = a.kind === "schedule_prompt";
|
const isSchedule = a.kind === "schedule_prompt";
|
||||||
const li = el("li", { class: "approval-card" });
|
const li = el("li", { class: "approval-card" });
|
||||||
|
|
@ -241,20 +230,11 @@ export function renderApprovals() {
|
||||||
const head = el(
|
const head = el(
|
||||||
"div",
|
"div",
|
||||||
{ class: "approval-head" },
|
{ class: "approval-head" },
|
||||||
el(
|
el("span", { class: "glyph" }, isUpdateMeta ? "↻" : "⏱"),
|
||||||
"span",
|
|
||||||
{ class: "glyph" },
|
|
||||||
isUpdateMeta ? "↻" : isSchedule ? "⏱" : "⇒",
|
|
||||||
),
|
|
||||||
el("span", { class: "id" }, "#" + a.id),
|
el("span", { class: "id" }, "#" + a.id),
|
||||||
el("span", { class: "agent" }, a.agent),
|
el("span", { class: "agent" }, a.agent),
|
||||||
el(
|
el("span", { class: "kind" }, isUpdateMeta ? "meta-update" : "schedule"),
|
||||||
"span",
|
|
||||||
{ class: "kind" },
|
|
||||||
isUpdateMeta ? "meta-update" : isSchedule ? "schedule" : "merge-pr",
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
if (isMergePr && a.sha_short) head.append(el("code", {}, a.sha_short));
|
|
||||||
// When the approval was requested — relative time, right-aligned.
|
// When the approval was requested — relative time, right-aligned.
|
||||||
// Goes amber once it's been pending an hour so a stale request is
|
// Goes amber once it's been pending an hour so a stale request is
|
||||||
// obvious at a glance (see docs/web-ui/dashboard.md::Approval card).
|
// obvious at a glance (see docs/web-ui/dashboard.md::Approval card).
|
||||||
|
|
@ -280,27 +260,7 @@ export function renderApprovals() {
|
||||||
if (a.description) {
|
if (a.description) {
|
||||||
body.append(el("div", { class: "approval-description" }, a.description));
|
body.append(el("div", { class: "approval-description" }, a.description));
|
||||||
}
|
}
|
||||||
if (isMergePr) {
|
if (isUpdateMeta) {
|
||||||
// PR-based config deploy: link to the reviewed PR on the forge.
|
|
||||||
// The config diff lives on the forge PR itself.
|
|
||||||
const drill = el("div", { class: "drill-ins" });
|
|
||||||
if (forgeBase && a.pr_number != null) {
|
|
||||||
drill.append(
|
|
||||||
el(
|
|
||||||
"a",
|
|
||||||
{
|
|
||||||
class: "panel-trigger",
|
|
||||||
target: "_blank",
|
|
||||||
rel: "noopener",
|
|
||||||
href: `${forgeBase}/agent-configs/${a.agent}/pulls/${a.pr_number}`,
|
|
||||||
title: "review this config PR on the hive forge",
|
|
||||||
},
|
|
||||||
"↳ review PR on forge ↗",
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
body.append(drill);
|
|
||||||
} else if (isUpdateMeta) {
|
|
||||||
let inputs;
|
let inputs;
|
||||||
try {
|
try {
|
||||||
inputs = JSON.parse(a.commit_ref || "[]");
|
inputs = JSON.parse(a.commit_ref || "[]");
|
||||||
|
|
@ -421,11 +381,7 @@ function renderApprovalHistory(root, history) {
|
||||||
el(
|
el(
|
||||||
"span",
|
"span",
|
||||||
{ class: "kind" },
|
{ class: "kind" },
|
||||||
a.kind === "update_meta_inputs"
|
a.kind === "update_meta_inputs" ? "meta-update" : "schedule",
|
||||||
? "meta-update"
|
|
||||||
: a.kind === "schedule_prompt"
|
|
||||||
? "schedule"
|
|
||||||
: "merge-pr",
|
|
||||||
),
|
),
|
||||||
" ",
|
" ",
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -10,13 +10,13 @@ Need new packages, env vars, or other NixOS config for yourself? You can't edit
|
||||||
|
|
||||||
Your config repo is mounted **read-only** at `/agents/{label}/config/` — `agent.nix` plus whatever extra files define you (declared packages, env vars, MCP servers). Read it to see exactly what defines you before asking for a change, so you can point at the precise file and line.
|
Your config repo is mounted **read-only** at `/agents/{label}/config/` — `agent.nix` plus whatever extra files define you (declared packages, env vars, MCP servers). Read it to see exactly what defines you before asking for a change, so you can point at the precise file and line.
|
||||||
|
|
||||||
Approval boundary: starting, stopping and rebuilding containers is the operator's — ask them when any agent needs one. _Creating_ a new agent is not something you can do from here — ask the operator, who scaffolds the new agent's config repo and spawns it from the dashboard. _Changing_ any agent's config is not a tool call at all — it's a forge PR on the agent's `agent-configs/<name>` repo, which queues a `MergeConfigPr` approval on open/update. The operator only signs off on changes; you run the day-to-day.
|
Approval boundary: starting, stopping and rebuilding containers is the operator's — ask them when any agent needs one. _Creating_ a new agent is not something you can do from here — ask the operator, who scaffolds the new agent's config repo and spawns it from the dashboard. _Changing_ any agent's config is not a tool call at all — it's a forge PR on the agent's `agent-configs/<name>` repo, which an operator reviews and merges on the forge; the merge deploys it. The operator only signs off on changes; you run the day-to-day.
|
||||||
|
|
||||||
Messages from sender `system` are hyperhive helper events (JSON body, `event` field discriminates): `approval_resolved`, `container_crash`, `needs_update`. Use these to react to lifecycle changes:
|
Messages from sender `system` are hyperhive helper events (JSON body, `event` field discriminates): `approval_resolved`, `container_crash`, `needs_update`. Use these to react to lifecycle changes:
|
||||||
|
|
||||||
- `needs_update` — agent's flake rev is stale. Ask the operator to rebuild it.
|
- `needs_update` — agent's flake rev is stale. Ask the operator to rebuild it.
|
||||||
- `container_crash` — ask the operator to start it again; if it keeps crashing, say so with what you saw.
|
- `container_crash` — ask the operator to start it again; if it keeps crashing, say so with what you saw.
|
||||||
- `approval_resolved` — one of your own submitted approvals (a scheduled prompt, a config PR, …) was approved, denied, or failed; the body carries the resolution.
|
- `approval_resolved` — one of your own submitted approvals (a scheduled prompt, …) was approved, denied, or failed; the body carries the resolution.
|
||||||
|
|
||||||
Lifecycle notices that don't need an immediate turn — a new agent spawned, a container rebuilt/killed/destroyed, or its login state changing — surface as todos instead of messages now. Call `get_loose_ends` to see them.
|
Lifecycle notices that don't need an immediate turn — a new agent spawned, a container rebuilt/killed/destroyed, or its login state changing — surface as todos instead of messages now. Call `get_loose_ends` to see them.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -14,16 +14,12 @@ use crate::lifecycle;
|
||||||
|
|
||||||
/// Approve a pending request. Marks the approval row durably, then submits
|
/// Approve a pending request. Marks the approval row durably, then submits
|
||||||
/// the work to the job queue so the dashboard POST returns immediately while
|
/// the work to the job queue so the dashboard POST returns immediately while
|
||||||
/// the long-running pipeline runs off-thread (operator no longer blocks on a
|
/// the long-running pipeline runs off-thread.
|
||||||
/// 30-90s spinner for `MergeConfigPr`).
|
|
||||||
///
|
///
|
||||||
/// Dispatch:
|
/// Dispatch:
|
||||||
/// - `MergeConfigPr` → a `DeployWindow` DAG (`MergeVerify → DeployApply →
|
|
||||||
/// FinalizeDeploy`, plus an `AfterAny` `DeployTail`, under a
|
|
||||||
/// resource-holding root; ~30-90s)
|
|
||||||
/// - `UpdateMetaInputs` → a `MetaUpdate` DAG (fan-out on completion)
|
/// - `UpdateMetaInputs` → a `MetaUpdate` DAG (fan-out on completion)
|
||||||
///
|
///
|
||||||
/// Every queued kind — deploys included — resolves its approval row via
|
/// Every queued kind resolves its approval row via
|
||||||
/// [`resolve_approval_dag`] when the DAG settles terminal.
|
/// [`resolve_approval_dag`] when the DAG settles terminal.
|
||||||
pub async fn approve(coord: Arc<Coordinator>, id: i64) -> Result<()> {
|
pub async fn approve(coord: Arc<Coordinator>, id: i64) -> Result<()> {
|
||||||
let approval = coord.approvals.mark_approved(id)?;
|
let approval = coord.approvals.mark_approved(id)?;
|
||||||
|
|
@ -67,154 +63,8 @@ pub async fn approve(coord: Arc<Coordinator>, id: i64) -> Result<()> {
|
||||||
}
|
}
|
||||||
result
|
result
|
||||||
}
|
}
|
||||||
ApprovalKind::MergeConfigPr => {
|
|
||||||
// The work ends in a container rebuild, so route it through the
|
|
||||||
// rebuild queue. The queue worker dispatches the deploy DAG's
|
|
||||||
// nodes to `run_deploy_merge_verify` (drift gate + eval),
|
|
||||||
// `run_deploy_apply` (ff-merge, then grows the rebuild subgraph),
|
|
||||||
// `run_finalize_deploy` (deploy tag + lock commit) and
|
|
||||||
// `run_deploy_tail` (compensation + forge mirror).
|
|
||||||
enqueue_approval_rebuild(&coord, approval.agent.as_str(), id);
|
|
||||||
Ok(())
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
/// Submit the deploy DAG tied to an approval id. Used by the `MergeConfigPr`
|
|
||||||
/// dispatch arm — the work ends in a container rebuild routed through the
|
|
||||||
/// queue. See [`crate::job_queue::templates::approval_deploy`] for the node
|
|
||||||
/// shape; the executor dispatches each node to the `run_deploy_*` bodies below.
|
|
||||||
fn enqueue_approval_rebuild(coord: &Arc<Coordinator>, agent: &str, approval_id: i64) {
|
|
||||||
if let Err(e) = coord.job_queue.insert_job(|b| {
|
|
||||||
crate::job_queue::templates::approval_deploy(b, agent, approval_id);
|
|
||||||
Vec::new()
|
|
||||||
}) {
|
|
||||||
tracing::error!(%agent, approval_id, error = ?e, "insert approval deploy dag failed");
|
|
||||||
}
|
|
||||||
coord.emit_rebuild_queue_snapshot();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Ref under which [`run_deploy_apply`] parks the pre-merge `applied/main`
|
|
||||||
/// sha, for [`run_deploy_tail`] to compensate with.
|
|
||||||
///
|
|
||||||
/// Deliberately a *git ref in the applied repo* rather than an in-memory value
|
|
||||||
/// handed between nodes: hive-c0re can restart between the apply and the tail,
|
|
||||||
/// and the whole point of splitting the deploy is that the tail still knows
|
|
||||||
/// what to undo when it does. The ref's existence IS the "a merge landed but
|
|
||||||
/// hasn't been confirmed good yet" flag — [`run_finalize_deploy`] drops it the
|
|
||||||
/// moment the rebuild has come up clean.
|
|
||||||
fn rollback_ref(approval_id: i64) -> String {
|
|
||||||
format!("refs/hyperhive/rollback/{approval_id}")
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Everything a deploy node needs, re-derived from sqlite on each node rather
|
|
||||||
/// than cached across the DAG. Nothing here is *computed* by an earlier node —
|
|
||||||
/// `pr` and `reviewed` are fields of the approval row the operator signed off
|
|
||||||
/// on — so re-reading is both cheap and the authoritative source of truth.
|
|
||||||
struct DeployCtx {
|
|
||||||
approval: hive_sh4re::approvals::Approval,
|
|
||||||
/// PR number, parsed from `approval.commit_ref`.
|
|
||||||
pr: u64,
|
|
||||||
/// The PR head sha the operator reviewed (`approval.fetched_sha`).
|
|
||||||
reviewed: String,
|
|
||||||
applied_dir: std::path::PathBuf,
|
|
||||||
/// The agent's forge config repo (`<owner>/<name>`).
|
|
||||||
repo: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
fn deploy_ctx(coord: &Coordinator, approval_id: i64) -> Result<DeployCtx> {
|
|
||||||
let approval = fetch_approval_for_worker(coord, approval_id, ApprovalKind::MergeConfigPr)?;
|
|
||||||
let pr: u64 = approval.commit_ref.parse().map_err(|e| {
|
|
||||||
anyhow::anyhow!(
|
|
||||||
"parse PR number from commit_ref {:?}: {e}",
|
|
||||||
approval.commit_ref
|
|
||||||
)
|
|
||||||
})?;
|
|
||||||
let reviewed = approval.fetched_sha.clone().ok_or_else(|| {
|
|
||||||
anyhow::anyhow!("merge config pr approval {approval_id} has no reviewed head sha")
|
|
||||||
})?;
|
|
||||||
Ok(DeployCtx {
|
|
||||||
pr,
|
|
||||||
reviewed,
|
|
||||||
applied_dir: crate::paths::applied_dir(approval.agent.as_str()),
|
|
||||||
repo: crate::forge::config_repo(approval.agent.as_str()),
|
|
||||||
approval,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/// `MergeVerify` node body — everything that can say "no" before anything is
|
|
||||||
/// mutated. `approval.commit_ref` is the PR number; `approval.fetched_sha` is
|
|
||||||
/// the PR head the operator reviewed. Steps:
|
|
||||||
/// 1. drift gate — re-read the live PR head; if it moved since review, abort
|
|
||||||
/// (the operator must re-review the new head);
|
|
||||||
/// 2. fetch the reviewed head into the applied repo so later git ops resolve
|
|
||||||
/// it locally;
|
|
||||||
/// 3. ancestry gate — the reviewed head must descend from `applied/main`;
|
|
||||||
/// 4. eval-verify the reviewed commit against the meta flake.
|
|
||||||
///
|
|
||||||
/// Steps 1 and 3 ask different questions and both are load-bearing. The drift
|
|
||||||
/// gate asks whether the *head* is still what was reviewed; the ancestry gate
|
|
||||||
/// asks whether the *base* is still underneath it. A PR opened from a stale base
|
|
||||||
/// passes the drift gate untouched and then rewinds `main` when it lands,
|
|
||||||
/// silently dropping every commit made in between.
|
|
||||||
///
|
|
||||||
/// Nothing here needs undoing on failure: the fetch only adds objects, and
|
|
||||||
/// `main` doesn't move. That's the whole reason this is its own node — a
|
|
||||||
/// failure at this stage leaves [`run_deploy_tail`] with no ref to compensate.
|
|
||||||
///
|
|
||||||
/// # Errors
|
|
||||||
///
|
|
||||||
/// Returns an error if the approval can't be loaded, if the live PR head has
|
|
||||||
/// drifted from the reviewed sha, if fetching that head into the applied repo
|
|
||||||
/// fails, if the reviewed head does not descend from `applied/main`, or if the
|
|
||||||
/// eval-verify of the reviewed commit fails. Every one of these leaves the forge
|
|
||||||
/// and `main` untouched, so the node is safely retryable.
|
|
||||||
pub async fn run_deploy_merge_verify(
|
|
||||||
coord: &Arc<Coordinator>,
|
|
||||||
approval_id: i64,
|
|
||||||
node_id: Option<u64>,
|
|
||||||
) -> Result<()> {
|
|
||||||
let ctx = deploy_ctx(coord, approval_id)?;
|
|
||||||
let pr = ctx.pr;
|
|
||||||
let reviewed = ctx.reviewed.as_str();
|
|
||||||
|
|
||||||
// 1. Drift gate: the live PR head must still equal what was reviewed.
|
|
||||||
let head = crate::forge::pr_head_sha(&ctx.repo, pr)
|
|
||||||
.await
|
|
||||||
.map_err(|e| anyhow::anyhow!("read PR #{pr} head: {e}"))?;
|
|
||||||
if head != reviewed {
|
|
||||||
bail!(
|
|
||||||
"PR #{pr} head drifted since review (reviewed {reviewed}, now {head}); re-review before merging"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. Fetch the reviewed head into applied so ff/verify/deploy resolve it.
|
|
||||||
crate::forge::fetch_pr_head_into_applied(&ctx.repo, pr)
|
|
||||||
.await
|
|
||||||
.map_err(|e| anyhow::anyhow!("fetch PR #{pr} head into applied: {e}"))?;
|
|
||||||
|
|
||||||
// 3. Ancestry gate: `main` must be reachable from the reviewed head, or the
|
|
||||||
// "fast-forward" in prepare_applied_target is really a rewind that drops
|
|
||||||
// every commit between the PR's base and where `main` actually is now.
|
|
||||||
let (current_main, descends) = applied_main_under(&ctx.applied_dir, reviewed).await?;
|
|
||||||
if !descends {
|
|
||||||
bail!(
|
|
||||||
"PR #{pr} does not descend from applied/main (main {current_main}, reviewed {reviewed}); \
|
|
||||||
merging it would discard commits — rebase the PR onto main and re-review"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 4. Eval-verify BEFORE the irreversible merge (bad nix fails fast here).
|
|
||||||
crate::meta::verify_commit(
|
|
||||||
ctx.approval.agent.as_str(),
|
|
||||||
&ctx.applied_dir,
|
|
||||||
reviewed,
|
|
||||||
node_id,
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.map_err(|e| anyhow::anyhow!("verify merge head {reviewed}: {e:#}"))?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// `applied/main`, and whether `target` descends from it. Fast-forwarding
|
/// `applied/main`, and whether `target` descends from it. Fast-forwarding
|
||||||
/// `main` to a `target` that does not is a rewind: it drops every commit on
|
/// `main` to a `target` that does not is a rewind: it drops every commit on
|
||||||
|
|
@ -262,8 +112,8 @@ pub(crate) async fn advance_applied_to_rev(agent: &str, rev: &str) -> Result<Rev
|
||||||
/// testable against a local repo.
|
/// testable against a local repo.
|
||||||
///
|
///
|
||||||
/// `applied/main` is compared to `rev` twice: before the fetch, so an applied
|
/// `applied/main` is compared to `rev` twice: before the fetch, so an applied
|
||||||
/// rev costs no forge round-trip, and after it, because a dashboard deploy of
|
/// rev costs no forge round-trip, and after it, because a meta-update cascade
|
||||||
/// the same merge fast-forwards `main` itself and may do so meanwhile.
|
/// fast-forwards `main` to the forge's and may do so meanwhile.
|
||||||
async fn advance_applied_main<T>(
|
async fn advance_applied_main<T>(
|
||||||
applied_dir: &std::path::Path,
|
applied_dir: &std::path::Path,
|
||||||
rev: &str,
|
rev: &str,
|
||||||
|
|
@ -290,210 +140,9 @@ async fn advance_applied_main<T>(
|
||||||
Ok(RevAdvance::Advanced)
|
Ok(RevAdvance::Advanced)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `DeployApply` node body — the irreversible half. Parks the rollback ref,
|
|
||||||
/// fast-forward-merges the PR (THE merge), then opens the deploy.
|
|
||||||
///
|
|
||||||
/// The ref is parked *before* the merge, so a hive-c0re crash anywhere from
|
|
||||||
/// here on still leaves [`run_deploy_tail`] enough to undo. If the merge itself
|
|
||||||
/// fails, `main` never moved and the tail's compensation is a no-op against the
|
|
||||||
/// same sha — harmless, and cheaper than trying to be clever about it.
|
|
||||||
///
|
|
||||||
/// Returning `Ok` is the signal for the caller to grow the rebuild subgraph into
|
|
||||||
/// this DAG under this node; the build itself does not happen here.
|
|
||||||
///
|
|
||||||
/// # Errors
|
|
||||||
///
|
|
||||||
/// Returns an error if the approval can't be loaded, if reading or parking the
|
|
||||||
/// pre-merge `main` sha fails, if the forge refuses the fast-forward merge
|
|
||||||
/// (including a head that drifted between verify and merge), or if
|
|
||||||
/// fast-forwarding `applied/main` / `meta::prepare_deploy` fails. From the merge
|
|
||||||
/// onward a failure is *not* retryable on its own — [`run_deploy_tail`] runs
|
|
||||||
/// `AfterAny` to compensate.
|
|
||||||
pub async fn run_deploy_apply(
|
|
||||||
coord: &Arc<Coordinator>,
|
|
||||||
approval_id: i64,
|
|
||||||
node_id: Option<u64>,
|
|
||||||
) -> Result<()> {
|
|
||||||
let ctx = deploy_ctx(coord, approval_id)?;
|
|
||||||
let agent = ctx.approval.agent.as_str();
|
|
||||||
let pr = ctx.pr;
|
|
||||||
|
|
||||||
let prev_main = lifecycle::git_rev_parse(&ctx.applied_dir, "refs/heads/main")
|
|
||||||
.await
|
|
||||||
.map_err(|e| anyhow::anyhow!("read applied/main: {e:#}"))?;
|
|
||||||
lifecycle::git_update_ref(&ctx.applied_dir, &rollback_ref(approval_id), &prev_main)
|
|
||||||
.await
|
|
||||||
.map_err(|e| anyhow::anyhow!("park rollback ref for approval {approval_id}: {e:#}"))?;
|
|
||||||
|
|
||||||
// THE merge: fast-forward-only merge the reviewed head to `main` via the
|
|
||||||
// forge API, pinned to the reviewed sha (`head_commit_id`). This one call
|
|
||||||
// both advances `main` to the reviewed head and marks the PR merged — no
|
|
||||||
// direct push to the protected branch. A failure here means `main` was NOT
|
|
||||||
// advanced, so it's fatal: we must not deploy a head the forge didn't merge.
|
|
||||||
match crate::forge::merge_config_pr_ff(&ctx.repo, pr, &ctx.reviewed).await {
|
|
||||||
Ok(()) => {}
|
|
||||||
Err(crate::forge::ForgeMergeError::HeadDrift { expected, actual }) => bail!(
|
|
||||||
"PR #{pr} head drifted before merge (reviewed {expected}, now {actual}); re-review before merging"
|
|
||||||
),
|
|
||||||
Err(e) => bail!("ff-merge PR #{pr}: {e}"),
|
|
||||||
}
|
|
||||||
|
|
||||||
prepare_applied_target(agent, &ctx.applied_dir, &ctx.reviewed, &prev_main, node_id).await
|
|
||||||
}
|
|
||||||
|
|
||||||
/// `DeployTail` node body — compensation + bookkeeping, `AfterAny` the apply
|
|
||||||
/// node so it runs on every outcome including a cancel-cascade. Infallible by
|
|
||||||
/// construction: it is the recovery step, so it has nothing to hand a failure
|
|
||||||
/// to. Every fallible call inside warns and continues.
|
|
||||||
///
|
|
||||||
/// 1. If the rollback ref survived, the deploy did not confirm good: tag the
|
|
||||||
/// merged commit `failed/<id>` (annotated with the DAG's first error, while
|
|
||||||
/// that sha is still reachable), then roll `applied/main` back to the parked
|
|
||||||
/// sha, resync the working tree, and drop the staged meta lock so the deploy
|
|
||||||
/// log only ever shows successes.
|
|
||||||
/// 2. Mirror the agent's config repo to the forge. `main` is already ff'd by
|
|
||||||
/// the merge, so only the `deployed/<id>` / `failed/<id>` tag refspec
|
|
||||||
/// actually lands — that's what gives the merged commit a forge-visible
|
|
||||||
/// deploy marker.
|
|
||||||
///
|
|
||||||
/// Takes `agent` from the node payload rather than the approval row so it still
|
|
||||||
/// works if the row vanished underneath the DAG (deny race, purge).
|
|
||||||
pub async fn run_deploy_tail(
|
|
||||||
coord: &Arc<Coordinator>,
|
|
||||||
queue_entry_id: Option<u64>,
|
|
||||||
agent: &str,
|
|
||||||
approval_id: i64,
|
|
||||||
) {
|
|
||||||
let applied_dir = crate::paths::applied_dir(agent);
|
|
||||||
let rollback = rollback_ref(approval_id);
|
|
||||||
if let Ok(prev_main) = lifecycle::git_rev_parse(&applied_dir, &rollback).await {
|
|
||||||
// Belt and braces: `run_deploy_apply` drops the ref before it plants
|
|
||||||
// `deployed/<id>`, so seeing both means the *delete* failed on an
|
|
||||||
// otherwise-successful deploy. Rolling back there would be the worst
|
|
||||||
// outcome this node can produce, so the tag wins.
|
|
||||||
if lifecycle::git_rev_parse(&applied_dir, &format!("deployed/{approval_id}"))
|
|
||||||
.await
|
|
||||||
.is_ok()
|
|
||||||
{
|
|
||||||
tracing::warn!(
|
|
||||||
%agent, approval_id,
|
|
||||||
"deploy tail: rollback ref outlived a successful deploy; dropping it without compensating"
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
// Mark the commit that failed to deploy, before undoing the merge
|
|
||||||
// that put it on `main`. After the rollback below, that sha is only
|
|
||||||
// reachable through this tag.
|
|
||||||
//
|
|
||||||
// Gated on `main` having actually moved: the rollback ref is parked
|
|
||||||
// *before* the merge, so its existence alone doesn't mean a merge
|
|
||||||
// happened. A pre-merge rejection (drift gate, eval failure, or the
|
|
||||||
// ff-merge itself failing) has no deployed commit to blame, and
|
|
||||||
// tagging the previous — innocent — head would point the operator at
|
|
||||||
// a commit that never got near a container.
|
|
||||||
//
|
|
||||||
// The annotation is read off the DAG rather than passed down from
|
|
||||||
// the node that failed: this node runs `AfterAny` its subject, so by
|
|
||||||
// now that node has settled `Failed` with its error recorded.
|
|
||||||
if let Ok(merged) = lifecycle::git_rev_parse(&applied_dir, "refs/heads/main").await
|
|
||||||
&& merged != prev_main
|
|
||||||
{
|
|
||||||
let tag = format!("failed/{approval_id}");
|
|
||||||
let body = queue_entry_id
|
|
||||||
.and_then(|dag_id| coord.job_queue.first_error(dag_id))
|
|
||||||
.unwrap_or_else(|| "deploy failed".to_owned());
|
|
||||||
if let Err(e) =
|
|
||||||
lifecycle::git_tag_annotated(&applied_dir, &tag, &merged, &body).await
|
|
||||||
{
|
|
||||||
tracing::warn!(%agent, approval_id, error = ?e, "deploy tail: annotate failed tag failed");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Err(e) =
|
|
||||||
lifecycle::git_update_ref(&applied_dir, "refs/heads/main", &prev_main).await
|
|
||||||
{
|
|
||||||
tracing::warn!(%agent, approval_id, error = ?e, "deploy tail: main rollback failed");
|
|
||||||
}
|
|
||||||
if let Err(e) = lifecycle::git_read_tree_reset(&applied_dir, "refs/heads/main").await {
|
|
||||||
tracing::warn!(%agent, approval_id, error = ?e, "deploy tail: rollback read-tree failed");
|
|
||||||
}
|
|
||||||
if let Err(e) = crate::meta::abort_deploy().await {
|
|
||||||
tracing::warn!(%agent, approval_id, error = ?e, "deploy tail: meta abort_deploy failed");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if let Err(e) = lifecycle::git_delete_ref(&applied_dir, &rollback).await {
|
|
||||||
tracing::warn!(%agent, approval_id, error = ?e, "deploy tail: drop rollback ref failed");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Err(e) = crate::forge::push_config(agent).await {
|
|
||||||
tracing::warn!(%agent, error = ?e, "forge: push_config after merge failed");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Max stderr bytes to inline in a PR failure comment. Keeps the comment
|
|
||||||
/// readable and under forge's size limits while still carrying the tail
|
|
||||||
/// where the nix/build error actually surfaces.
|
|
||||||
const PR_FAIL_LOG_TAIL_BYTES: usize = 4000;
|
|
||||||
|
|
||||||
/// On a failed `MergeConfigPr` deploy, post the failing build log back to the
|
|
||||||
/// config PR as a comment so the manager sees the rejection reason on the PR
|
|
||||||
/// itself. Best-effort: any error here is logged, never allowed to disturb the
|
|
||||||
/// approval-resolution path.
|
|
||||||
///
|
|
||||||
/// The failing `build_log` row is located heuristically: the most recent `fail`
|
|
||||||
/// row for this agent that started at/after the approval was decided (i.e. when
|
|
||||||
/// its deploy DAG was submitted). Because deploys are serialised per agent
|
|
||||||
/// through the queue, that is the step which just failed — `verify`,
|
|
||||||
/// `prepare-deploy`, `prebuild`, or the container rebuild. Pre-build failures
|
|
||||||
/// (drift gate, fetch) create no `build_log` row, so the comment then carries
|
|
||||||
/// only the error text.
|
|
||||||
async fn post_merge_failure_to_pr(
|
|
||||||
coord: &Arc<Coordinator>,
|
|
||||||
approval: &hive_sh4re::approvals::Approval,
|
|
||||||
err: &anyhow::Error,
|
|
||||||
) {
|
|
||||||
let Ok(pr) = approval.commit_ref.parse::<u64>() else {
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
let repo = crate::forge::config_repo(approval.agent.as_str());
|
|
||||||
let since_ts = approval
|
|
||||||
.resolved_at
|
|
||||||
.unwrap_or(approval.requested_at)
|
|
||||||
.timestamp();
|
|
||||||
|
|
||||||
let log_section = coord
|
|
||||||
.build_logs
|
|
||||||
.list_recent_for_agent(approval.agent.as_str(), 10)
|
|
||||||
.ok()
|
|
||||||
.and_then(|rows| {
|
|
||||||
rows.into_iter()
|
|
||||||
.find(|r| r.status.as_deref() == Some("fail") && r.started_at >= since_ts)
|
|
||||||
})
|
|
||||||
.and_then(|row| coord.build_logs.get_full(row.id).ok().flatten())
|
|
||||||
.map(|full| {
|
|
||||||
let tail = tail_bytes(full.stderr.trim_end(), PR_FAIL_LOG_TAIL_BYTES);
|
|
||||||
format!(
|
|
||||||
"\n\n**Failing step:** `{}` (build log #{})\n\n```\n{tail}\n```",
|
|
||||||
full.header.kind, full.header.id
|
|
||||||
)
|
|
||||||
})
|
|
||||||
.unwrap_or_default();
|
|
||||||
|
|
||||||
let body = format!(
|
|
||||||
"## ⚠️ config deploy failed\n\n\
|
|
||||||
Approval #{} to merge this PR could not be deployed:\n\n\
|
|
||||||
```\n{err:#}\n```{log_section}",
|
|
||||||
approval.id
|
|
||||||
);
|
|
||||||
|
|
||||||
if let Err(e) = crate::forge::post_pr_comment(&repo, pr, &body).await {
|
|
||||||
tracing::warn!(agent = %approval.agent, %pr, error = ?e, "post merge-failure comment to PR failed");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Post why the merged commit `rev` of `agent`'s config repo was not deployed
|
/// Post why the merged commit `rev` of `agent`'s config repo was not deployed
|
||||||
/// onto the PR whose merge produced it. Best-effort, like
|
/// onto the PR whose merge produced it. Best-effort: a failure here is logged
|
||||||
/// [`post_merge_failure_to_pr`]: a failure here is logged only.
|
/// only.
|
||||||
pub(crate) async fn post_rev_deploy_failure_to_pr(agent: &str, rev: &str, err: &anyhow::Error) {
|
pub(crate) async fn post_rev_deploy_failure_to_pr(agent: &str, rev: &str, err: &anyhow::Error) {
|
||||||
let repo = crate::forge::config_repo(agent);
|
let repo = crate::forge::config_repo(agent);
|
||||||
let pr = match crate::forge::merged_pr_for_commit(&repo, rev).await {
|
let pr = match crate::forge::merged_pr_for_commit(&repo, rev).await {
|
||||||
|
|
@ -513,19 +162,6 @@ pub(crate) async fn post_rev_deploy_failure_to_pr(agent: &str, rev: &str, err: &
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Return the last `max_bytes` of `s`, snapped to a char boundary, prefixed
|
|
||||||
/// with an elision marker when truncated.
|
|
||||||
fn tail_bytes(s: &str, max_bytes: usize) -> String {
|
|
||||||
if s.len() <= max_bytes {
|
|
||||||
return s.to_owned();
|
|
||||||
}
|
|
||||||
let mut start = s.len() - max_bytes;
|
|
||||||
while start < s.len() && !s.is_char_boundary(start) {
|
|
||||||
start += 1;
|
|
||||||
}
|
|
||||||
format!("[… truncated …]\n{}", &s[start..])
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Inline (non-queued) handler for `ApprovalKind::SchedulePrompt`.
|
/// Inline (non-queued) handler for `ApprovalKind::SchedulePrompt`.
|
||||||
/// On approve, decode the `SchedulePromptPayload` JSON from the
|
/// On approve, decode the `SchedulePromptPayload` JSON from the
|
||||||
/// approval's `commit_ref`, insert a row into `scheduled_prompts`
|
/// approval's `commit_ref`, insert a row into `scheduled_prompts`
|
||||||
|
|
@ -554,12 +190,12 @@ async fn run_approval_schedule_prompt(
|
||||||
.context("insert scheduled prompt")
|
.context("insert scheduled prompt")
|
||||||
}
|
}
|
||||||
.await;
|
.await;
|
||||||
finish_approval(coord, &approval, result, None).await
|
finish_approval(coord, &approval, result)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Resolve an approval row from how its DAG's work ended — the body of the
|
/// Resolve an approval row from how its DAG's work ended — the body of the
|
||||||
/// [`NodeKind::ResolveApproval`] tail node. Every approval-carrying template
|
/// [`NodeKind::ResolveApproval`] tail node. Every approval-carrying template
|
||||||
/// resolves here, deploys included: the deploy pipeline is ordinary queue nodes,
|
/// resolves here: the pipeline is ordinary queue nodes,
|
||||||
/// so the work's terminal state is the authoritative outcome and there's no
|
/// so the work's terminal state is the authoritative outcome and there's no
|
||||||
/// in-node resolution to skip around.
|
/// in-node resolution to skip around.
|
||||||
///
|
///
|
||||||
|
|
@ -568,7 +204,7 @@ async fn run_approval_schedule_prompt(
|
||||||
/// their own node rather than from one node branching.
|
/// their own node rather than from one node branching.
|
||||||
///
|
///
|
||||||
/// [`NodeKind::ResolveApproval`]: crate::job_queue::NodeKind::ResolveApproval
|
/// [`NodeKind::ResolveApproval`]: crate::job_queue::NodeKind::ResolveApproval
|
||||||
pub(crate) async fn resolve_approval_dag(
|
pub(crate) fn resolve_approval_dag(
|
||||||
coord: &Arc<Coordinator>,
|
coord: &Arc<Coordinator>,
|
||||||
approval_id: i64,
|
approval_id: i64,
|
||||||
outcome: crate::job_queue::TerminalState,
|
outcome: crate::job_queue::TerminalState,
|
||||||
|
|
@ -595,77 +231,15 @@ pub(crate) async fn resolve_approval_dag(
|
||||||
}
|
}
|
||||||
TerminalState::Failed => Err(anyhow::anyhow!("{}", error.unwrap_or("job dag failed"))),
|
TerminalState::Failed => Err(anyhow::anyhow!("{}", error.unwrap_or("job dag failed"))),
|
||||||
};
|
};
|
||||||
let mut terminal_tag = None;
|
if let Err(e) = finish_approval(coord, &approval, result) {
|
||||||
if approval.kind == ApprovalKind::MergeConfigPr {
|
|
||||||
terminal_tag = deploy_terminal_tag(approval.agent.as_str(), approval_id, outcome).await;
|
|
||||||
// On a failed deploy, surface the failing build log back onto the
|
|
||||||
// PR so the manager sees why it was rejected without leaving the
|
|
||||||
// forge. Posted here rather than inside a node because this is the
|
|
||||||
// one place that holds the DAG's definitive error — a `MergeVerify`
|
|
||||||
// rejection and a `DeployApply` build failure both land here.
|
|
||||||
if let Err(e) = &result {
|
|
||||||
post_merge_failure_to_pr(coord, &approval, e).await;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if let Err(e) = finish_approval(coord, &approval, result, terminal_tag).await {
|
|
||||||
tracing::warn!(approval_id, error = ?e, "approval dag resolved with failure");
|
tracing::warn!(approval_id, error = ?e, "approval dag resolved with failure");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Which bookkeeping tag a settled deploy DAG actually planted, for the
|
fn finish_approval(
|
||||||
/// `Rebuilt` event's `tag` field. The state picks the candidate name, but the
|
|
||||||
/// applied repo has the final say: a pre-merge rejection (`MergeVerify` drift
|
|
||||||
/// gate, eval failure) fails the DAG without ever planting `failed/<id>`, and
|
|
||||||
/// tag plants are best-effort. Reporting a tag that isn't there would send the
|
|
||||||
/// manager looking for a ref that doesn't exist.
|
|
||||||
async fn deploy_terminal_tag(
|
|
||||||
agent: &str,
|
|
||||||
approval_id: i64,
|
|
||||||
outcome: crate::job_queue::TerminalState,
|
|
||||||
) -> Option<String> {
|
|
||||||
use crate::job_queue::TerminalState;
|
|
||||||
let candidate = match outcome {
|
|
||||||
TerminalState::Done => format!("deployed/{approval_id}"),
|
|
||||||
// Nothing ran, so nothing was planted.
|
|
||||||
TerminalState::Cancelled | TerminalState::Skipped => return None,
|
|
||||||
TerminalState::Failed => format!("failed/{approval_id}"),
|
|
||||||
};
|
|
||||||
lifecycle::git_rev_parse(&crate::paths::applied_dir(agent), &candidate)
|
|
||||||
.await
|
|
||||||
.ok()
|
|
||||||
.map(|_| candidate)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Re-fetch an approval row from sqlite for a queue-worker dispatch.
|
|
||||||
/// Bails if the row is gone (deny race), if its kind doesn't match,
|
|
||||||
/// or if the lookup itself fails. The kind check is defensive — the
|
|
||||||
/// queue's `dispatch` already routes by `QueueKind`, but the approval
|
|
||||||
/// kind is the authoritative source of truth and a mismatch points
|
|
||||||
/// at a deeper bug we'd want to surface.
|
|
||||||
fn fetch_approval_for_worker(
|
|
||||||
coord: &Coordinator,
|
|
||||||
approval_id: i64,
|
|
||||||
expected_kind: ApprovalKind,
|
|
||||||
) -> Result<hive_sh4re::approvals::Approval> {
|
|
||||||
let approval = coord
|
|
||||||
.approvals
|
|
||||||
.get(approval_id)
|
|
||||||
.map_err(|e| anyhow::anyhow!("read approval {approval_id}: {e:#}"))?
|
|
||||||
.ok_or_else(|| anyhow::anyhow!("approval {approval_id} no longer exists"))?;
|
|
||||||
if approval.kind != expected_kind {
|
|
||||||
bail!(
|
|
||||||
"approval {approval_id} kind mismatch: queue expected {expected_kind:?}, row is {actual:?}",
|
|
||||||
actual = approval.kind
|
|
||||||
);
|
|
||||||
}
|
|
||||||
Ok(approval)
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn finish_approval(
|
|
||||||
coord: &Coordinator,
|
coord: &Coordinator,
|
||||||
approval: &hive_sh4re::approvals::Approval,
|
approval: &hive_sh4re::approvals::Approval,
|
||||||
result: Result<()>,
|
result: Result<()>,
|
||||||
terminal_tag: Option<String>,
|
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
let (status, note, ok) = match &result {
|
let (status, note, ok) = match &result {
|
||||||
Ok(()) => (ApprovalStatus::Approved, None, true),
|
Ok(()) => (ApprovalStatus::Approved, None, true),
|
||||||
|
|
@ -683,8 +257,6 @@ async fn finish_approval(
|
||||||
commit_ref: approval.commit_ref.clone(),
|
commit_ref: approval.commit_ref.clone(),
|
||||||
status,
|
status,
|
||||||
note: note.clone(),
|
note: note.clone(),
|
||||||
sha: approval.fetched_sha.clone(),
|
|
||||||
tag: terminal_tag.clone(),
|
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
// Phase 5b: also fire on the dashboard event channel so the
|
// Phase 5b: also fire on the dashboard event channel so the
|
||||||
|
|
@ -693,88 +265,18 @@ async fn finish_approval(
|
||||||
// approval's logged resolved_at indirectly via `Utc::now()`;
|
// approval's logged resolved_at indirectly via `Utc::now()`;
|
||||||
// failures already wrote it via mark_failed above.
|
// failures already wrote it via mark_failed above.
|
||||||
let approval_kind = <&str>::from(approval.kind);
|
let approval_kind = <&str>::from(approval.kind);
|
||||||
let sha_short = approval
|
|
||||||
.fetched_sha
|
|
||||||
.as_deref()
|
|
||||||
.map(|s| s[..s.len().min(12)].to_owned());
|
|
||||||
let status_str = if ok { "approved" } else { "failed" };
|
let status_str = if ok { "approved" } else { "failed" };
|
||||||
coord.emit_approval_resolved(crate::coordinator::ApprovalResolved {
|
coord.emit_approval_resolved(crate::coordinator::ApprovalResolved {
|
||||||
id: approval.id,
|
id: approval.id,
|
||||||
agent: approval.agent.as_str(),
|
agent: approval.agent.as_str(),
|
||||||
approval_kind,
|
approval_kind,
|
||||||
sha_short,
|
|
||||||
status: status_str,
|
status: status_str,
|
||||||
note: note.clone(),
|
note: note.clone(),
|
||||||
description: approval.description.clone(),
|
description: approval.description.clone(),
|
||||||
});
|
});
|
||||||
// For rebuild approvals, also surface the underlying action so the
|
|
||||||
// manager knows whether the lifecycle step succeeded. The
|
|
||||||
// ApprovalResolved event already carries the same `ok` signal but
|
|
||||||
// separating it lets the manager react to the lifecycle change
|
|
||||||
// without having to special-case approvals.
|
|
||||||
match approval.kind {
|
|
||||||
// MergeConfigPr ends in a container rebuild — surface a Rebuilt
|
|
||||||
// lifecycle event.
|
|
||||||
ApprovalKind::MergeConfigPr => {
|
|
||||||
let summary = crate::coordinator::rebuilt_todo_summary(
|
|
||||||
approval.agent.as_str(),
|
|
||||||
ok,
|
|
||||||
note.as_deref(),
|
|
||||||
approval.fetched_sha.as_deref(),
|
|
||||||
terminal_tag.as_deref(),
|
|
||||||
);
|
|
||||||
let _ = coord
|
|
||||||
.push_todo_submitter(
|
|
||||||
approval.id,
|
|
||||||
"core",
|
|
||||||
Some(format!("rebuilt:{}", approval.agent)),
|
|
||||||
summary,
|
|
||||||
None,
|
|
||||||
)
|
|
||||||
.await;
|
|
||||||
}
|
|
||||||
// UpdateMetaInputs / SchedulePrompt: ApprovalResolved already
|
|
||||||
// carries the result. No separate lifecycle event needed.
|
|
||||||
ApprovalKind::UpdateMetaInputs | ApprovalKind::SchedulePrompt => {}
|
|
||||||
}
|
|
||||||
result
|
result
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Open the deploy for the config-PR flow: fast-forward `applied/main` to
|
|
||||||
/// `target`, sync the working tree, and run phase 1 of the meta two-phase
|
|
||||||
/// deploy. The container rebuild that used to run inline here is now the
|
|
||||||
/// subgraph [`run_deploy_apply`]'s node grows into the DAG, and the closing half
|
|
||||||
/// is [`run_finalize_deploy`].
|
|
||||||
///
|
|
||||||
/// **Undo is not this function's job.** Every early return here leaves the
|
|
||||||
/// applied repo dirty on purpose — [`run_deploy_tail`] owns compensation, and
|
|
||||||
/// it runs whether this returns `Err`, panics, or never returns at all because
|
|
||||||
/// hive-c0re was restarted underneath it. That's the whole point of parking the
|
|
||||||
/// pre-merge sha in a git ref instead of a local variable.
|
|
||||||
///
|
|
||||||
/// Caller-specific bits stay OUT of here: fetching the PR head, the
|
|
||||||
/// `verify_commit` gate, and the ff-merge. The agent always already exists here
|
|
||||||
/// (a merge is never a first deploy), so there's no `sync_agents` step — the
|
|
||||||
/// first-deploy DAG's `Provision` node owns first-time meta registration.
|
|
||||||
async fn prepare_applied_target(
|
|
||||||
agent: &str,
|
|
||||||
applied_dir: &std::path::Path,
|
|
||||||
target: &str,
|
|
||||||
expected_main: &str,
|
|
||||||
node_id: Option<u64>,
|
|
||||||
) -> Result<()> {
|
|
||||||
// Meta input pins `?ref=main`, so this is what makes nix re-lock to the
|
|
||||||
// target commit on the prepare_deploy step below.
|
|
||||||
ff_applied_main(applied_dir, target, expected_main).await?;
|
|
||||||
|
|
||||||
// Phase 1 of the meta two-phase deploy: relock without committing. The
|
|
||||||
// staged lock then stays uncommitted across the whole appended rebuild —
|
|
||||||
// which is why the `MetaWindow` is held by the deploy root, not by a node.
|
|
||||||
crate::meta::prepare_deploy(agent, node_id)
|
|
||||||
.await
|
|
||||||
.map_err(|e| anyhow::anyhow!("meta prepare_deploy: {e:#}"))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Fast-forward `applied/main` to `target` and sync the working tree.
|
/// Fast-forward `applied/main` to `target` and sync the working tree.
|
||||||
///
|
///
|
||||||
/// Compare-and-swap, not a bare set: `main` must still be `expected_main`, the
|
/// Compare-and-swap, not a bare set: `main` must still be `expected_main`, the
|
||||||
|
|
@ -798,46 +300,6 @@ async fn ff_applied_main(
|
||||||
.map_err(|e| anyhow::anyhow!("read-tree to main: {e:#}"))
|
.map_err(|e| anyhow::anyhow!("read-tree to main: {e:#}"))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `FinalizeDeploy` node body — phase 2 of the meta two-phase deploy, run once
|
|
||||||
/// the appended rebuild subgraph has built, swapped, and brought the container
|
|
||||||
/// back up.
|
|
||||||
///
|
|
||||||
/// Drops the rollback ref *first*: from here the deploy is good and
|
|
||||||
/// [`run_deploy_tail`] must not roll `main` back. Ordering that ahead of the tag
|
|
||||||
/// plant is what makes the tail's `deployed/<id>` cross-check a second line of
|
|
||||||
/// defence rather than the only one. No agent kick — the rebuild's own
|
|
||||||
/// `RebuildBookkeeping` already did it.
|
|
||||||
///
|
|
||||||
/// # Errors
|
|
||||||
///
|
|
||||||
/// Returns an error if the approval can't be loaded, if dropping the rollback
|
|
||||||
/// ref fails, or if planting the `deployed/<id>` tag fails. Those two git writes
|
|
||||||
/// *are* the deploy's "confirmed good" signal, so warning past them would let
|
|
||||||
/// this node report success while leaving the tail looking at the git state of a
|
|
||||||
/// failure — and the tail would then compensate a good deploy. A failing
|
|
||||||
/// `meta::finalize_deploy` is deliberately *not* an error: the container is
|
|
||||||
/// already running the new config by then, and the staged `flake.lock` it
|
|
||||||
/// couldn't commit is something the operator can land by hand.
|
|
||||||
pub async fn run_finalize_deploy(coord: &Arc<Coordinator>, approval_id: i64) -> Result<()> {
|
|
||||||
let ctx = deploy_ctx(coord, approval_id)?;
|
|
||||||
let agent = ctx.approval.agent.as_str();
|
|
||||||
let target = ctx.reviewed.as_str();
|
|
||||||
|
|
||||||
lifecycle::git_delete_ref(&ctx.applied_dir, &rollback_ref(approval_id))
|
|
||||||
.await
|
|
||||||
.map_err(|e| anyhow::anyhow!("drop rollback ref for approval {approval_id}: {e:#}"))?;
|
|
||||||
|
|
||||||
let tag = format!("deployed/{approval_id}");
|
|
||||||
lifecycle::git_tag(&ctx.applied_dir, &tag, target)
|
|
||||||
.await
|
|
||||||
.map_err(|e| anyhow::anyhow!("plant {tag}: {e:#}"))?;
|
|
||||||
|
|
||||||
if let Err(e) = crate::meta::finalize_deploy(agent, target, &tag).await {
|
|
||||||
tracing::warn!(%agent, approval_id, error = ?e, "meta finalize_deploy failed");
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Tear down a sub-agent container. By default this is non-destructive to
|
/// Tear down a sub-agent container. By default this is non-destructive to
|
||||||
/// persistent state: the proposed/applied config repos and the Claude
|
/// persistent state: the proposed/applied config repos and the Claude
|
||||||
/// credentials dir under `/var/lib/hyperhive/{agents,applied}/<name>/` are
|
/// credentials dir under `/var/lib/hyperhive/{agents,applied}/<name>/` are
|
||||||
|
|
@ -871,9 +333,7 @@ pub fn deny(coord: &Coordinator, id: i64, note: Option<&str>) -> Result<()> {
|
||||||
coord.approvals.mark_denied(id, note)?;
|
coord.approvals.mark_denied(id, note)?;
|
||||||
tracing::info!(%id, note, "approval denied");
|
tracing::info!(%id, note, "approval denied");
|
||||||
if let Some(a) = approval {
|
if let Some(a) = approval {
|
||||||
let sha = a.fetched_sha.clone();
|
|
||||||
let approval_kind = <&str>::from(a.kind);
|
let approval_kind = <&str>::from(a.kind);
|
||||||
let sha_short = sha.as_deref().map(|s| s[..s.len().min(12)].to_owned());
|
|
||||||
let description = a.description.clone();
|
let description = a.description.clone();
|
||||||
let agent_owned = a.agent.clone();
|
let agent_owned = a.agent.clone();
|
||||||
coord.notify_submitter(
|
coord.notify_submitter(
|
||||||
|
|
@ -884,16 +344,12 @@ pub fn deny(coord: &Coordinator, id: i64, note: Option<&str>) -> Result<()> {
|
||||||
commit_ref: a.commit_ref,
|
commit_ref: a.commit_ref,
|
||||||
status: ApprovalStatus::Denied,
|
status: ApprovalStatus::Denied,
|
||||||
note: note.map(String::from),
|
note: note.map(String::from),
|
||||||
sha,
|
|
||||||
// A denied config PR carries no git tag — it stays open on the forge.
|
|
||||||
tag: None,
|
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
coord.emit_approval_resolved(crate::coordinator::ApprovalResolved {
|
coord.emit_approval_resolved(crate::coordinator::ApprovalResolved {
|
||||||
id,
|
id,
|
||||||
agent: agent_owned.as_str(),
|
agent: agent_owned.as_str(),
|
||||||
approval_kind,
|
approval_kind,
|
||||||
sha_short,
|
|
||||||
status: "denied",
|
status: "denied",
|
||||||
note: note.map(String::from),
|
note: note.map(String::from),
|
||||||
description,
|
description,
|
||||||
|
|
|
||||||
|
|
@ -421,7 +421,6 @@ pub struct ApprovalResolved<'a> {
|
||||||
pub id: i64,
|
pub id: i64,
|
||||||
pub agent: &'a str,
|
pub agent: &'a str,
|
||||||
pub approval_kind: &'static str,
|
pub approval_kind: &'static str,
|
||||||
pub sha_short: Option<String>,
|
|
||||||
pub status: &'static str,
|
pub status: &'static str,
|
||||||
pub note: Option<String>,
|
pub note: Option<String>,
|
||||||
pub description: Option<String>,
|
pub description: Option<String>,
|
||||||
|
|
@ -430,14 +429,12 @@ pub struct ApprovalResolved<'a> {
|
||||||
/// Field-named payload for [`Coordinator::emit_approval_added`].
|
/// Field-named payload for [`Coordinator::emit_approval_added`].
|
||||||
/// Mirrors the `ApprovalAdded` dashboard-event fields. `agent`
|
/// Mirrors the `ApprovalAdded` dashboard-event fields. `agent`
|
||||||
/// borrows from the caller; `approval_kind` is a compile-time
|
/// borrows from the caller; `approval_kind` is a compile-time
|
||||||
/// constant. `pr_number` is set for `merge_config_pr` only.
|
/// constant.
|
||||||
pub struct ApprovalAdded<'a> {
|
pub struct ApprovalAdded<'a> {
|
||||||
pub id: i64,
|
pub id: i64,
|
||||||
pub agent: &'a str,
|
pub agent: &'a str,
|
||||||
pub approval_kind: &'static str,
|
pub approval_kind: &'static str,
|
||||||
pub sha_short: Option<String>,
|
|
||||||
pub description: Option<String>,
|
pub description: Option<String>,
|
||||||
pub pr_number: Option<u64>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The two ways [`Coordinator::set_paused_by_name`] can fail. See its own
|
/// The two ways [`Coordinator::set_paused_by_name`] can fail. See its own
|
||||||
|
|
@ -782,18 +779,14 @@ impl Coordinator {
|
||||||
id,
|
id,
|
||||||
agent,
|
agent,
|
||||||
approval_kind,
|
approval_kind,
|
||||||
sha_short,
|
|
||||||
description,
|
description,
|
||||||
pr_number,
|
|
||||||
} = ev;
|
} = ev;
|
||||||
self.emit_dashboard_event(DashboardEvent::ApprovalAdded {
|
self.emit_dashboard_event(DashboardEvent::ApprovalAdded {
|
||||||
seq: self.next_seq(),
|
seq: self.next_seq(),
|
||||||
id,
|
id,
|
||||||
agent: agent.to_owned(),
|
agent: agent.to_owned(),
|
||||||
approval_kind,
|
approval_kind,
|
||||||
sha_short,
|
|
||||||
description,
|
description,
|
||||||
pr_number,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -811,7 +804,6 @@ impl Coordinator {
|
||||||
id,
|
id,
|
||||||
agent,
|
agent,
|
||||||
approval_kind,
|
approval_kind,
|
||||||
sha_short,
|
|
||||||
status,
|
status,
|
||||||
note,
|
note,
|
||||||
description,
|
description,
|
||||||
|
|
@ -826,7 +818,6 @@ impl Coordinator {
|
||||||
id,
|
id,
|
||||||
agent: agent.to_owned(),
|
agent: agent.to_owned(),
|
||||||
approval_kind,
|
approval_kind,
|
||||||
sha_short,
|
|
||||||
status,
|
status,
|
||||||
resolved_at: hive_sh4re::wire_time::from_secs(resolved_at),
|
resolved_at: hive_sh4re::wire_time::from_secs(resolved_at),
|
||||||
note,
|
note,
|
||||||
|
|
@ -1256,24 +1247,6 @@ impl Coordinator {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `push_todo` to whichever agent submitted approval `approval_id` —
|
|
||||||
/// same resolution `notify_submitter` uses. Every caller here is a
|
|
||||||
/// one-shot approval-resolution notice, so `reopen_if_acked` is
|
|
||||||
/// unconditionally `false` — there's no reconciler/event distinction
|
|
||||||
/// to make for an event that only ever fires once.
|
|
||||||
pub async fn push_todo_submitter(
|
|
||||||
&self,
|
|
||||||
approval_id: i64,
|
|
||||||
subsystem: &str,
|
|
||||||
key: Option<String>,
|
|
||||||
summary: String,
|
|
||||||
source: Option<String>,
|
|
||||||
) -> Result<(), String> {
|
|
||||||
let target = self.submitter_or_manager(approval_id);
|
|
||||||
self.push_todo(&target, subsystem, key, summary, source, false)
|
|
||||||
.await
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Push a `HelperEvent` into an arbitrary agent's inbox. Encoded
|
/// Push a `HelperEvent` into an arbitrary agent's inbox. Encoded
|
||||||
/// the same way as `notify_manager` (sender = `SYSTEM_SENDER`,
|
/// the same way as `notify_manager` (sender = `SYSTEM_SENDER`,
|
||||||
/// body = JSON-encoded event) — e.g. `ContainerCrash`, `NeedsUpdate`.
|
/// body = JSON-encoded event) — e.g. `ContainerCrash`, `NeedsUpdate`.
|
||||||
|
|
@ -1476,11 +1449,9 @@ impl Coordinator {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `push_todo`/`push_todo_submitter` summary text for a rebuild outcome —
|
/// `push_todo` summary text for a rebuild outcome, for
|
||||||
/// shared by the two `Rebuilt` call sites (`actions::finish_approval`'s
|
/// `job_queue::exec::run_emit_rebuilt`. Pure + independently testable, unlike
|
||||||
/// `MergeConfigPr` arm, `job_queue::exec::run_emit_rebuilt`) so the wording
|
/// the old `HelperEvent::Rebuilt`'s separate `sha`/`tag`
|
||||||
/// stays identical regardless of which path fired. Pure + independently
|
|
||||||
/// testable, unlike the old `HelperEvent::Rebuilt`'s separate `sha`/`tag`
|
|
||||||
/// fields — those become part of the summary text itself now, since a todo
|
/// fields — those become part of the summary text itself now, since a todo
|
||||||
/// carries one string, not a structured payload.
|
/// carries one string, not a structured payload.
|
||||||
#[must_use]
|
#[must_use]
|
||||||
|
|
|
||||||
|
|
@ -89,15 +89,10 @@ pub(super) fn gc_orphans(coord: &Coordinator, approvals: Vec<Approval>) -> Vec<A
|
||||||
let note = "agent state dir missing";
|
let note = "agent state dir missing";
|
||||||
let _ = coord.approvals.mark_failed(a.id, note);
|
let _ = coord.approvals.mark_failed(a.id, note);
|
||||||
tracing::info!(id = a.id, agent = %a.agent, "auto-failed orphan approval");
|
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 {
|
coord.emit_approval_resolved(crate::coordinator::ApprovalResolved {
|
||||||
id: a.id,
|
id: a.id,
|
||||||
agent: a.agent.as_str(),
|
agent: a.agent.as_str(),
|
||||||
approval_kind: <&str>::from(a.kind),
|
approval_kind: <&str>::from(a.kind),
|
||||||
sha_short,
|
|
||||||
status: "failed",
|
status: "failed",
|
||||||
note: Some(note.to_owned()),
|
note: Some(note.to_owned()),
|
||||||
description: a.description.clone(),
|
description: a.description.clone(),
|
||||||
|
|
|
||||||
|
|
@ -45,7 +45,6 @@ use crate::lifecycle;
|
||||||
(name = "state_files", description = "proxied reads of allow-listed per-agent state files"),
|
(name = "state_files", description = "proxied reads of allow-listed per-agent state files"),
|
||||||
(name = "state_snapshot", description = "cold-load dashboard snapshot"),
|
(name = "state_snapshot", description = "cold-load dashboard snapshot"),
|
||||||
(name = "tombstones", description = "purge of retained state for destroyed agents"),
|
(name = "tombstones", description = "purge of retained state for destroyed agents"),
|
||||||
(name = "webhook", description = "forgejo webhook receivers"),
|
|
||||||
)
|
)
|
||||||
)]
|
)]
|
||||||
struct ApiDoc;
|
struct ApiDoc;
|
||||||
|
|
@ -67,7 +66,6 @@ mod schedules;
|
||||||
mod state_files;
|
mod state_files;
|
||||||
mod state_snapshot;
|
mod state_snapshot;
|
||||||
mod tombstones;
|
mod tombstones;
|
||||||
mod webhook;
|
|
||||||
|
|
||||||
// Run after lock bumps by the job queue (`job_queue/exec.rs`); the view
|
// Run after lock bumps by the job queue (`job_queue/exec.rs`); the view
|
||||||
// type feeds `DashboardEvent::MetaInputsChanged` (`dashboard_events.rs`).
|
// type feeds `DashboardEvent::MetaInputsChanged` (`dashboard_events.rs`).
|
||||||
|
|
@ -87,11 +85,6 @@ pub(crate) use tombstones::emit_tombstones_snapshot;
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
struct AppState {
|
struct AppState {
|
||||||
coord: Arc<Coordinator>,
|
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(
|
#[allow(
|
||||||
|
|
@ -101,11 +94,7 @@ struct AppState {
|
||||||
handler; splitting that exhaustive list across helpers would \
|
handler; splitting that exhaustive list across helpers would \
|
||||||
obscure the route map for no readability gain"
|
obscure the route map for no readability gain"
|
||||||
)]
|
)]
|
||||||
pub async fn serve(
|
pub async fn serve(port: u16, coord: Arc<Coordinator>) -> Result<()> {
|
||||||
port: u16,
|
|
||||||
coord: Arc<Coordinator>,
|
|
||||||
webhook_secret: Option<String>,
|
|
||||||
) -> Result<()> {
|
|
||||||
// API-only: the gateway static-serves the dashboard dist and proxies
|
// API-only: the gateway static-serves the dashboard dist and proxies
|
||||||
// non-static requests here (see `nix/host-modules/hive-gateway/vhosts.nix`).
|
// non-static requests here (see `nix/host-modules/hive-gateway/vhosts.nix`).
|
||||||
// Unmatched paths 404.
|
// Unmatched paths 404.
|
||||||
|
|
@ -162,7 +151,6 @@ pub async fn serve(
|
||||||
.routes(routes!(schedules::post_schedule_resume))
|
.routes(routes!(schedules::post_schedule_resume))
|
||||||
.routes(routes!(schedules::post_schedule_fire_now))
|
.routes(routes!(schedules::post_schedule_fire_now))
|
||||||
.routes(routes!(schedules::post_rebuild_queue_cancel))
|
.routes(routes!(schedules::post_rebuild_queue_cancel))
|
||||||
.routes(routes!(webhook::post_webhook_config_pr))
|
|
||||||
.routes(routes!(approvals::post_approve))
|
.routes(routes!(approvals::post_approve))
|
||||||
.routes(routes!(approvals::post_deny))
|
.routes(routes!(approvals::post_deny))
|
||||||
.routes(routes!(lifecycle_ops::post_destroy))
|
.routes(routes!(lifecycle_ops::post_destroy))
|
||||||
|
|
@ -191,10 +179,7 @@ pub async fn serve(
|
||||||
"/api/openapi.json",
|
"/api/openapi.json",
|
||||||
get(move || async move { Json(api.clone()) }),
|
get(move || async move { Json(api.clone()) }),
|
||||||
)
|
)
|
||||||
.with_state(AppState {
|
.with_state(AppState { coord });
|
||||||
coord,
|
|
||||||
webhook_secret,
|
|
||||||
});
|
|
||||||
// Binds loopback-only; external access via gateway.
|
// Binds loopback-only; external access via gateway.
|
||||||
// Rationale: docs/networking/gateway.md::Firewall posture.
|
// Rationale: docs/networking/gateway.md::Firewall posture.
|
||||||
let addr = SocketAddr::from(([127, 0, 0, 1], port));
|
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_resume))
|
||||||
.routes(routes!(schedules::post_schedule_fire_now))
|
.routes(routes!(schedules::post_schedule_fire_now))
|
||||||
.routes(routes!(schedules::post_rebuild_queue_cancel))
|
.routes(routes!(schedules::post_rebuild_queue_cancel))
|
||||||
.routes(routes!(webhook::post_webhook_config_pr))
|
|
||||||
.routes(routes!(approvals::post_approve))
|
.routes(routes!(approvals::post_approve))
|
||||||
.routes(routes!(approvals::post_deny))
|
.routes(routes!(approvals::post_deny))
|
||||||
.routes(routes!(lifecycle_ops::post_destroy))
|
.routes(routes!(lifecycle_ops::post_destroy))
|
||||||
|
|
|
||||||
|
|
@ -131,8 +131,7 @@ struct ApprovalHistoryView {
|
||||||
id: i64,
|
id: i64,
|
||||||
agent: String,
|
agent: String,
|
||||||
kind: &'static str,
|
kind: &'static str,
|
||||||
/// First 12 chars of the canonical sha (preferred) or
|
/// First 12 chars of the manager-supplied ref. None when it is empty.
|
||||||
/// manager-supplied ref. None when the approval carries neither.
|
|
||||||
sha_short: Option<String>,
|
sha_short: Option<String>,
|
||||||
/// `approved` / `denied` / `failed`.
|
/// `approved` / `denied` / `failed`.
|
||||||
status: &'static str,
|
status: &'static str,
|
||||||
|
|
@ -149,25 +148,14 @@ struct ApprovalView {
|
||||||
id: i64,
|
id: i64,
|
||||||
agent: String,
|
agent: String,
|
||||||
kind: &'static str,
|
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.
|
/// Manager-supplied description shown on the approval card.
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
description: Option<String>,
|
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
|
/// Raw `commit_ref` payload for `UpdateMetaInputs` (JSON-encoded
|
||||||
/// `Vec<String>` of input names; `"[]"` = all inputs) and
|
/// `Vec<String>` of input names; `"[]"` = all inputs) and
|
||||||
/// `SchedulePrompt` (JSON-encoded `SchedulePromptPayload`). The
|
/// `SchedulePrompt` (JSON-encoded `SchedulePromptPayload`). The
|
||||||
/// frontend parses this to render a human-readable card body.
|
/// frontend parses this to render a human-readable card body.
|
||||||
/// `None` for every other kind.
|
commit_ref: String,
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
commit_ref: Option<String>,
|
|
||||||
/// RFC 3339 UTC time the approval was queued. Rendered as a
|
/// RFC 3339 UTC time the approval was queued. Rendered as a
|
||||||
/// relative time on the card so the operator can spot a stale
|
/// relative time on the card so the operator can spot a stale
|
||||||
/// request.
|
/// request.
|
||||||
|
|
@ -388,13 +376,11 @@ fn build_transient_views(
|
||||||
out
|
out
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Render each pending approval into its dashboard view (short sha for
|
|
||||||
/// `MergeConfigPr`).
|
|
||||||
/// Project a resolved sqlite row into the lean shape the dashboard
|
/// Project a resolved sqlite row into the lean shape the dashboard
|
||||||
/// history tab consumes — no `diff_html` (rendering 30 of them
|
/// history tab consumes — no `diff_html` (rendering 30 of them
|
||||||
/// per /api/state poll would mean 30 git diffs per refresh).
|
/// per /api/state poll would mean 30 git diffs per refresh).
|
||||||
fn history_view(a: Approval) -> ApprovalHistoryView {
|
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() {
|
let sha_short = if displayed.is_empty() {
|
||||||
None
|
None
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -421,54 +407,17 @@ fn history_view(a: Approval) -> ApprovalHistoryView {
|
||||||
}
|
}
|
||||||
|
|
||||||
fn build_approval_views(approvals: Vec<Approval>) -> Vec<ApprovalView> {
|
fn build_approval_views(approvals: Vec<Approval>) -> Vec<ApprovalView> {
|
||||||
let mut out = Vec::with_capacity(approvals.len());
|
approvals
|
||||||
for a in approvals {
|
.into_iter()
|
||||||
out.push(match a.kind {
|
.map(|a| ApprovalView {
|
||||||
hive_sh4re::approvals::ApprovalKind::UpdateMetaInputs => ApprovalView {
|
|
||||||
id: a.id,
|
id: a.id,
|
||||||
agent: a.agent.to_string(),
|
agent: a.agent.to_string(),
|
||||||
kind: "update_meta_inputs",
|
kind: <&str>::from(a.kind),
|
||||||
sha_short: None,
|
|
||||||
description: a.description,
|
description: a.description,
|
||||||
pr_number: None,
|
commit_ref: a.commit_ref,
|
||||||
commit_ref: Some(a.commit_ref),
|
|
||||||
requested_at: a.requested_at,
|
requested_at: a.requested_at,
|
||||||
},
|
})
|
||||||
hive_sh4re::approvals::ApprovalKind::SchedulePrompt => ApprovalView {
|
.collect()
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `/api/jobq/graph` query string — the generic jobq/wire query shape (any
|
/// `/api/jobq/graph` query string — the generic jobq/wire query shape (any
|
||||||
|
|
|
||||||
|
|
@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -52,7 +52,7 @@ pub enum DashboardEvent {
|
||||||
/// enough to render the dashboard row without a `/api/state`
|
/// enough to render the dashboard row without a `/api/state`
|
||||||
/// refetch.
|
/// refetch.
|
||||||
///
|
///
|
||||||
/// The approval's own kind (`"merge_config_pr"` / `"schedule_prompt"`) lives
|
/// The approval's own kind (`"schedule_prompt"` / `"update_meta_inputs"`) lives
|
||||||
/// on `approval_kind` rather than `kind` because the latter is taken
|
/// on `approval_kind` rather than `kind` because the latter is taken
|
||||||
/// by the serde tag identifying which `DashboardEvent` variant
|
/// by the serde tag identifying which `DashboardEvent` variant
|
||||||
/// this is.
|
/// this is.
|
||||||
|
|
@ -61,15 +61,7 @@ pub enum DashboardEvent {
|
||||||
id: i64,
|
id: i64,
|
||||||
agent: String,
|
agent: String,
|
||||||
approval_kind: &'static str,
|
approval_kind: &'static str,
|
||||||
sha_short: Option<String>,
|
|
||||||
description: Option<String>,
|
description: Option<String>,
|
||||||
/// Forge PR number, for `merge_config_pr` approvals only — lets
|
|
||||||
/// the live `applyApprovalAdded` path build the "review PR on
|
|
||||||
/// forge" link without waiting for a cold `/api/state` refresh
|
|
||||||
/// (mirrors `ApprovalView::pr_number`). `None` for every other
|
|
||||||
/// kind.
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
pr_number: Option<u64>,
|
|
||||||
},
|
},
|
||||||
/// A pending approval transitioned to a terminal state
|
/// A pending approval transitioned to a terminal state
|
||||||
/// (approved / denied / failed). Clients move the row out of the
|
/// (approved / denied / failed). Clients move the row out of the
|
||||||
|
|
@ -79,7 +71,6 @@ pub enum DashboardEvent {
|
||||||
id: i64,
|
id: i64,
|
||||||
agent: String,
|
agent: String,
|
||||||
approval_kind: &'static str,
|
approval_kind: &'static str,
|
||||||
sha_short: Option<String>,
|
|
||||||
/// `"approved"` / `"denied"` / `"failed"`.
|
/// `"approved"` / `"denied"` / `"failed"`.
|
||||||
status: &'static str,
|
status: &'static str,
|
||||||
resolved_at: DateTime<Utc>,
|
resolved_at: DateTime<Utc>,
|
||||||
|
|
@ -307,17 +298,14 @@ mod tests {
|
||||||
seq: 1,
|
seq: 1,
|
||||||
id: 1,
|
id: 1,
|
||||||
agent: "x".into(),
|
agent: "x".into(),
|
||||||
approval_kind: "merge_config_pr",
|
approval_kind: "schedule_prompt",
|
||||||
sha_short: None,
|
|
||||||
description: None,
|
description: None,
|
||||||
pr_number: None,
|
|
||||||
},
|
},
|
||||||
DashboardEvent::ApprovalResolved {
|
DashboardEvent::ApprovalResolved {
|
||||||
seq: 1,
|
seq: 1,
|
||||||
id: 1,
|
id: 1,
|
||||||
agent: "x".into(),
|
agent: "x".into(),
|
||||||
approval_kind: "merge_config_pr",
|
approval_kind: "schedule_prompt",
|
||||||
sha_short: None,
|
|
||||||
status: "approved",
|
status: "approved",
|
||||||
resolved_at: hive_sh4re::wire_time::from_secs(0),
|
resolved_at: hive_sh4re::wire_time::from_secs(0),
|
||||||
note: None,
|
note: None,
|
||||||
|
|
|
||||||
|
|
@ -128,8 +128,7 @@ fn runner_address_matches(raw: &str, configured: &str) -> bool {
|
||||||
|
|
||||||
/// Bound on reaching the forge.
|
/// Bound on reaching the forge.
|
||||||
const HTTP_CONNECT_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(5);
|
const HTTP_CONNECT_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(5);
|
||||||
/// Bound on one whole forge admin call, body included; the same budget
|
/// Bound on one whole forge admin call, body included.
|
||||||
/// `config_pr_poll` gives this forge.
|
|
||||||
const HTTP_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(15);
|
const HTTP_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(15);
|
||||||
|
|
||||||
/// A client with both forge bounds applied.
|
/// A client with both forge bounds applied.
|
||||||
|
|
|
||||||
|
|
@ -1,189 +0,0 @@
|
||||||
//! Polling fallback for the config-PR webhook.
|
|
||||||
//!
|
|
||||||
//! The webhook (`/webhook/config-pr`) is the primary path for detecting open
|
|
||||||
//! PRs on `agent-configs/*` repos and queuing `MergeConfigPr` approvals.
|
|
||||||
//! But webhooks can be missed — hive-c0re might be down when a PR is opened,
|
|
||||||
//! or Forgejo might fail a delivery.
|
|
||||||
//!
|
|
||||||
//! This module provides [`poll_open_config_prs`], called periodically from
|
|
||||||
//! `main.rs`, which scans all `agent-configs/*` repos for open PRs that have
|
|
||||||
//! no pending `MergeConfigPr` approval yet, and queues one. Idempotent: PRs
|
|
||||||
//! that already have a pending approval are skipped.
|
|
||||||
|
|
||||||
use std::sync::Arc;
|
|
||||||
|
|
||||||
use anyhow::Result;
|
|
||||||
use forgejo_api::structs::{RepoListPullRequestsQuery, RepoListPullRequestsQueryState};
|
|
||||||
|
|
||||||
use crate::coordinator::Coordinator;
|
|
||||||
use crate::forge::CONFIG_ORG;
|
|
||||||
|
|
||||||
const HTTP_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(15);
|
|
||||||
|
|
||||||
/// Scan every repo in `agent-configs` for open PRs that have no pending
|
|
||||||
/// `MergeConfigPr` approval yet, and queue one for each gap found.
|
|
||||||
///
|
|
||||||
/// Designed to be called on a periodic timer (e.g. every 5 minutes) as a
|
|
||||||
/// fault-tolerance backstop for the Forgejo webhook. The webhook fires
|
|
||||||
/// immediately; this catches anything the webhook missed.
|
|
||||||
pub async fn poll_open_config_prs(core_token: &str, coord: &Arc<Coordinator>) -> Result<()> {
|
|
||||||
let client = crate::forge::api(core_token)?;
|
|
||||||
|
|
||||||
// List all repos in agent-configs org.
|
|
||||||
let repos = tokio::time::timeout(HTTP_TIMEOUT, client.org_list_repos(CONFIG_ORG).all())
|
|
||||||
.await
|
|
||||||
.map_err(anyhow::Error::from)
|
|
||||||
.and_then(|r| r.map_err(anyhow::Error::from))?;
|
|
||||||
|
|
||||||
// (agent, pr_number) of every OPEN config PR seen this sweep, plus the set
|
|
||||||
// of agents whose PR list was fetched successfully. The reconcile pass
|
|
||||||
// below uses these to cancel pending approvals whose PR is no longer open,
|
|
||||||
// without wrongly cancelling one when a repo's list failed transiently.
|
|
||||||
let mut open_prs: std::collections::HashSet<(String, u64)> = std::collections::HashSet::new();
|
|
||||||
let mut scanned_agents: std::collections::HashSet<String> = std::collections::HashSet::new();
|
|
||||||
|
|
||||||
for repo in repos {
|
|
||||||
let Some(repo_name) = repo.name.as_deref() else {
|
|
||||||
continue;
|
|
||||||
};
|
|
||||||
// The repo name is the agent name (agent-configs/<agent>).
|
|
||||||
let agent = repo_name;
|
|
||||||
|
|
||||||
let query = RepoListPullRequestsQuery {
|
|
||||||
state: Some(RepoListPullRequestsQueryState::Open),
|
|
||||||
sort: None,
|
|
||||||
milestone: None,
|
|
||||||
labels: None,
|
|
||||||
poster: None,
|
|
||||||
base: None,
|
|
||||||
head: None,
|
|
||||||
};
|
|
||||||
|
|
||||||
let prs = match tokio::time::timeout(
|
|
||||||
HTTP_TIMEOUT,
|
|
||||||
client
|
|
||||||
.repo_list_pull_requests(CONFIG_ORG, repo_name, query)
|
|
||||||
.all(),
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
Ok(Ok(prs)) => {
|
|
||||||
scanned_agents.insert(agent.to_owned());
|
|
||||||
prs
|
|
||||||
}
|
|
||||||
Ok(Err(e)) => {
|
|
||||||
tracing::debug!(
|
|
||||||
%agent, error = %e,
|
|
||||||
"config-pr poll: listing PRs failed, skipping repo"
|
|
||||||
);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
Err(_) => {
|
|
||||||
tracing::debug!(
|
|
||||||
%agent,
|
|
||||||
"config-pr poll: timeout listing PRs, skipping repo"
|
|
||||||
);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
for pr in prs {
|
|
||||||
let Some(pr_number) = pr.number.and_then(|n| u64::try_from(n).ok()) else {
|
|
||||||
continue;
|
|
||||||
};
|
|
||||||
open_prs.insert((agent.to_owned(), pr_number));
|
|
||||||
|
|
||||||
// `submit_merge_config_pr` is idempotent + PR-drift aware: it
|
|
||||||
// no-ops when an approval pinned to this PR's *current* head is
|
|
||||||
// already pending, and cancels+re-queues when the head has drifted.
|
|
||||||
// So the poll can call it unconditionally — it backstops both a
|
|
||||||
// missed `opened` webhook (no approval yet) and a missed
|
|
||||||
// `synchronize` (stale approval whose head moved).
|
|
||||||
let description = format!("PR #{pr_number} on {CONFIG_ORG}/{agent} (poll fallback)");
|
|
||||||
if let Err(e) = crate::socket_server::submit_merge_config_pr(
|
|
||||||
coord,
|
|
||||||
agent,
|
|
||||||
pr_number,
|
|
||||||
Some(&description),
|
|
||||||
"poll", // submitter — identifies the polling path in the audit trail
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
tracing::warn!(
|
|
||||||
%agent, %pr_number, error = ?e,
|
|
||||||
"config-pr poll: failed to reconcile MergeConfigPr approval"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Reconcile: cancel pending approvals whose PR is no longer open.
|
|
||||||
reconcile_stale_config_pr_approvals(coord, &open_prs, &scanned_agents);
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Cancel pending `MergeConfigPr` approvals whose PR is no longer open — merged
|
|
||||||
/// (incl. outside the approval flow) or closed. Without this the card lingers on
|
|
||||||
/// the dashboard forever, since the webhook only signals opened PRs and the
|
|
||||||
/// add-loop only ever queues.
|
|
||||||
///
|
|
||||||
/// `open_prs` is the set of `(agent, pr_number)` seen open this sweep and
|
|
||||||
/// `scanned_agents` the agents whose PR list was fetched successfully — the
|
|
||||||
/// reconcile is guarded to those so a transient list failure can't cancel a
|
|
||||||
/// still-valid approval.
|
|
||||||
fn reconcile_stale_config_pr_approvals(
|
|
||||||
coord: &Arc<Coordinator>,
|
|
||||||
open_prs: &std::collections::HashSet<(String, u64)>,
|
|
||||||
scanned_agents: &std::collections::HashSet<String>,
|
|
||||||
) {
|
|
||||||
let pending = match coord.approvals.pending() {
|
|
||||||
Ok(p) => p,
|
|
||||||
Err(e) => {
|
|
||||||
tracing::warn!(error = ?e, "config-pr poll: pending() failed, skipping reconcile");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
for a in pending {
|
|
||||||
if a.kind != hive_sh4re::approvals::ApprovalKind::MergeConfigPr
|
|
||||||
|| !scanned_agents.contains(a.agent.as_str())
|
|
||||||
{
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
let Ok(pr_number) = a.commit_ref.parse::<u64>() else {
|
|
||||||
continue;
|
|
||||||
};
|
|
||||||
if open_prs.contains(&(a.agent.to_string(), pr_number)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
match coord
|
|
||||||
.approvals
|
|
||||||
.mark_cancelled(a.id, "config-pr poll (PR no longer open)")
|
|
||||||
{
|
|
||||||
Ok(_) => {
|
|
||||||
tracing::info!(
|
|
||||||
agent = %a.agent, pr_number, id = a.id,
|
|
||||||
"config-pr poll: cancelled stale MergeConfigPr approval (PR merged/closed)"
|
|
||||||
);
|
|
||||||
coord.emit_approval_resolved(crate::coordinator::ApprovalResolved {
|
|
||||||
id: a.id,
|
|
||||||
agent: a.agent.as_str(),
|
|
||||||
approval_kind: "merge_config_pr",
|
|
||||||
sha_short: a
|
|
||||||
.fetched_sha
|
|
||||||
.as_deref()
|
|
||||||
.map(|s| s[..s.len().min(12)].to_owned()),
|
|
||||||
status: "cancelled",
|
|
||||||
note: Some("PR merged/closed outside the approval".to_owned()),
|
|
||||||
description: a.description.clone(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
tracing::warn!(
|
|
||||||
agent = %a.agent, id = a.id, error = ?e,
|
|
||||||
"config-pr poll: failed to cancel stale MergeConfigPr approval"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -5,16 +5,12 @@
|
||||||
//! No-op when `hive-forge` isn't running. Full design: `docs/integrations/forge.md`.
|
//! No-op when `hive-forge` isn't running. Full design: `docs/integrations/forge.md`.
|
||||||
|
|
||||||
mod ci_runner;
|
mod ci_runner;
|
||||||
pub mod config_pr_poll;
|
mod pr_comment;
|
||||||
mod pr_merge;
|
|
||||||
mod reconcile;
|
mod reconcile;
|
||||||
mod repos;
|
mod repos;
|
||||||
mod users;
|
mod users;
|
||||||
|
|
||||||
pub use pr_merge::{
|
pub use pr_comment::{config_repo, merged_pr_for_commit, post_pr_comment};
|
||||||
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 reconcile::{fetch_forge_main, reconcile_config_apply, reconcile_config_status};
|
pub use reconcile::{fetch_forge_main, reconcile_config_apply, reconcile_config_status};
|
||||||
pub use repos::{
|
pub use repos::{
|
||||||
clone_config_into_proposed, ensure_config_repo, ensure_meta_remote, ensure_repo,
|
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
|
/// 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
|
/// and reads + writes every repo here. Each agent is a **write collaborator
|
||||||
/// agent is a **write collaborator on its own** `agent-configs/<name>` repo —
|
/// on its own** `agent-configs/<name>` repo — the editable PR surface it
|
||||||
/// the editable PR surface it pushes config-change branches to — but `main` is
|
/// pushes config-change branches to. `main`'s branch protection is
|
||||||
/// branch-protected core-only, so only hive-c0re's verify-and-ff-push merge
|
/// swarm-controller's: merge and approval allowlisted to the `operators` team,
|
||||||
/// handler lands on it (operator approval required; the agent can't push
|
/// so the agent can't push `main` or self-merge, and an operator's merge
|
||||||
/// `main` or self-merge). The repos remain private, so an agent still can't
|
/// deploys through swarm-controller. The repos remain private, so an agent
|
||||||
/// reach *another* agent's config. `main` is fast-forward-only — hive-c0re
|
/// still can't reach *another* agent's config. hive-c0re never force-pushes;
|
||||||
/// never force-pushes; the `push_config` mirror runs best-effort until the
|
/// the `push_config` mirror runs best-effort.
|
||||||
/// PR-merge flow retires it.
|
|
||||||
pub(crate) const CONFIG_ORG: &str = "agent-configs";
|
pub(crate) const CONFIG_ORG: &str = "agent-configs";
|
||||||
/// Forgejo org hosting the operator-curated shared docs/skills repo
|
/// Forgejo org hosting the operator-curated shared docs/skills repo
|
||||||
/// that every agent gets read-only access to. Agents use it as a
|
/// 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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
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";
|
/// The remote carries **no credential**. `argv` is world-readable through
|
||||||
|
/// `/proc/<pid>/cmdline`, so a token spliced in here would be published to
|
||||||
fn hook(url: &str) -> forgejo_api::structs::Hook {
|
/// every local user for the life of the git child.
|
||||||
serde_json::from_value(serde_json::json!({ "id": 1, "config": { "url": url } }))
|
///
|
||||||
.expect("hook json")
|
/// 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]
|
#[test]
|
||||||
fn a_hook_at_the_target_with_the_registered_secret_is_kept() {
|
fn forge_git_url_preserves_https() {
|
||||||
assert!(config_pr_hook_is_current(&[hook(TARGET)], TARGET, true));
|
// 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 credential goes in an `Authorization` header instead — decodable
|
||||||
/// the right URL is not enough: without the matching record it is
|
/// back to `core:<token>`, so the swap is genuinely equivalent auth and
|
||||||
/// replaced, or every delivery fails HMAC.
|
/// not a silent downgrade to anonymous.
|
||||||
#[test]
|
#[test]
|
||||||
fn a_hook_at_the_target_with_a_changed_secret_is_replaced() {
|
fn core_auth_header_is_basic_core_token() {
|
||||||
assert!(!config_pr_hook_is_current(&[hook(TARGET)], TARGET, false));
|
use base64::Engine as _;
|
||||||
}
|
let header = super::core_auth_header("s3cret");
|
||||||
|
let b64 = header
|
||||||
#[test]
|
.strip_prefix("Authorization: Basic ")
|
||||||
fn a_hook_on_another_base_is_not_ours() {
|
.expect("basic auth header");
|
||||||
let stale = hook("http://127.0.0.1:7000/webhook/config-pr");
|
let decoded = base64::engine::general_purpose::STANDARD
|
||||||
assert!(!config_pr_hook_is_current(&[stale], TARGET, true));
|
.decode(b64)
|
||||||
assert!(!config_pr_hook_is_current(&[], TARGET, true));
|
.expect("valid base64");
|
||||||
|
assert_eq!(String::from_utf8_lossy(&decoded), "core:s3cret");
|
||||||
|
assert!(
|
||||||
|
!header.contains("s3cret"),
|
||||||
|
"token not in the clear: {header}"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|
|
||||||
56
hive-c0re/src/forge/pr_comment.rs
Normal file
56
hive-c0re/src/forge/pr_comment.rs
Normal file
|
|
@ -0,0 +1,56 @@
|
||||||
|
//! Commenting on an agent's config PRs as the core forge user — how a hive
|
||||||
|
//! reports a merged config commit it refused to deploy back onto the PR that
|
||||||
|
//! merged it.
|
||||||
|
|
||||||
|
use anyhow::{Context, Result};
|
||||||
|
|
||||||
|
use super::{CONFIG_ORG, api, core_token};
|
||||||
|
|
||||||
|
/// Full `owner/name` path of an agent's config repo on the forge.
|
||||||
|
pub fn config_repo(agent: &str) -> String {
|
||||||
|
format!("{CONFIG_ORG}/{agent}")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The number of the merged PR that put commit `sha` on `repo`'s base branch.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
/// Absent core token, malformed repo, no such PR, or transport/API failure.
|
||||||
|
pub async fn merged_pr_for_commit(repo: &str, sha: &str) -> Result<u64> {
|
||||||
|
let token = core_token().context("forge core token absent")?;
|
||||||
|
let (owner, name) = repo
|
||||||
|
.split_once('/')
|
||||||
|
.with_context(|| format!("forge repo `{repo}` is not owner/name"))?;
|
||||||
|
let pull = api(&token)?
|
||||||
|
.repo_get_commit_pull_request(owner, name, sha)
|
||||||
|
.await
|
||||||
|
.context("GET pull request of commit")?;
|
||||||
|
pull.number
|
||||||
|
.and_then(|n| u64::try_from(n).ok())
|
||||||
|
.with_context(|| format!("pull request of {sha} in {repo} carries no number"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Post a comment to PR (= issue) `pr` on `repo` as the core forge user.
|
||||||
|
/// PRs are issues in Forgejo, so the PR number is the issue index.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
/// Absent core token, malformed repo, or transport/API failure.
|
||||||
|
pub async fn post_pr_comment(repo: &str, pr: u64, body: &str) -> Result<()> {
|
||||||
|
let token = core_token().context("forge core token absent")?;
|
||||||
|
let (owner, name) = repo
|
||||||
|
.split_once('/')
|
||||||
|
.with_context(|| format!("forge repo `{repo}` is not owner/name"))?;
|
||||||
|
let index = i64::try_from(pr).with_context(|| format!("PR index {pr} overflows i64"))?;
|
||||||
|
api(&token)?
|
||||||
|
.issue_create_comment(
|
||||||
|
owner,
|
||||||
|
name,
|
||||||
|
index,
|
||||||
|
forgejo_api::structs::CreateIssueCommentOption {
|
||||||
|
body: body.to_owned(),
|
||||||
|
updated_at: None,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.context("post PR comment")?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
@ -1,332 +0,0 @@
|
||||||
//! PR-based config-flow merge primitives — the forge-side mechanics
|
|
||||||
//! hive-c0re's deploy apply node (`actions::run_deploy_apply`) orchestrates to
|
|
||||||
//! land an operator-approved config PR. Part of the operator trust
|
|
||||||
//! boundary; moved verbatim from the `forge` module root.
|
|
||||||
|
|
||||||
use anyhow::Context;
|
|
||||||
use forgejo_api::structs::{MergePullRequestOption, MergePullRequestOptionDo, StateType};
|
|
||||||
|
|
||||||
use super::{CONFIG_ORG, api, core_auth_header, core_token, forge_git_url};
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// PR-based config-flow merge primitives (part of the
|
|
||||||
// dashboard-approve-driven config-change flow).
|
|
||||||
//
|
|
||||||
// The dashboard-approve-driven flow has hive-c0re verify an operator-approved
|
|
||||||
// config PR, then land it: fast-forward-merge the verified sha into the
|
|
||||||
// protected default branch via the forge merge API (= the merge). These fns are
|
|
||||||
// the forge-side mechanics the c0re deploy apply node (`run_deploy_apply`)
|
|
||||||
// orchestrates; the orchestration fetches the verified sha into the agent's
|
|
||||||
// applied repo (for the eval-verify) before calling `merge_config_pr_ff`. The
|
|
||||||
// core token is sourced internally (`core_token`), never passed in. `repo` is
|
|
||||||
// the agent's editable forge config repo in `owner/name` form (e.g.
|
|
||||||
// `agent-configs/<agent>`).
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/// Typed failure for the merge primitives so the c0re approve-handler can
|
|
||||||
/// `match` recoverable drift (surface a re-review message) against a hard
|
|
||||||
/// failure (fail the approval). `HeadDrift` carries the observed sha; `Other`
|
|
||||||
/// is everything else (a raced non-ff `main`, transport, API, unexpected) and
|
|
||||||
/// is not auto-retried.
|
|
||||||
#[derive(Debug)]
|
|
||||||
pub enum ForgeMergeError {
|
|
||||||
/// The PR head moved off `expected` (now at `actual`) since the handler's
|
|
||||||
/// pre-merge `pr_head_sha` re-read — caught by the `head_commit_id` pin on
|
|
||||||
/// the merge call, which refuses to merge a head that isn't the reviewed sha.
|
|
||||||
HeadDrift { expected: String, actual: String },
|
|
||||||
/// Transport / API / unexpected failure — hard-fail, no auto-retry. Covers
|
|
||||||
/// a raced non-fast-forwardable `main` (the `fast-forward-only` merge is
|
|
||||||
/// refused by the forge) as well.
|
|
||||||
Other(anyhow::Error),
|
|
||||||
}
|
|
||||||
|
|
||||||
impl std::fmt::Display for ForgeMergeError {
|
|
||||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
||||||
match self {
|
|
||||||
Self::HeadDrift { expected, actual } => {
|
|
||||||
write!(f, "PR head drifted: expected {expected}, found {actual}")
|
|
||||||
}
|
|
||||||
Self::Other(e) => write!(f, "{e}"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl std::error::Error for ForgeMergeError {}
|
|
||||||
|
|
||||||
impl From<anyhow::Error> for ForgeMergeError {
|
|
||||||
fn from(e: anyhow::Error) -> Self {
|
|
||||||
Self::Other(e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Agent name from an `owner/name` forge repo string (the trailing segment).
|
|
||||||
fn repo_agent_name(repo: &str) -> &str {
|
|
||||||
repo.rsplit('/').next().unwrap_or(repo)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Resolve a PR's head sha via `git ls-remote <repo> refs/pull/<pr>/head`
|
|
||||||
/// (Forgejo exposes PR heads there). Pure read, no mutation — the handler's
|
|
||||||
/// primary drift gate (compare against the approved sha), and
|
|
||||||
/// `merge_config_pr_ff` uses it to classify a merge-API rejection as head-drift.
|
|
||||||
///
|
|
||||||
/// # Errors
|
|
||||||
/// `Other` on transport failure or an empty/missing ref.
|
|
||||||
pub async fn pr_head_sha(repo: &str, pr: u64) -> Result<String, ForgeMergeError> {
|
|
||||||
let token = core_token()
|
|
||||||
.ok_or_else(|| ForgeMergeError::Other(anyhow::anyhow!("forge core token absent")))?;
|
|
||||||
let url = forge_git_url(repo);
|
|
||||||
let refspec = format!("refs/pull/{pr}/head");
|
|
||||||
let out = crate::lifecycle::git_command_authed(&core_auth_header(&token))
|
|
||||||
.args(["ls-remote", &url, &refspec])
|
|
||||||
.output()
|
|
||||||
.await
|
|
||||||
.context("git ls-remote PR head")?;
|
|
||||||
if !out.status.success() {
|
|
||||||
return Err(ForgeMergeError::Other(anyhow::anyhow!(
|
|
||||||
"git ls-remote {repo} {refspec} failed ({}): {}",
|
|
||||||
out.status,
|
|
||||||
String::from_utf8_lossy(&out.stderr).trim()
|
|
||||||
)));
|
|
||||||
}
|
|
||||||
let stdout = String::from_utf8_lossy(&out.stdout);
|
|
||||||
let sha = stdout
|
|
||||||
.split_whitespace()
|
|
||||||
.next()
|
|
||||||
.filter(|s| !s.is_empty())
|
|
||||||
.ok_or_else(|| {
|
|
||||||
ForgeMergeError::Other(anyhow::anyhow!(
|
|
||||||
"no head ref for PR #{pr} in {repo} (ls-remote empty)"
|
|
||||||
))
|
|
||||||
})?;
|
|
||||||
Ok(sha.to_string())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Check whether PR `pr` on `repo` is still open. Returns `Ok(true)` if
|
|
||||||
/// open, `Ok(false)` if closed or merged, or an error on transport failure.
|
|
||||||
///
|
|
||||||
/// Called at submission time to give an early, actionable error rather than
|
|
||||||
/// queuing an approval card that will fail later in the approve handler.
|
|
||||||
///
|
|
||||||
/// # Errors
|
|
||||||
/// `Other` on transport failure or a missing/malformed PR response.
|
|
||||||
pub async fn pr_is_open(repo: &str, pr: u64) -> Result<bool, ForgeMergeError> {
|
|
||||||
let token = core_token()
|
|
||||||
.ok_or_else(|| ForgeMergeError::Other(anyhow::anyhow!("forge core token absent")))?;
|
|
||||||
let (owner, name) = repo.split_once('/').ok_or_else(|| {
|
|
||||||
ForgeMergeError::Other(anyhow::anyhow!("forge repo `{repo}` is not owner/name"))
|
|
||||||
})?;
|
|
||||||
let index = i64::try_from(pr)
|
|
||||||
.map_err(|_| ForgeMergeError::Other(anyhow::anyhow!("PR index {pr} overflows i64")))?;
|
|
||||||
let client = api(&token).map_err(ForgeMergeError::Other)?;
|
|
||||||
let pull = client
|
|
||||||
.repo_get_pull_request(owner, name, index)
|
|
||||||
.await
|
|
||||||
.map_err(|e| ForgeMergeError::Other(anyhow::Error::from(e).context("GET pull request")))?;
|
|
||||||
Ok(pull.state == Some(StateType::Open))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Full `owner/name` path of an agent's config repo on the forge — the
|
|
||||||
/// `agent-configs` org mirror that the PR-merge flow reads + fast-forwards.
|
|
||||||
pub fn config_repo(agent: &str) -> String {
|
|
||||||
format!("{CONFIG_ORG}/{agent}")
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Fetch PR #`pr`'s head into the agent's applied repo via
|
|
||||||
/// `refs/pull/<pr>/head` (which Forgejo always serves — a bare-sha fetch can
|
|
||||||
/// be refused by uploadpack policy). This makes the reviewed head an object
|
|
||||||
/// in the applied repo so the `git_update_ref(main, …)` in the deploy tail and
|
|
||||||
/// the eval-verify both resolve it locally before the irreversible merge.
|
|
||||||
///
|
|
||||||
/// # Errors
|
|
||||||
/// `Other` on transport failure or a non-zero git exit.
|
|
||||||
pub async fn fetch_pr_head_into_applied(repo: &str, pr: u64) -> Result<(), ForgeMergeError> {
|
|
||||||
let token = core_token()
|
|
||||||
.ok_or_else(|| ForgeMergeError::Other(anyhow::anyhow!("forge core token absent")))?;
|
|
||||||
let url = forge_git_url(repo);
|
|
||||||
let applied = crate::paths::applied_dir(repo_agent_name(repo));
|
|
||||||
let refspec = format!("refs/pull/{pr}/head");
|
|
||||||
let out = crate::lifecycle::git_command_authed(&core_auth_header(&token))
|
|
||||||
.current_dir(&applied)
|
|
||||||
.args(["fetch", "--no-tags", &url, &refspec])
|
|
||||||
.output()
|
|
||||||
.await
|
|
||||||
.context("git fetch PR head into applied")?;
|
|
||||||
if !out.status.success() {
|
|
||||||
return Err(ForgeMergeError::Other(anyhow::anyhow!(
|
|
||||||
"git fetch {repo} {refspec} into applied failed ({}): {}",
|
|
||||||
out.status,
|
|
||||||
String::from_utf8_lossy(&out.stderr).trim()
|
|
||||||
)));
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Fast-forward-merge PR `pr` on `repo` to `sha` via the Forgejo merge API —
|
|
||||||
/// THE merge in the config-PR flow. Uses `Do=fast-forward-only` so `main` only
|
|
||||||
/// ever advances by fast-forward (never a merge commit; a raced non-ff `main`
|
|
||||||
/// is refused by the forge → `Other`), and pins `head_commit_id = sha` so the
|
|
||||||
/// forge atomically refuses to merge a head that isn't the reviewed sha —
|
|
||||||
/// closing the check-then-merge race without ever pushing the protected branch.
|
|
||||||
/// `core` needs only the repo's merge whitelist, never push access to `main`.
|
|
||||||
/// The single call fast-forwards `main` to exactly `sha` and marks the PR
|
|
||||||
/// merged.
|
|
||||||
///
|
|
||||||
/// # Errors
|
|
||||||
/// `HeadDrift` if the live PR head no longer matches `sha` (the pin rejected
|
|
||||||
/// it); `Other` for a raced non-ff `main`, transport, or API failure.
|
|
||||||
pub async fn merge_config_pr_ff(repo: &str, pr: u64, sha: &str) -> Result<(), ForgeMergeError> {
|
|
||||||
let token = core_token()
|
|
||||||
.ok_or_else(|| ForgeMergeError::Other(anyhow::anyhow!("forge core token absent")))?;
|
|
||||||
let (owner, name) = repo.split_once('/').ok_or_else(|| {
|
|
||||||
ForgeMergeError::Other(anyhow::anyhow!("forge repo `{repo}` is not owner/name"))
|
|
||||||
})?;
|
|
||||||
let index = i64::try_from(pr)
|
|
||||||
.map_err(|_| ForgeMergeError::Other(anyhow::anyhow!("PR index {pr} overflows i64")))?;
|
|
||||||
let body = MergePullRequestOption {
|
|
||||||
r#do: MergePullRequestOptionDo::FastForwardOnly,
|
|
||||||
merge_commit_id: None,
|
|
||||||
merge_message_field: None,
|
|
||||||
merge_title_field: None,
|
|
||||||
delete_branch_after_merge: None,
|
|
||||||
force_merge: None,
|
|
||||||
head_commit_id: Some(sha.to_owned()),
|
|
||||||
merge_when_checks_succeed: None,
|
|
||||||
};
|
|
||||||
let client = api(&token).map_err(ForgeMergeError::Other)?;
|
|
||||||
match client
|
|
||||||
.repo_merge_pull_request(owner, name, index, body)
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
Ok(()) => Ok(()),
|
|
||||||
Err(e) => {
|
|
||||||
// Classify: a live head that no longer matches `sha` is drift (the
|
|
||||||
// `head_commit_id` pin rejected it); everything else — a raced
|
|
||||||
// non-ff `main`, transport, an API error — is a hard failure.
|
|
||||||
// Best-effort, since the handler's pre-merge re-read is the primary
|
|
||||||
// gate; a transport failure that also breaks this read falls through
|
|
||||||
// to `Other`.
|
|
||||||
match pr_head_sha(repo, pr).await {
|
|
||||||
Ok(actual) if actual != sha => Err(ForgeMergeError::HeadDrift {
|
|
||||||
expected: sha.to_string(),
|
|
||||||
actual,
|
|
||||||
}),
|
|
||||||
_ => Err(ForgeMergeError::Other(
|
|
||||||
anyhow::Error::from(e).context(format!("ff-merge PR #{pr} in {repo} to {sha}")),
|
|
||||||
)),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The number of the merged PR that put commit `sha` on `repo`'s base branch.
|
|
||||||
///
|
|
||||||
/// # Errors
|
|
||||||
/// `Other` on absent core token, malformed repo, no such PR, or
|
|
||||||
/// transport/API failure.
|
|
||||||
pub async fn merged_pr_for_commit(repo: &str, sha: &str) -> Result<u64, ForgeMergeError> {
|
|
||||||
let token = core_token()
|
|
||||||
.ok_or_else(|| ForgeMergeError::Other(anyhow::anyhow!("forge core token absent")))?;
|
|
||||||
let (owner, name) = repo.split_once('/').ok_or_else(|| {
|
|
||||||
ForgeMergeError::Other(anyhow::anyhow!("forge repo `{repo}` is not owner/name"))
|
|
||||||
})?;
|
|
||||||
let client = api(&token).map_err(ForgeMergeError::Other)?;
|
|
||||||
let pull = client
|
|
||||||
.repo_get_commit_pull_request(owner, name, sha)
|
|
||||||
.await
|
|
||||||
.map_err(|e| {
|
|
||||||
ForgeMergeError::Other(anyhow::Error::from(e).context("GET pull request of commit"))
|
|
||||||
})?;
|
|
||||||
pull.number
|
|
||||||
.and_then(|n| u64::try_from(n).ok())
|
|
||||||
.ok_or_else(|| {
|
|
||||||
ForgeMergeError::Other(anyhow::anyhow!(
|
|
||||||
"pull request of {sha} in {repo} carries no number"
|
|
||||||
))
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Post a comment to PR (= issue) `pr` on `repo` as the core forge user.
|
|
||||||
/// PRs are issues in Forgejo, so the PR number is the issue index. Used to
|
|
||||||
/// surface a failed config-approval deploy's build log back onto the PR so
|
|
||||||
/// the manager sees why it was rejected without leaving the forge. `repo`
|
|
||||||
/// is `owner/name`.
|
|
||||||
///
|
|
||||||
/// # Errors
|
|
||||||
/// `Other` on absent core token, malformed repo, or transport/API failure.
|
|
||||||
pub async fn post_pr_comment(repo: &str, pr: u64, body: &str) -> Result<(), ForgeMergeError> {
|
|
||||||
let token = core_token()
|
|
||||||
.ok_or_else(|| ForgeMergeError::Other(anyhow::anyhow!("forge core token absent")))?;
|
|
||||||
let (owner, name) = repo.split_once('/').ok_or_else(|| {
|
|
||||||
ForgeMergeError::Other(anyhow::anyhow!("forge repo `{repo}` is not owner/name"))
|
|
||||||
})?;
|
|
||||||
let index = i64::try_from(pr)
|
|
||||||
.map_err(|_| ForgeMergeError::Other(anyhow::anyhow!("PR index {pr} overflows i64")))?;
|
|
||||||
let client = api(&token).map_err(ForgeMergeError::Other)?;
|
|
||||||
client
|
|
||||||
.issue_create_comment(
|
|
||||||
owner,
|
|
||||||
name,
|
|
||||||
index,
|
|
||||||
forgejo_api::structs::CreateIssueCommentOption {
|
|
||||||
body: body.to_owned(),
|
|
||||||
updated_at: None,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.map_err(|e| ForgeMergeError::Other(anyhow::Error::from(e).context("post PR comment")))?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::repo_agent_name;
|
|
||||||
use crate::forge::git_url_with_base;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn repo_agent_name_takes_trailing_segment() {
|
|
||||||
assert_eq!(repo_agent_name("agent-configs/atlas"), "atlas");
|
|
||||||
assert_eq!(repo_agent_name("atlas"), "atlas");
|
|
||||||
assert_eq!(repo_agent_name("a/b/c"), "c");
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 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 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}");
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 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 core_auth_header_is_basic_core_token() {
|
|
||||||
use base64::Engine as _;
|
|
||||||
let header = crate::forge::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}"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -121,24 +121,13 @@ pub(super) async fn run_node(
|
||||||
// takes it directly instead of re-matching the kind behind a `bail!`
|
// takes it directly instead of re-matching the kind behind a `bail!`
|
||||||
// that could never fire.
|
// that could never fire.
|
||||||
NodeKind::WritePermFile { payload, .. } => run_write_perm_file(coord, agent, payload).await,
|
NodeKind::WritePermFile { payload, .. } => run_write_perm_file(coord, agent, payload).await,
|
||||||
NodeKind::MergeVerify { approval_id, .. } => {
|
|
||||||
run_merge_verify(coord, *approval_id, id).await
|
|
||||||
}
|
|
||||||
NodeKind::DeployApply { approval_id, .. } => {
|
|
||||||
run_deploy_apply(coord, *approval_id, id).await.map(|()| {
|
|
||||||
super::templates::deploy_rebuild_nodes(&builder, agent, *approval_id);
|
|
||||||
})
|
|
||||||
}
|
|
||||||
NodeKind::FinalizeDeploy { approval_id, .. } => {
|
|
||||||
run_finalize_deploy(coord, *approval_id).await
|
|
||||||
}
|
|
||||||
NodeKind::DeployTail { approval_id, .. } => {
|
|
||||||
run_deploy_tail(coord, coord.job_queue.root_of(id), agent, *approval_id).await
|
|
||||||
}
|
|
||||||
NodeKind::ResolveApproval {
|
NodeKind::ResolveApproval {
|
||||||
approval_id,
|
approval_id,
|
||||||
outcome,
|
outcome,
|
||||||
} => run_resolve_approval(coord, coord.job_queue.root_of(id), *approval_id, *outcome).await,
|
} => {
|
||||||
|
run_resolve_approval(coord, coord.job_queue.root_of(id), *approval_id, *outcome);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
NodeKind::EmitRebuilt { ok, .. } => {
|
NodeKind::EmitRebuilt { ok, .. } => {
|
||||||
run_emit_rebuilt(coord, agent, coord.job_queue.root_of(id), *ok).await;
|
run_emit_rebuilt(coord, agent, coord.job_queue.root_of(id), *ok).await;
|
||||||
Ok(())
|
Ok(())
|
||||||
|
|
@ -147,10 +136,9 @@ pub(super) async fn run_node(
|
||||||
// Braces carry no work of their own; completing one lets it reach
|
// Braces carry no work of their own; completing one lets it reach
|
||||||
// `Finishing` so the nodes under it start. What they declare stays held
|
// `Finishing` so the nodes under it start. What they declare stays held
|
||||||
// until their whole subtree settles.
|
// until their whole subtree settles.
|
||||||
NodeKind::DeployWindow { .. } | NodeKind::AgentWindow { .. } => Ok(()),
|
NodeKind::AgentWindow { .. } => Ok(()),
|
||||||
NodeKind::ForgeSweep => run_forge_sweep().await,
|
NodeKind::ForgeSweep => run_forge_sweep().await,
|
||||||
NodeKind::MatrixSweep => run_matrix_sweep().await,
|
NodeKind::MatrixSweep => run_matrix_sweep().await,
|
||||||
NodeKind::WebhookRegister => run_webhook_register().await,
|
|
||||||
NodeKind::KnowledgePull => run_knowledge_pull(coord).await,
|
NodeKind::KnowledgePull => run_knowledge_pull(coord).await,
|
||||||
NodeKind::WantedPull => run_wanted_pull(coord).await,
|
NodeKind::WantedPull => run_wanted_pull(coord).await,
|
||||||
};
|
};
|
||||||
|
|
@ -218,34 +206,6 @@ fn matrix_sweep_banner(ctx: sweep_health::SweepFailure) -> String {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Boot-time Forgejo webhook management as a DAG node — see
|
|
||||||
/// [`NodeKind::WebhookRegister`]. Mirrors the guard chain the
|
|
||||||
/// `tokio::spawn` block it replaced used: no-op (not an error) when the
|
|
||||||
/// HMAC secret, core token, or hive domain aren't available yet.
|
|
||||||
///
|
|
||||||
/// Registers the config-PR hook only. The knowledge hook is the swarm
|
|
||||||
/// controller's, which addresses an event to each hive over the queue.
|
|
||||||
async fn run_webhook_register() -> Result<()> {
|
|
||||||
let Ok(webhook_secret) = crate::webhook_secret::load_or_generate() else {
|
|
||||||
tracing::debug!("webhook secret unavailable; skipping hook registration");
|
|
||||||
return Ok(());
|
|
||||||
};
|
|
||||||
let Some(token) = crate::forge::core_token() else {
|
|
||||||
return Ok(());
|
|
||||||
};
|
|
||||||
let domain = std::env::var("HYPERHIVE_HIVE_DOMAIN")
|
|
||||||
.ok()
|
|
||||||
.filter(|v| !v.is_empty());
|
|
||||||
let Some(domain) = domain else {
|
|
||||||
tracing::debug!("HYPERHIVE_HIVE_DOMAIN unset; skipping webhook registration");
|
|
||||||
return Ok(());
|
|
||||||
};
|
|
||||||
if let Err(e) = crate::forge::ensure_config_pr_webhook(&token, &domain, &webhook_secret).await {
|
|
||||||
tracing::warn!(error = ?e, "forge: ensure_config_pr_webhook failed");
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// `/knowledge` pull as a DAG node — see [`NodeKind::KnowledgePull`]. Every
|
/// `/knowledge` pull as a DAG node — see [`NodeKind::KnowledgePull`]. Every
|
||||||
/// pass reports here, whoever submitted it: as the node's own error on the
|
/// pass reports here, whoever submitted it: as the node's own error on the
|
||||||
/// dashboard, and into the debounced banner.
|
/// dashboard, and into the debounced banner.
|
||||||
|
|
@ -313,17 +273,16 @@ async fn run_wanted_pull(coord: &Arc<Coordinator>) -> Result<()> {
|
||||||
/// answer. Best-effort — a resolution failure is logged inside
|
/// answer. Best-effort — a resolution failure is logged inside
|
||||||
/// [`crate::actions::resolve_approval_dag`], never surfaced as a node failure,
|
/// [`crate::actions::resolve_approval_dag`], never surfaced as a node failure,
|
||||||
/// since the work already happened and failing the tail would only misreport it.
|
/// since the work already happened and failing the tail would only misreport it.
|
||||||
async fn run_resolve_approval(
|
fn run_resolve_approval(
|
||||||
coord: &Arc<Coordinator>,
|
coord: &Arc<Coordinator>,
|
||||||
dag_id: Option<u64>,
|
dag_id: Option<u64>,
|
||||||
approval_id: i64,
|
approval_id: i64,
|
||||||
outcome: TerminalState,
|
outcome: TerminalState,
|
||||||
) -> Result<()> {
|
) {
|
||||||
let reason = (outcome == TerminalState::Failed)
|
let reason = (outcome == TerminalState::Failed)
|
||||||
.then(|| dag_id.and_then(|dag| coord.job_queue.first_error(dag)))
|
.then(|| dag_id.and_then(|dag| coord.job_queue.first_error(dag)))
|
||||||
.flatten();
|
.flatten();
|
||||||
crate::actions::resolve_approval_dag(coord, approval_id, outcome, reason.as_deref()).await;
|
crate::actions::resolve_approval_dag(coord, approval_id, outcome, reason.as_deref());
|
||||||
Ok(())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Rerender the meta flake from whatever containers still exist on disk.
|
/// Rerender the meta flake from whatever containers still exist on disk.
|
||||||
|
|
@ -629,9 +588,8 @@ async fn run_meta_lock(
|
||||||
// against whatever `applied/<name>` already happened to be locked to —
|
// against whatever `applied/<name>` already happened to be locked to —
|
||||||
// normally current, but silently stale forever if a past deploy failed
|
// normally current, but silently stale forever if a past deploy failed
|
||||||
// and nothing since retried it. Relocks against each agent's *declared*
|
// and nothing since retried it. Relocks against each agent's *declared*
|
||||||
// input (the forge URL, not the local `applied/<name>` mirror
|
// input (the forge URL, not the local `applied/<name>` mirror a rebuild
|
||||||
// `prepare_deploy` uses for a reviewed deploy) — this path has no PR to
|
// relocks against) — this path has nothing to gate. One combined call, not per-agent:
|
||||||
// review, so there's nothing to gate. One combined call, not per-agent:
|
|
||||||
// simpler, at the cost of one unreachable/broken agent repo failing the
|
// simpler, at the cost of one unreachable/broken agent repo failing the
|
||||||
// whole cascade relock rather than just that agent.
|
// whole cascade relock rather than just that agent.
|
||||||
if !cascade.is_empty() {
|
if !cascade.is_empty() {
|
||||||
|
|
@ -890,50 +848,6 @@ async fn run_write_perm_file(
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Deploy phase 1 — drift gate, fetch, eval-verify. Mutates nothing, so a
|
|
||||||
/// failure here cancel-cascades the rest of the subtree with the forge and the
|
|
||||||
/// applied repo exactly as they were.
|
|
||||||
async fn run_merge_verify(coord: &Arc<Coordinator>, approval_id: i64, id: NodeId) -> Result<()> {
|
|
||||||
crate::actions::run_deploy_merge_verify(coord, approval_id, Some(id.get())).await
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Deploy phase 2 — the irreversible half: ff-merge, then phase 1 of the
|
|
||||||
/// two-phase meta deploy.
|
|
||||||
///
|
|
||||||
/// On success it grows the ordinary rebuild subgraph (plus its closing
|
|
||||||
/// `FinalizeDeploy`) into this DAG rooted on *this* node — which is what puts
|
|
||||||
/// the appended nodes inside the `DeployWindow`'s subtree, so the `MetaWindow`
|
|
||||||
/// their `MetaSync` declares is re-entered rather than deadlocked against the
|
|
||||||
/// ancestor already holding it. On failure nothing is appended and the tail
|
|
||||||
/// compensates, exactly as before.
|
|
||||||
async fn run_deploy_apply(coord: &Arc<Coordinator>, approval_id: i64, id: NodeId) -> Result<()> {
|
|
||||||
crate::actions::run_deploy_apply(coord, approval_id, Some(id.get())).await
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Deploy phase 3 — close the staged-lock window once the appended rebuild has
|
|
||||||
/// come up clean: drop the rollback ref, plant the `deployed/<id>` tag, commit
|
|
||||||
/// the staged lock.
|
|
||||||
async fn run_finalize_deploy(coord: &Arc<Coordinator>, approval_id: i64) -> Result<()> {
|
|
||||||
crate::actions::run_finalize_deploy(coord, approval_id).await
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Deploy compensation + bookkeeping tail. `AfterAny` the apply node, so it
|
|
||||||
/// runs on every outcome; it is deliberately infallible (see
|
|
||||||
/// [`crate::actions::run_deploy_tail`]) — a failing tail must not flip an
|
|
||||||
/// otherwise-successful deploy's DAG state.
|
|
||||||
///
|
|
||||||
/// Takes the agent from the node payload so the tail can still compensate when
|
|
||||||
/// the approval row is gone (deny race, purge).
|
|
||||||
async fn run_deploy_tail(
|
|
||||||
coord: &Arc<Coordinator>,
|
|
||||||
dag_id: Option<u64>,
|
|
||||||
agent: &str,
|
|
||||||
approval_id: i64,
|
|
||||||
) -> Result<()> {
|
|
||||||
crate::actions::run_deploy_tail(coord, dag_id, agent, approval_id).await;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Compute which agents a `nix flake update <inputs>` on the meta
|
/// Compute which agents a `nix flake update <inputs>` on the meta
|
||||||
/// flake affects — the fan-out set for `MetaUpdate` DAGs. Empty
|
/// flake affects — the fan-out set for `MetaUpdate` DAGs. Empty
|
||||||
/// `inputs` or any input under `hyperhive` → every container;
|
/// `inputs` or any input under `hyperhive` → every container;
|
||||||
|
|
|
||||||
|
|
@ -102,28 +102,11 @@ pub enum NodeKind {
|
||||||
WriteDropin { agent: String },
|
WriteDropin { agent: String },
|
||||||
/// Commit `tool-groups.json` / `capabilities.json` per its `payload`.
|
/// Commit `tool-groups.json` / `capabilities.json` per its `payload`.
|
||||||
WritePermFile { agent: String, payload: PermPayload },
|
WritePermFile { agent: String, payload: PermPayload },
|
||||||
/// Group root of the approval-deploy (`MergeConfigPr`) subtree — the
|
|
||||||
/// **brace** that owns the deploy window. See _Braces_ and _Approvals_.
|
|
||||||
DeployWindow { agent: String, approval_id: i64 },
|
|
||||||
/// Group root of a rebuild subtree — the **brace**, scoped to one
|
/// Group root of a rebuild subtree — the **brace**, scoped to one
|
||||||
/// agent. Why braces exist and what they cost: _Braces_.
|
/// agent. Why braces exist and what they cost: _Braces_.
|
||||||
AgentWindow { agent: String },
|
AgentWindow { agent: String },
|
||||||
/// Deploy phase 1 — **verify only, mutates nothing.**
|
/// Tail node of an approval-carrying DAG (a meta-inputs update): resolve
|
||||||
MergeVerify { agent: String, approval_id: i64 },
|
/// the approval row from how the work ended.
|
||||||
/// Deploy phase 2 — the irreversible fast-forward plus the *opening*
|
|
||||||
/// half of the two-phase meta deploy. Does **not** build the container
|
|
||||||
/// itself; grows the ordinary rebuild subgraph in as its own children
|
|
||||||
/// (see the rebuild-path section's approval-deploy paragraph).
|
|
||||||
DeployApply { agent: String, approval_id: i64 },
|
|
||||||
/// Deploy phase 3 — closes the two-phase meta deploy once
|
|
||||||
/// [`NodeKind::DeployApply`]'s rebuild subgraph comes up clean.
|
|
||||||
FinalizeDeploy { agent: String, approval_id: i64 },
|
|
||||||
/// Deploy compensation **and bookkeeping** tail. See the `DeployTail`
|
|
||||||
/// node-inventory row for its three responsibilities and why it isn't
|
|
||||||
/// named `AbortDeploy`.
|
|
||||||
DeployTail { agent: String, approval_id: i64 },
|
|
||||||
/// Tail node of an approval-carrying DAG (opaque deploy /
|
|
||||||
/// config-PR merge): resolve the approval row from how the work ended.
|
|
||||||
ResolveApproval {
|
ResolveApproval {
|
||||||
approval_id: i64,
|
approval_id: i64,
|
||||||
/// Which outcome this node reports. A template emits **one per
|
/// Which outcome this node reports. A template emits **one per
|
||||||
|
|
@ -147,8 +130,6 @@ pub enum NodeKind {
|
||||||
/// Matrix user/space sweep (`matrix::ensure_all`): at boot and every 30
|
/// Matrix user/space sweep (`matrix::ensure_all`): at boot and every 30
|
||||||
/// minutes. Agentless.
|
/// minutes. Agentless.
|
||||||
MatrixSweep,
|
MatrixSweep,
|
||||||
/// One-shot boot-time Forgejo webhook registration. Agentless.
|
|
||||||
WebhookRegister,
|
|
||||||
/// `/knowledge` pull (`knowledge::pull`): at boot, on the swarm
|
/// `/knowledge` pull (`knowledge::pull`): at boot, on the swarm
|
||||||
/// knowledge-changed event, and hourly. Agentless.
|
/// knowledge-changed event, and hourly. Agentless.
|
||||||
KnowledgePull,
|
KnowledgePull,
|
||||||
|
|
@ -174,9 +155,6 @@ impl hive_jobq_wire::WireNode for NodeKind {
|
||||||
if !agent.is_empty() {
|
if !agent.is_empty() {
|
||||||
data.insert("agent".to_owned(), agent.into());
|
data.insert("agent".to_owned(), agent.into());
|
||||||
}
|
}
|
||||||
if let NodeKind::DeployWindow { approval_id, .. } = self {
|
|
||||||
data.insert("approval_id".to_owned(), (*approval_id).into());
|
|
||||||
}
|
|
||||||
if let NodeKind::MetaLock { inputs, .. } = self
|
if let NodeKind::MetaLock { inputs, .. } = self
|
||||||
&& !inputs.is_empty()
|
&& !inputs.is_empty()
|
||||||
{
|
{
|
||||||
|
|
@ -221,19 +199,13 @@ impl NodeKind {
|
||||||
| NodeKind::PauseDrain { agent }
|
| NodeKind::PauseDrain { agent }
|
||||||
| NodeKind::WriteDropin { agent }
|
| NodeKind::WriteDropin { agent }
|
||||||
| NodeKind::WritePermFile { agent, .. }
|
| NodeKind::WritePermFile { agent, .. }
|
||||||
| NodeKind::DeployWindow { agent, .. }
|
|
||||||
| NodeKind::AgentWindow { agent }
|
| NodeKind::AgentWindow { agent }
|
||||||
| NodeKind::MergeVerify { agent, .. }
|
|
||||||
| NodeKind::DeployApply { agent, .. }
|
|
||||||
| NodeKind::FinalizeDeploy { agent, .. }
|
|
||||||
| NodeKind::DeployTail { agent, .. }
|
|
||||||
| NodeKind::EmitRebuilt { agent, .. }
|
| NodeKind::EmitRebuilt { agent, .. }
|
||||||
| NodeKind::SetWanted { agent, .. } => agent,
|
| NodeKind::SetWanted { agent, .. } => agent,
|
||||||
NodeKind::MetaLock { .. }
|
NodeKind::MetaLock { .. }
|
||||||
| NodeKind::ResolveApproval { .. }
|
| NodeKind::ResolveApproval { .. }
|
||||||
| NodeKind::ForgeSweep
|
| NodeKind::ForgeSweep
|
||||||
| NodeKind::MatrixSweep
|
| NodeKind::MatrixSweep
|
||||||
| NodeKind::WebhookRegister
|
|
||||||
| NodeKind::KnowledgePull
|
| NodeKind::KnowledgePull
|
||||||
| NodeKind::WantedPull => "",
|
| NodeKind::WantedPull => "",
|
||||||
}
|
}
|
||||||
|
|
@ -280,8 +252,8 @@ impl NodeKind {
|
||||||
// bug, and a `true` here would suppress the alert that says so.
|
// bug, and a `true` here would suppress the alert that says so.
|
||||||
// - `Reconcile` is a planner; it fans out `Start` / `Stop`, which carry
|
// - `Reconcile` is a planner; it fans out `Start` / `Stop`, which carry
|
||||||
// their own answer.
|
// their own answer.
|
||||||
// - `DeployWindow` brackets a deploy without itself stopping anything.
|
// - `AgentWindow` brackets a rebuild without itself stopping anything,
|
||||||
// - `AgentWindow` likewise, though it *parents* nodes that answer
|
// though it *parents* nodes that answer
|
||||||
// `true`. This is per-node, not per-subtree, and each of those
|
// `true`. This is per-node, not per-subtree, and each of those
|
||||||
// children reports for itself — so `true` here would only widen
|
// children reports for itself — so `true` here would only widen
|
||||||
// suppression over the build and tail, where a vanished container is
|
// suppression over the build and tail, where a vanished container is
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@
|
||||||
//! that already held the resource could get away with declaring nothing.
|
//! that already held the resource could get away with declaring nothing.
|
||||||
//!
|
//!
|
||||||
//! **The one sanctioned exception is a brace** — a pure-resource-holder root
|
//! **The one sanctioned exception is a brace** — a pure-resource-holder root
|
||||||
//! ([`NodeKind::AgentWindow`], [`NodeKind::DeployWindow`]) declaring for a
|
//! ([`NodeKind::AgentWindow`]) declaring for a
|
||||||
//! coordinated subtree whose members then declare nothing. See
|
//! coordinated subtree whose members then declare nothing. See
|
||||||
//! `docs/scheduler/coordinator.md`, _Braces_ — which also carries the per-operation DAG
|
//! `docs/scheduler/coordinator.md`, _Braces_ — which also carries the per-operation DAG
|
||||||
//! shapes, so they are not restated here.
|
//! shapes, so they are not restated here.
|
||||||
|
|
@ -314,42 +314,6 @@ pub(crate) fn graceful_rebuild_nodes<'a>(
|
||||||
rebuild_subtree(builder, agent, relock, true, after)
|
rebuild_subtree(builder, agent, relock, true, after)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The rebuild subgraph a [`NodeKind::DeployApply`] grows into its own DAG once
|
|
||||||
/// the merge has landed and `prepare_deploy` has staged the lock, plus the
|
|
||||||
/// [`NodeKind::FinalizeDeploy`] that closes the window behind it.
|
|
||||||
///
|
|
||||||
/// `relock = false` is the whole reason this composes: `prepare_deploy` already
|
|
||||||
/// relocked and staged `flake.lock`, so the appended `MetaSync` must do the dir
|
|
||||||
/// prep + `sync_agents` *without* re-locking over it.
|
|
||||||
///
|
|
||||||
/// `FinalizeDeploy` waits on **two** roots, which together reproduce the gate
|
|
||||||
/// the old fused node had around its inline `rebuild_no_meta` call:
|
|
||||||
/// - `AfterOk` `Prebuild` — a parent's state is its roll-up, so this is `Done`
|
|
||||||
/// only once `StopForUpdate` → `Swap` → `RebuildBookkeeping` all are (a failed *or*
|
|
||||||
/// cancelled child rolls the parent up `Failed`). That's the old
|
|
||||||
/// `build_result`.
|
|
||||||
/// - `AfterOk` `Reconcile` — the old call passed `deferred_start = false` on
|
|
||||||
/// purpose: the container had to come back up *before* the deploy was
|
|
||||||
/// finalized. `Reconcile` alone would not do, being `AfterAny` — it reaches
|
|
||||||
/// `Done` even after a failed `Swap`.
|
|
||||||
///
|
|
||||||
/// Declared into a **running** `DeployApply`'s own builder, not submitted: the
|
|
||||||
/// roots below become children of that node, which puts them inside the
|
|
||||||
/// `DeployWindow`'s subtree — so the `MetaWindow` this subgraph's `MetaSync`
|
|
||||||
/// and `FinalizeDeploy` declare is re-entered from the ancestor already holding
|
|
||||||
/// it rather than deadlocking against it.
|
|
||||||
pub(crate) fn deploy_rebuild_nodes(builder: &JobBuilder, agent: &str, approval_id: i64) {
|
|
||||||
let roots = rebuild_nodes(builder, agent, false, None);
|
|
||||||
let _finalize = builder
|
|
||||||
.node(NodeKind::FinalizeDeploy {
|
|
||||||
agent: agent.to_owned(),
|
|
||||||
approval_id,
|
|
||||||
})
|
|
||||||
.needs(Resource::MetaWindow)
|
|
||||||
.after_ok(roots.agent_window)
|
|
||||||
.after_ok(roots.reconcile);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// One uniform rebuild shape — no `was_running` branch. `StopForUpdate`
|
/// One uniform rebuild shape — no `was_running` branch. `StopForUpdate`
|
||||||
/// noops when already down; the tail `Reconcile` auto-noops the start
|
/// noops when already down; the tail `Reconcile` auto-noops the start
|
||||||
/// when `wanted = Offline` (a rebuild of a deliberately-stopped agent
|
/// when `wanted = Offline` (a rebuild of a deliberately-stopped agent
|
||||||
|
|
@ -375,71 +339,6 @@ pub fn rebuild(builder: &JobBuilder, agent: &str, relock: bool) -> Vec<hive_jobq
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Approval-driven deploy (`MergeConfigPr`) as a phase subtree rather than the
|
|
||||||
/// single opaque node it used to be. Structure:
|
|
||||||
/// - `DeployWindow` (**root**): the resource holder — global meta window, agent
|
|
||||||
/// lease, build slot — held across every child below. No work of its own; it
|
|
||||||
/// reaches `Finishing` immediately and the children run inside it.
|
|
||||||
/// - `MergeVerify` (child): drift-gate + fetch + eval-verify. Mutates nothing,
|
|
||||||
/// so a failure here cancel-cascades its siblings with the forge and the
|
|
||||||
/// applied repo exactly as they were.
|
|
||||||
/// - `DeployApply` (child, `AfterOk` `MergeVerify`): the irreversible half —
|
|
||||||
/// ff-merge + `prepare_deploy`. It doesn't rebuild inline; it grows
|
|
||||||
/// [`deploy_rebuild_nodes`] into this DAG as its own children, so the build
|
|
||||||
/// and the closing `FinalizeDeploy` are real nodes under the same window.
|
|
||||||
/// - `DeployTail` (child, `AfterAny` `DeployApply`): the compensation +
|
|
||||||
/// bookkeeping tail — rollback when a merge landed unfinalized, forge tag
|
|
||||||
/// mirror, PR failure comment (see [`NodeKind::DeployTail`]).
|
|
||||||
///
|
|
||||||
/// - `ResolveApproval` (**root**, `AfterAny` `DeployWindow`): resolves the
|
|
||||||
/// approval row. A root rather than another child, so it isn't inside the
|
|
||||||
/// window's resource subtree — it runs once the window has released the meta
|
|
||||||
/// window, lease and build slot. One edge suffices here: `DeployWindow` is the
|
|
||||||
/// DAG's only other group-root, so its roll-up already *is* the whole
|
|
||||||
/// pipeline's outcome.
|
|
||||||
///
|
|
||||||
/// The window still spans the container build, as it must: `prepare_deploy`
|
|
||||||
/// leaves `flake.lock` staged-uncommitted for the build's whole duration.
|
|
||||||
pub fn approval_deploy(builder: &JobBuilder, agent: &str, approval_id: i64) {
|
|
||||||
let a = || agent.to_owned();
|
|
||||||
// The window is the widest holder in the tree: it brackets a nix
|
|
||||||
// build (`BuildSlot`), takes the container down across the swap
|
|
||||||
// (`Agent`), and serialises the meta mutation its subtree performs
|
|
||||||
// (`MetaWindow`). All three are held for its whole subtree, which
|
|
||||||
// is what lets the appended rebuild's `MetaSync` and the
|
|
||||||
// `FinalizeDeploy` re-enter rather than contend.
|
|
||||||
let window = builder
|
|
||||||
.node(NodeKind::DeployWindow {
|
|
||||||
agent: a(),
|
|
||||||
approval_id,
|
|
||||||
})
|
|
||||||
.needs(Resource::BuildSlot)
|
|
||||||
.needs(Resource::Agent(a()))
|
|
||||||
.needs(Resource::MetaWindow);
|
|
||||||
let verify = builder
|
|
||||||
.node(NodeKind::MergeVerify {
|
|
||||||
agent: a(),
|
|
||||||
approval_id,
|
|
||||||
})
|
|
||||||
.part_of(window);
|
|
||||||
let apply = builder
|
|
||||||
.node(NodeKind::DeployApply {
|
|
||||||
agent: a(),
|
|
||||||
approval_id,
|
|
||||||
})
|
|
||||||
.part_of(window)
|
|
||||||
.after_ok(verify);
|
|
||||||
let _tail = builder
|
|
||||||
.node(NodeKind::DeployTail {
|
|
||||||
agent: a(),
|
|
||||||
approval_id,
|
|
||||||
})
|
|
||||||
.part_of(window)
|
|
||||||
.after_any(apply);
|
|
||||||
|
|
||||||
resolve_approval_tails(builder, approval_id, window);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// First deploy of an agent this hive has never seen, asked for by the swarm:
|
/// First deploy of an agent this hive has never seen, asked for by the swarm:
|
||||||
/// `Provision` (proposed/applied repos, state subvolume, meta registration)
|
/// `Provision` (proposed/applied repos, state subvolume, meta registration)
|
||||||
/// then `Create` (`nixos-container create`), drop-in write, then `Reconcile`
|
/// then `Create` (`nixos-container create`), drop-in write, then `Reconcile`
|
||||||
|
|
|
||||||
|
|
@ -1203,19 +1203,15 @@ fn cancelled_power_op_runs_no_compensating_node() {
|
||||||
fn cancelled_dag_still_runs_its_approval_tail() {
|
fn cancelled_dag_still_runs_its_approval_tail() {
|
||||||
let q = JobQueue::new(1);
|
let q = JobQueue::new(1);
|
||||||
insert(&q, |builder| {
|
insert(&q, |builder| {
|
||||||
templates::approval_deploy(builder, "agent-a", 7);
|
templates::meta_update(builder, Vec::new(), Some(7));
|
||||||
});
|
});
|
||||||
// Found by kind, not returned: `approval_deploy` deliberately names
|
// Found by kind, not returned: `meta_update` names nothing, because the
|
||||||
// nothing, because nothing polls it — the approval row is how an operator
|
// approval row is how an operator follows it.
|
||||||
// follows a deploy, so the template is fire-and-forget in production and a
|
let lock = node_of(&q, "meta_lock").get();
|
||||||
// test must not make it return an id it would otherwise have no use for.
|
assert!(q.cancel(lock), "fully-queued dag cancels");
|
||||||
let window = node_of(&q, "deploy_window").get();
|
|
||||||
assert!(q.cancel(window), "fully-queued dag cancels");
|
|
||||||
// The `Cancelled` tail is the only node whose edge accepts a dropped
|
// The `Cancelled` tail is the only node whose edge accepts a dropped
|
||||||
// dependency, so it is the only one `cancel` spares — and *which* tail
|
// dependency, so it is the only one `cancel` spares — and *which* tail
|
||||||
// survives is the whole assertion: the template emits one per outcome and
|
// survives names how the approval row is about to be resolved.
|
||||||
// the spared one names how the approval row is about to be resolved.
|
|
||||||
// Nothing computes it, so reading the survivor is reading the answer.
|
|
||||||
let spared = pending_payloads(&q);
|
let spared = pending_payloads(&q);
|
||||||
assert!(
|
assert!(
|
||||||
matches!(
|
matches!(
|
||||||
|
|
@ -1227,171 +1223,9 @@ fn cancelled_dag_still_runs_its_approval_tail() {
|
||||||
),
|
),
|
||||||
"only the cancelled-outcome tail is spared, got {spared:?}"
|
"only the cancelled-outcome tail is spared, got {spared:?}"
|
||||||
);
|
);
|
||||||
// ⚠️ `Cancelled`, and it reads terminal **while the spared tail is still
|
assert_eq!(state_of(&q, lock), State::Cancelled);
|
||||||
// pending** — the one place this differs from the container era, where the
|
|
||||||
// cancel landed on a node *above* the window and the window rolled up
|
|
||||||
// `Finishing`. Here the operator cancels the window itself, so its own
|
|
||||||
// state is `Cancelled` however its subtree is doing. Deliberately asserted
|
|
||||||
// rather than routed around: the group's card goes terminal while a
|
|
||||||
// bookkeeping node runs on. That is acceptable for the tail this test
|
|
||||||
// protects (it resolves the approval row and nothing waits on it), and it
|
|
||||||
// would not be for work an operator expects to still be watching.
|
|
||||||
assert_eq!(state_of(&q, window), State::Cancelled);
|
|
||||||
// An unrelated group landing in the same graph doesn't disturb this one's
|
|
||||||
// state — a root rolls up its own subtree, not the graph.
|
|
||||||
let _other = insert_named(&q, |builder| rebuild(builder, "agent-b"));
|
|
||||||
assert_eq!(state_of(&q, window), State::Cancelled);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- approval deploy subtree ----
|
|
||||||
|
|
||||||
/// The config-PR deploy is a subtree, not one opaque node. The
|
|
||||||
/// resource-holding root completes immediately (its `Finishing` state is the
|
|
||||||
/// parent gate that releases the children), then the phases run strictly in
|
|
||||||
/// order — and the `AfterAny` tail still runs when the irreversible half fails,
|
|
||||||
/// because it's the node that compensates for it.
|
|
||||||
#[test]
|
|
||||||
fn deploy_dag_runs_phases_in_order_and_tails_a_failed_apply() {
|
|
||||||
let q = JobQueue::new(1);
|
|
||||||
insert(&q, |builder| {
|
|
||||||
templates::approval_deploy(builder, "agent-a", 7);
|
|
||||||
});
|
|
||||||
|
|
||||||
assert_eq!(
|
|
||||||
declared_shape(&q),
|
|
||||||
vec![
|
|
||||||
// The window is the group root and holds the meta window for the
|
|
||||||
// whole subtree; the three phases are its sub-nodes.
|
|
||||||
row("deploy_window", None, &[]),
|
|
||||||
row("merge_verify", Some("deploy_window"), &[]),
|
|
||||||
// Apply only on a clean verify — a failed verify cancel-cascades
|
|
||||||
// it, which is what leaves the forge and the applied repo untouched.
|
|
||||||
row(
|
|
||||||
"deploy_apply",
|
|
||||||
Some("deploy_window"),
|
|
||||||
&[("merge_verify", "done")]
|
|
||||||
),
|
|
||||||
// The compensation tail accepts every terminal outcome of apply,
|
|
||||||
// *including `skipped`* — which is the state apply lands in when
|
|
||||||
// verify failed and it never ran. That one edge is the entire
|
|
||||||
// "still tails a failed apply / a failed verify" behaviour, and it
|
|
||||||
// is why two separate DAG-driving tests collapsed into this table.
|
|
||||||
row(
|
|
||||||
"deploy_tail",
|
|
||||||
Some("deploy_window"),
|
|
||||||
&[("deploy_apply", "done|failed|skipped")]
|
|
||||||
),
|
|
||||||
// One approval tail per outcome, gated on the window's roll-up.
|
|
||||||
row("resolve_approval", None, &[("deploy_window", "done")]),
|
|
||||||
row("resolve_approval", None, &[("deploy_window", "failed")]),
|
|
||||||
row("resolve_approval", None, &[("deploy_window", "cancelled")]),
|
|
||||||
]
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The deploy's happy path: `DeployApply` does not build. It grows the ordinary
|
|
||||||
/// rebuild chain into the live DAG under itself, and `FinalizeDeploy` — gated on
|
|
||||||
/// that graft finishing — plants the deploy tag last.
|
|
||||||
///
|
|
||||||
/// The queue is built with **one** build slot on purpose. `DeployWindow` already
|
|
||||||
/// holds that slot (and the meta window) for the whole subtree, so the grafted
|
|
||||||
/// `Prebuild` can only ever claim by *re-entering* its ancestor's hold. If the
|
|
||||||
/// graft were rooted anywhere outside `DeployWindow`'s subtree it would block on
|
|
||||||
/// a resource its own DAG owns and deadlock — this test is what pins that down.
|
|
||||||
#[test]
|
|
||||||
fn deploy_apply_grows_rebuild_subgraph_and_finalizes_after_it() {
|
|
||||||
// What `DeployApply` grows is `deploy_rebuild_nodes`' output, and that is a
|
|
||||||
// pure declaration — so it is declared here directly rather than by running
|
|
||||||
// a deploy far enough to graft it. **Reproducing the runtime path is not
|
|
||||||
// needed to test what the runtime path declares.**
|
|
||||||
//
|
|
||||||
// The grafting mechanism itself is hive_jobq's and tested there: the work
|
|
||||||
// lands under the emitter *before* it settles, and the emitter parks in
|
|
||||||
// `Finishing` so a downstream `AfterAny` gate stays shut while the new
|
|
||||||
// children run (`a_completing_node_grows_the_work_it_declared`,
|
|
||||||
// `parent_parks_in_finishing_until_children_roll_up`).
|
|
||||||
let q = JobQueue::new(1);
|
|
||||||
insert(&q, |builder| {
|
|
||||||
templates::deploy_rebuild_nodes(builder, "agent-a", 11);
|
|
||||||
});
|
|
||||||
|
|
||||||
assert_eq!(
|
|
||||||
declared_shape(&q),
|
|
||||||
vec![
|
|
||||||
row("meta_sync", None, &[]),
|
|
||||||
row("agent_window", None, &[("meta_sync", "done")]),
|
|
||||||
row("prebuild", Some("agent_window"), &[]),
|
|
||||||
row(
|
|
||||||
"stop_for_update",
|
|
||||||
Some("agent_window"),
|
|
||||||
&[("prebuild", "done")]
|
|
||||||
),
|
|
||||||
row("swap", Some("stop_for_update"), &[]),
|
|
||||||
row(
|
|
||||||
"rebuild_bookkeeping",
|
|
||||||
Some("stop_for_update"),
|
|
||||||
&[("swap", "done")]
|
|
||||||
),
|
|
||||||
row(
|
|
||||||
"reconcile",
|
|
||||||
None,
|
|
||||||
&[("agent_window", "done|failed|skipped")]
|
|
||||||
),
|
|
||||||
// The deploy tag is planted only after the rebuild came up clean:
|
|
||||||
// `AfterOk` on **both** roots, so either one failing skips it. That
|
|
||||||
// pair of edges is the whole "skips finalize on a failed graft"
|
|
||||||
// behaviour — no run needed to see it.
|
|
||||||
row(
|
|
||||||
"finalize_deploy",
|
|
||||||
None,
|
|
||||||
&[("agent_window", "done"), ("reconcile", "done")]
|
|
||||||
),
|
|
||||||
]
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// `deploy_dag_skips_finalize_but_still_tails_a_failed_graft` lived here.
|
|
||||||
//
|
|
||||||
// A failure *inside* the grafted rebuild is the failure mode subgraph growth
|
|
||||||
// introduces: the deploy is already merged and the container half-swapped, so
|
|
||||||
// `FinalizeDeploy` must be cancel-cascaded (no `deployed/<id>` tag planted)
|
|
||||||
// while the tail still runs to compensate, and `Reconcile` is deliberately
|
|
||||||
// still reached — it boots the container back up.
|
|
||||||
//
|
|
||||||
// Every declared half of that is asserted by
|
|
||||||
// `deploy_apply_grows_rebuild_subgraph_and_finalizes_after_it`, which reads
|
|
||||||
// `deploy_rebuild_nodes`' shape directly:
|
|
||||||
//
|
|
||||||
// - "a failed swap still reaches Reconcile" is the `reconcile` row's
|
|
||||||
// `AfterAny` edge on `prebuild` (`done|failed|skipped`);
|
|
||||||
// - "finalize is cancel-cascaded" is `finalize_deploy`'s `AfterOk` pair —
|
|
||||||
// either root failing skips it;
|
|
||||||
// - "the tail still runs" is `deploy_tail`'s own `done|failed|skipped` edge
|
|
||||||
// on apply, asserted in the `approval_deploy` table above.
|
|
||||||
//
|
|
||||||
// The runtime halves are hive_jobq's: cascade on failure, roll-up, and
|
|
||||||
// `first_error` digging past a group root that rolled up `Failed` while
|
|
||||||
// carrying no error of its own (`first_error_skips_a_rolled_up_failure_
|
|
||||||
// carrying_no_error`). That last one is why the DAG reports "profile swap
|
|
||||||
// failed" rather than nothing — the mechanism, not this shape.
|
|
||||||
|
|
||||||
// `deploy_dag_skips_apply_but_still_runs_tail_when_verify_fails` lived here.
|
|
||||||
//
|
|
||||||
// A pre-merge rejection (drift gate, eval failure) cancel-cascades the
|
|
||||||
// irreversible half via its `AfterOk` edge, while the tail is still reached —
|
|
||||||
// it owns the forge mirror, not just compensation. That test and
|
|
||||||
// `deploy_dag_runs_phases_in_order_and_tails_a_failed_apply` differed only in
|
|
||||||
// *where* they injected the failure, and each drove the whole DAG to watch the
|
|
||||||
// tail run anyway.
|
|
||||||
//
|
|
||||||
// Both outcomes follow from one declared edge, which the surviving test now
|
|
||||||
// asserts directly: the tail accepts `done|failed|skipped` on apply, and
|
|
||||||
// `skipped` is exactly the state apply lands in when verify failed and it never
|
|
||||||
// ran. The runtime halves are hive_jobq's and tested there —
|
|
||||||
// `failed_after_ok_dep_cancels_dependents_but_after_any_still_runs` and
|
|
||||||
// `failed_child_rolls_parent_up_to_failed` (an Ok tail cannot launder a failed
|
|
||||||
// deploy into a success).
|
|
||||||
|
|
||||||
// ---- history ----
|
// ---- history ----
|
||||||
//
|
//
|
||||||
// The node → build-log link is no longer queue state: the log row carries
|
// The node → build-log link is no longer queue state: the log row carries
|
||||||
|
|
|
||||||
|
|
@ -135,55 +135,6 @@ pub async fn git_tag(dir: &Path, name: &str, target: &str) -> Result<()> {
|
||||||
git(dir, &["tag", name, target]).await
|
git(dir, &["tag", name, target]).await
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Plant an annotated tag with `body` as the message. Used for
|
|
||||||
/// `failed/<id>` (body = build error) and `denied/<id>` (body =
|
|
||||||
/// operator note). Multi-line bodies handled via stdin so we don't
|
|
||||||
/// have to escape anything.
|
|
||||||
pub async fn git_tag_annotated(dir: &Path, name: &str, target: &str, body: &str) -> Result<()> {
|
|
||||||
use tokio::io::AsyncWriteExt;
|
|
||||||
// Annotated tags are git objects, so they need a tagger identity
|
|
||||||
// (same constraint as a commit). Pass the hive-c0re identity
|
|
||||||
// inline rather than relying on a global git config — applied
|
|
||||||
// repos are hive-c0re-owned and the host's user might not have
|
|
||||||
// user.email set.
|
|
||||||
let mut child = git_command()
|
|
||||||
.current_dir(dir)
|
|
||||||
.args([
|
|
||||||
"-c",
|
|
||||||
&format!("user.name={GIT_NAME}"),
|
|
||||||
"-c",
|
|
||||||
&format!("user.email={GIT_EMAIL}"),
|
|
||||||
"tag",
|
|
||||||
"-a",
|
|
||||||
name,
|
|
||||||
target,
|
|
||||||
"-F",
|
|
||||||
"-",
|
|
||||||
])
|
|
||||||
.stdin(std::process::Stdio::piped())
|
|
||||||
.stdout(std::process::Stdio::piped())
|
|
||||||
.stderr(std::process::Stdio::piped())
|
|
||||||
.spawn()
|
|
||||||
.with_context(|| format!("spawn git tag -a {name} in {}", dir.display()))?;
|
|
||||||
if let Some(mut stdin) = child.stdin.take() {
|
|
||||||
stdin
|
|
||||||
.write_all(body.as_bytes())
|
|
||||||
.await
|
|
||||||
.context("write tag body to git stdin")?;
|
|
||||||
// Drop closes stdin so git can finish reading.
|
|
||||||
drop(stdin);
|
|
||||||
}
|
|
||||||
let out = child.wait_with_output().await.context("wait git tag -a")?;
|
|
||||||
if !out.status.success() {
|
|
||||||
bail!(
|
|
||||||
"git tag -a {name} failed ({}): {}",
|
|
||||||
out.status,
|
|
||||||
String::from_utf8_lossy(&out.stderr).trim()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Replace working tree + index with the tree at `target` without
|
/// Replace working tree + index with the tree at `target` without
|
||||||
/// moving HEAD. `applied/main` stays pointing at the last known-good
|
/// moving HEAD. `applied/main` stays pointing at the last known-good
|
||||||
/// `deployed/*` while we let `nixos-container update` evaluate the
|
/// `deployed/*` while we let `nixos-container update` evaluate the
|
||||||
|
|
@ -252,13 +203,3 @@ pub async fn git_is_ancestor(dir: &Path, ancestor: &str, descendant: &str) -> Re
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Delete a ref. The counterpart to [`git_update_ref`] for the bookkeeping
|
|
||||||
/// refs a deploy parks in the applied repo (`refs/hyperhive/rollback/<id>`,
|
|
||||||
/// which records the pre-merge `main` so the deploy tail can compensate a
|
|
||||||
/// merge that landed but never finalized). `update-ref -d` is a no-op-free
|
|
||||||
/// delete: it errors if the ref does not exist, so callers that treat absence
|
|
||||||
/// as "nothing to undo" should check with [`git_rev_parse`] first.
|
|
||||||
pub async fn git_delete_ref(dir: &Path, refname: &str) -> Result<()> {
|
|
||||||
git(dir, &["update-ref", "-d", refname]).await
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -8,9 +8,8 @@ mod setup;
|
||||||
mod tests;
|
mod tests;
|
||||||
|
|
||||||
pub use git::{
|
pub use git::{
|
||||||
git, git_authed, git_command, git_command_authed, git_delete_ref, git_is_ancestor,
|
git, git_authed, git_command, git_command_authed, git_is_ancestor, git_read_tree_reset,
|
||||||
git_read_tree_reset, git_rev_parse, git_tag, git_tag_annotated, git_update_ref,
|
git_rev_parse, git_tag, git_update_ref, git_update_ref_cas,
|
||||||
git_update_ref_cas,
|
|
||||||
};
|
};
|
||||||
pub use host_config::write_dropins;
|
pub use host_config::write_dropins;
|
||||||
pub use setup::{
|
pub use setup::{
|
||||||
|
|
|
||||||
|
|
@ -98,8 +98,8 @@ async fn ensure_applied_remote(proposed_dir: &Path, name: &str) -> Result<()> {
|
||||||
/// Set up the applied repo. First-spawn only: init the repo, pull
|
/// Set up the applied repo. First-spawn only: init the repo, pull
|
||||||
/// proposed's initial commit in via `git fetch`, tag it `deployed/0`.
|
/// proposed's initial commit in via `git fetch`, tag it `deployed/0`.
|
||||||
/// This is the *only* time hive-c0re reads from `proposed` for an
|
/// This is the *only* time hive-c0re reads from `proposed` for an
|
||||||
/// agent — subsequent config changes are fetched from the reviewed
|
/// agent — subsequent config changes are fetched from the forge's `main`
|
||||||
/// forge PR head at merge time (see `actions::run_deploy_apply`).
|
/// (see `actions::advance_applied_to_rev`).
|
||||||
///
|
///
|
||||||
/// `proposed_dir` is `None` on rebuild paths where the repo already
|
/// `proposed_dir` is `None` on rebuild paths where the repo already
|
||||||
/// exists — we just verify it's the right shape and bail otherwise.
|
/// exists — we just verify it's the right shape and bail otherwise.
|
||||||
|
|
|
||||||
|
|
@ -40,7 +40,6 @@ mod swarm_queue;
|
||||||
mod swarm_status;
|
mod swarm_status;
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod test_env;
|
mod test_env;
|
||||||
mod webhook_secret;
|
|
||||||
mod workers;
|
mod workers;
|
||||||
|
|
||||||
pub(crate) use agent_config::{capabilities, limits, resource_limits, tool_groups, topology};
|
pub(crate) use agent_config::{capabilities, limits, resource_limits, tool_groups, topology};
|
||||||
|
|
@ -296,54 +295,6 @@ async fn cmd_serve(
|
||||||
// `workers::auto_update::submit_startup_sweep_nodes`), submitted
|
// `workers::auto_update::submit_startup_sweep_nodes`), submitted
|
||||||
// unconditionally on every boot — moved off a bare `tokio::spawn` so it
|
// unconditionally on every boot — moved off a bare `tokio::spawn` so it
|
||||||
// shows as real work on the dashboard.
|
// shows as real work on the dashboard.
|
||||||
// Webhook HMAC secret: load from state dir or generate on first run.
|
|
||||||
// Used by both the webhook handlers (verification) and the Forgejo
|
|
||||||
// hook registrations (so Forgejo signs deliveries with the same key).
|
|
||||||
let webhook_secret: Option<String> = match crate::webhook_secret::load_or_generate() {
|
|
||||||
Ok(s) => Some(s),
|
|
||||||
Err(e) => {
|
|
||||||
tracing::error!(
|
|
||||||
error = ?e,
|
|
||||||
"webhook secret load/generate failed; /webhook/* endpoints disabled and hooks not registered"
|
|
||||||
);
|
|
||||||
None
|
|
||||||
}
|
|
||||||
};
|
|
||||||
// Webhook setup: now a `NodeKind::WebhookRegister` DAG node (see
|
|
||||||
// `workers::auto_update::submit_startup_sweep_nodes`), registering both
|
|
||||||
// `internal/knowledge` (push → git pull) and the `agent-configs` org
|
|
||||||
// (pull_request → queue MergeConfigPr approval) hooks. Its executor
|
|
||||||
// re-derives the core token / hive domain / HMAC secret itself, mirroring
|
|
||||||
// the guard chain that used to live here — see `job_queue::exec::
|
|
||||||
// run_webhook_register`.
|
|
||||||
// Config-PR polling fallback: scan agent-configs org every 5 minutes
|
|
||||||
// for open PRs that have no pending MergeConfigPr approval. Catches
|
|
||||||
// anything the webhook missed (c0re was down when PR opened, delivery
|
|
||||||
// failed, etc.). First sweep fires immediately on startup.
|
|
||||||
let poll_coord = coord.clone();
|
|
||||||
let mut poll_shutdown = coord.shutdown_rx();
|
|
||||||
tokio::spawn(async move {
|
|
||||||
let interval = std::time::Duration::from_mins(5);
|
|
||||||
loop {
|
|
||||||
if let Some(token) = forge::core_token() {
|
|
||||||
let result = Box::pin(forge::config_pr_poll::poll_open_config_prs(
|
|
||||||
&token,
|
|
||||||
&poll_coord,
|
|
||||||
))
|
|
||||||
.await;
|
|
||||||
if let Err(e) = result {
|
|
||||||
tracing::debug!(error = ?e, "config-pr poll: sweep failed (forge may be absent)");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
tokio::select! {
|
|
||||||
() = tokio::time::sleep(interval) => {}
|
|
||||||
_ = poll_shutdown.changed() => {
|
|
||||||
tracing::info!("config-pr poll: shutdown signal received");
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
// Knowledge periodic pull: hourly fallback in case a knowledge event from
|
// Knowledge periodic pull: hourly fallback in case a knowledge event from
|
||||||
// the swarm is missed (e.g. hive-c0re was down when it was sent). Sleeps
|
// the swarm is missed (e.g. hive-c0re was down when it was sent). Sleeps
|
||||||
// before its first pull: the boot `NodeKind::KnowledgePull` DAG node
|
// before its first pull: the boot `NodeKind::KnowledgePull` DAG node
|
||||||
|
|
@ -506,9 +457,8 @@ async fn cmd_serve(
|
||||||
// channel (used by `recv_blocking_batch`) stays untouched.
|
// channel (used by `recv_blocking_batch`) stays untouched.
|
||||||
spawn_broker_to_dashboard_forwarder(coord.clone());
|
spawn_broker_to_dashboard_forwarder(coord.clone());
|
||||||
let dash_coord = coord.clone();
|
let dash_coord = coord.clone();
|
||||||
let dash_secret = webhook_secret.clone();
|
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
if let Err(e) = dashboard::serve(dashboard_port, dash_coord, dash_secret).await {
|
if let Err(e) = dashboard::serve(dashboard_port, dash_coord).await {
|
||||||
tracing::error!(error = ?e, "dashboard failed");
|
tracing::error!(error = ?e, "dashboard failed");
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
//! Single hive-c0re-owned flake at `/var/lib/hyperhive/meta/` that
|
//! Single hive-c0re-owned flake at `/var/lib/hyperhive/meta/` that
|
||||||
//! exports one `nixosConfiguration` per agent and drives the system-wide
|
//! exports one `nixosConfiguration` per agent and drives the system-wide
|
||||||
//! deploy audit trail. Flow (`sync_agents`, two-phase `prepare_deploy` /
|
//! deploy audit trail. Flow (`sync_agents`, `lock_update_for_rebuild`,
|
||||||
//! `finalize_deploy` / `abort_deploy`, `lock_update_hyperhive`):
|
//! `lock_update_hyperhive`):
|
||||||
//! `docs/agent-lifecycle/approvals.md::Meta flake`.
|
//! `docs/agent-lifecycle/approvals.md::Meta flake`.
|
||||||
|
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
|
|
@ -28,16 +28,12 @@ const GIT_EMAIL: &str = "c0re@hyperhive.local";
|
||||||
static META_LOCK: Mutex<()> = Mutex::const_new(());
|
static META_LOCK: Mutex<()> = Mutex::const_new(());
|
||||||
|
|
||||||
// Exclusivity for meta-repo *windows* that span multiple `META_LOCK`
|
// Exclusivity for meta-repo *windows* that span multiple `META_LOCK`
|
||||||
// acquisitions — above all the two-phase deploy (`prepare_deploy` stages
|
// acquisitions is **not** a mutex in this module. `META_LOCK` above
|
||||||
// `flake.lock` uncommitted for the whole container build;
|
// serializes individual git ops but cannot keep another op out of a window
|
||||||
// `finalize_deploy` / `abort_deploy` resolve it) — is **not** a mutex in
|
// spanning several of them; that window is owned by the job queue instead,
|
||||||
// this module. `META_LOCK` above serializes individual git ops but cannot
|
// as `Resource::MetaWindow`, declared by every meta-mutating node kind. A
|
||||||
// keep another op out of that staged window; that window is owned by the
|
// resource can be held by a subtree root across its children, which a `MutexGuard`
|
||||||
// job queue instead, as `Resource::MetaWindow`, declared by every
|
// (bounded by one executor fn) cannot.
|
||||||
// meta-mutating node kind (it declares `Resource::MetaWindow`). A resource can
|
|
||||||
// be held by a subtree root across its children, which a `MutexGuard`
|
|
||||||
// (bounded by one executor fn) cannot — that's what lets the deploy be
|
|
||||||
// modelled as sub-nodes rather than one opaque node.
|
|
||||||
|
|
||||||
/// Where the manager sees this directory inside its container (RO bind).
|
/// Where the manager sees this directory inside its container (RO bind).
|
||||||
pub const CONTAINER_MANAGER_META_MOUNT: &str = "/meta";
|
pub const CONTAINER_MANAGER_META_MOUNT: &str = "/meta";
|
||||||
|
|
@ -231,75 +227,6 @@ pub async fn sync_agents(hive: &HiveEnv, agents: &[AgentSpec]) -> Result<()> {
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Phase 1 of an apply-commit deploy. Updates the locked rev of
|
|
||||||
/// `agent-<name>` to whatever `applied/<name>/main` currently points
|
|
||||||
/// at and **stages** the lock so `nixos-container update --flake
|
|
||||||
/// meta#<n>` (which reads via `git+file://`) sees the new rev via
|
|
||||||
/// the index. Doesn't commit — `finalize_deploy` commits on build
|
|
||||||
/// success, `abort_deploy` drops the staged change on failure so
|
|
||||||
/// meta history only carries successful deploys.
|
|
||||||
pub async fn prepare_deploy(name: &str, node_id: Option<u64>) -> Result<()> {
|
|
||||||
let _guard = META_LOCK.lock().await;
|
|
||||||
let dir = crate::paths::meta_root();
|
|
||||||
let input = format!("agent-{name}");
|
|
||||||
// Re-lock the agent input against the LOCAL applied mirror, not the
|
|
||||||
// persistent forge URL declared in the meta flake (see the `## Meta flake`
|
|
||||||
// note in docs/agent-lifecycle/approvals.md): the deploy must build the exact reviewed
|
|
||||||
// config that `verify_commit` gated and `applied/<n>/main` was
|
|
||||||
// fast-forwarded to, and it must keep working when the forge is
|
|
||||||
// unreachable (rebuilds fire on crash-restart / meta bumps too, not just
|
|
||||||
// config PRs). `--override-input` writes the applied rev into the lock;
|
|
||||||
// the forge URL stays the declared, reviewable source of truth.
|
|
||||||
let applied = applied_override_url(&crate::paths::applied_dir(name));
|
|
||||||
nix_logged(
|
|
||||||
&dir,
|
|
||||||
&[
|
|
||||||
"flake",
|
|
||||||
"update",
|
|
||||||
&input,
|
|
||||||
"--override-input",
|
|
||||||
&input,
|
|
||||||
&applied,
|
|
||||||
],
|
|
||||||
name,
|
|
||||||
"prepare-deploy",
|
|
||||||
node_id,
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
// Stage the new lock — git+file://'s dirty-tree fetcher reads
|
|
||||||
// index entries, so the upcoming nixos-container update sees the
|
|
||||||
// bumped rev without a commit yet.
|
|
||||||
git(&dir, &["add", "flake.lock"]).await
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Phase 2-success. Commit the staged lock with the deployed tag +
|
|
||||||
/// sha as the message. No-op when the rev was already at the right
|
|
||||||
/// place (nothing staged → nothing to commit).
|
|
||||||
pub async fn finalize_deploy(name: &str, sha: &str, tag: &str) -> Result<()> {
|
|
||||||
let _guard = META_LOCK.lock().await;
|
|
||||||
let dir = crate::paths::meta_root();
|
|
||||||
if !paths_dirty(&dir, &["flake.lock"]).await? {
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
let short = &sha[..sha.len().min(12)];
|
|
||||||
git_commit_paths(
|
|
||||||
&dir,
|
|
||||||
&format!("deploy {name} {tag} {short}"),
|
|
||||||
&["flake.lock"],
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Phase 2-failure. Unstage + restore the lock so meta returns to
|
|
||||||
/// the previously-committed shas. The failed proposal is still
|
|
||||||
/// captured in `applied/<n>`'s annotated `failed/<id>` tag.
|
|
||||||
pub async fn abort_deploy() -> Result<()> {
|
|
||||||
let _guard = META_LOCK.lock().await;
|
|
||||||
let dir = crate::paths::meta_root();
|
|
||||||
git(&dir, &["restore", "--staged", "flake.lock"]).await?;
|
|
||||||
git(&dir, &["restore", "flake.lock"]).await
|
|
||||||
}
|
|
||||||
|
|
||||||
/// One-shot used by the manual-rebuild path: relock just one
|
/// One-shot used by the manual-rebuild path: relock just one
|
||||||
/// agent's input and commit the lock change if any. Single-phase
|
/// agent's input and commit the lock change if any. Single-phase
|
||||||
/// (no separate finalize) because rebuild has no failure-revert
|
/// (no separate finalize) because rebuild has no failure-revert
|
||||||
|
|
@ -308,9 +235,9 @@ pub async fn lock_update_for_rebuild(name: &str) -> Result<()> {
|
||||||
let _guard = META_LOCK.lock().await;
|
let _guard = META_LOCK.lock().await;
|
||||||
let dir = crate::paths::meta_root();
|
let dir = crate::paths::meta_root();
|
||||||
let input = format!("agent-{name}");
|
let input = format!("agent-{name}");
|
||||||
// Re-lock from the local applied mirror, not the persistent forge URL —
|
// Re-lock from the local applied mirror, not the persistent forge URL:
|
||||||
// same rationale as `prepare_deploy`: build exactly `applied/<n>/main` and
|
// build exactly `applied/<n>/main` and stay reproducible when the forge
|
||||||
// stay reproducible when the forge is unreachable.
|
// is unreachable.
|
||||||
let applied = applied_override_url(&crate::paths::applied_dir(name));
|
let applied = applied_override_url(&crate::paths::applied_dir(name));
|
||||||
nix(
|
nix(
|
||||||
&dir,
|
&dir,
|
||||||
|
|
@ -336,13 +263,6 @@ pub async fn lock_update_for_rebuild(name: &str) -> Result<()> {
|
||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Build the `--override-input` value pinning an agent's config repo to
|
|
||||||
/// an exact revision: `git+file://<applied_dir>?rev=<sha>`. Pure so the
|
|
||||||
/// URL shape is unit-testable without a nix shellout.
|
|
||||||
fn agent_input_override(applied_dir: &Path, sha: &str) -> String {
|
|
||||||
format!("git+file://{}?rev={sha}", applied_dir.display())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// `--override-input` URL re-locking an agent's config input against its
|
/// `--override-input` URL re-locking an agent's config input against its
|
||||||
/// LOCAL applied mirror (`git+file://<applied_dir>`, current `main` head)
|
/// LOCAL applied mirror (`git+file://<applied_dir>`, current `main` head)
|
||||||
/// instead of the persistent forge URL declared in the meta flake. The
|
/// instead of the persistent forge URL declared in the meta flake. The
|
||||||
|
|
@ -354,51 +274,6 @@ fn applied_override_url(applied_dir: &Path) -> String {
|
||||||
format!("git+file://{}", applied_dir.display())
|
format!("git+file://{}", applied_dir.display())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Non-mutating "would this commit apply?" verify for the PR-based config
|
|
||||||
/// flow. Evaluates the agent's nixos configuration with its meta
|
|
||||||
/// input overridden to the exact `sha`, WITHOUT moving `applied/main` or
|
|
||||||
/// writing the real meta `flake.lock`: `--override-input` pins the input
|
|
||||||
/// at eval time only and `--no-write-lock-file` guarantees the on-disk
|
|
||||||
/// lock is never touched (a genuinely-needed lock change surfaces as an
|
|
||||||
/// error rather than a silent mutation). Confirms the flake at `sha`
|
|
||||||
/// evaluates and the meta lock resolves around it. `Ok(())` = would
|
|
||||||
/// apply; `Err` carries the eval/lock failure so the operator's approve
|
|
||||||
/// is rejected before any live-state change.
|
|
||||||
///
|
|
||||||
/// Precondition: `sha` must be reachable in `applied_dir`'s object store
|
|
||||||
/// — the caller fetches the PR head into `applied_dir` first. Eval-only
|
|
||||||
/// (no build): catches nix / eval / module-option errors + lock
|
|
||||||
/// resolution at the same cost profile as the legacy pre-merge check;
|
|
||||||
/// the real container build still runs (and can still roll back) on the
|
|
||||||
/// actual apply, so this is the right "would this apply" gate.
|
|
||||||
pub async fn verify_commit(
|
|
||||||
name: &str,
|
|
||||||
applied_dir: &Path,
|
|
||||||
sha: &str,
|
|
||||||
node_id: Option<u64>,
|
|
||||||
) -> Result<()> {
|
|
||||||
let _guard = META_LOCK.lock().await;
|
|
||||||
let dir = crate::paths::meta_root();
|
|
||||||
let input = format!("agent-{name}");
|
|
||||||
let over = agent_input_override(applied_dir, sha);
|
|
||||||
let attr = format!(".#nixosConfigurations.{name}.config.system.build.toplevel.drvPath");
|
|
||||||
nix_logged(
|
|
||||||
&dir,
|
|
||||||
&[
|
|
||||||
"eval",
|
|
||||||
&attr,
|
|
||||||
"--override-input",
|
|
||||||
&input,
|
|
||||||
&over,
|
|
||||||
"--no-write-lock-file",
|
|
||||||
],
|
|
||||||
name,
|
|
||||||
"verify",
|
|
||||||
node_id,
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Update one or more named inputs in the meta flake and commit
|
/// Update one or more named inputs in the meta flake and commit
|
||||||
/// the resulting lock change with a single combined message.
|
/// the resulting lock change with a single combined message.
|
||||||
/// Used by the dashboard's "update meta inputs" form so the
|
/// Used by the dashboard's "update meta inputs" form so the
|
||||||
|
|
@ -447,7 +322,7 @@ pub async fn lock_update_hyperhive() -> Result<()> {
|
||||||
|
|
||||||
/// Write the tool-groups file for `agent` and commit it atomically
|
/// Write the tool-groups file for `agent` and commit it atomically
|
||||||
/// under `META_LOCK`. Ensures the JSON change is staged + committed
|
/// under `META_LOCK`. Ensures the JSON change is staged + committed
|
||||||
/// before the next `prepare_deploy` or `sync_agents` runs, so the
|
/// before the next `sync_agents` runs, so the
|
||||||
/// working tree is never left dirty by an untimely `PermChange` write.
|
/// working tree is never left dirty by an untimely `PermChange` write.
|
||||||
pub async fn commit_tool_groups(agent: &str, groups: &[String]) -> Result<()> {
|
pub async fn commit_tool_groups(agent: &str, groups: &[String]) -> Result<()> {
|
||||||
let _guard = META_LOCK.lock().await;
|
let _guard = META_LOCK.lock().await;
|
||||||
|
|
@ -490,8 +365,8 @@ pub async fn commit_capabilities(agent: &str, caps: &[String]) -> Result<()> {
|
||||||
|
|
||||||
/// Write the resource-limits file for `agent` and commit it atomically
|
/// Write the resource-limits file for `agent` and commit it atomically
|
||||||
/// under `META_LOCK`. Same rationale as `commit_tool_groups`: the
|
/// under `META_LOCK`. Same rationale as `commit_tool_groups`: the
|
||||||
/// working tree must never be left dirty for the next `prepare_deploy`
|
/// working tree must never be left dirty for the next `sync_agents` to trip
|
||||||
/// or `sync_agents` to trip over.
|
/// over.
|
||||||
///
|
///
|
||||||
/// Unlike the perm files this one is never injected into the container —
|
/// Unlike the perm files this one is never injected into the container —
|
||||||
/// it's a host-side cap *on* the agent — but it lives in the same repo
|
/// it's a host-side cap *on* the agent — but it lives in the same repo
|
||||||
|
|
@ -1048,9 +923,7 @@ where
|
||||||
// on-disk state and boot never depends on the forge being reachable.
|
// on-disk state and boot never depends on the forge being reachable.
|
||||||
// The forge `agent-configs/<name>` repos stay the review/audit surface
|
// The forge `agent-configs/<name>` repos stay the review/audit surface
|
||||||
// (config PRs land there) but are not the flake's build input. deploy +
|
// (config PRs land there) but are not the flake's build input. deploy +
|
||||||
// rebuild re-lock this input to `applied/<name>`'s current `main` head;
|
// rebuild re-lock this input to `applied/<name>`'s current `main` head.
|
||||||
// `verify_commit` overrides it to a proposed `?rev=<sha>` for eval
|
|
||||||
// before that head moves.
|
|
||||||
for spec in agents {
|
for spec in agents {
|
||||||
let _ = writeln!(
|
let _ = writeln!(
|
||||||
out,
|
out,
|
||||||
|
|
@ -1459,8 +1332,7 @@ async fn git_commit(dir: &Path, message: &str) -> Result<()> {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Path-limited commit: commits ONLY the given paths, so unrelated
|
/// Path-limited commit: commits ONLY the given paths, so unrelated
|
||||||
/// staged content — above all a `prepare_deploy`-staged `flake.lock`
|
/// staged content can never be swept into someone else's commit. Every targeted
|
||||||
/// — can never be swept into someone else's commit. Every targeted
|
|
||||||
/// meta commit (perm files, topology, lock bumps) goes through this;
|
/// meta commit (perm files, topology, lock bumps) goes through this;
|
||||||
/// only `sync_agents` uses the bare [`git_commit`], because its
|
/// only `sync_agents` uses the bare [`git_commit`], because its
|
||||||
/// staged set *is* its intentional commit set.
|
/// staged set *is* its intentional commit set.
|
||||||
|
|
@ -1507,7 +1379,7 @@ fn nix_argv<'a>(args: &[&'a str]) -> Vec<&'a str> {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Run `nix <args>` in `dir` (flakes enabled), capturing combined output.
|
/// Run `nix <args>` in `dir` (flakes enabled), capturing combined output.
|
||||||
/// Shared core of [`nix`] and [`nix_logged`].
|
/// The core of [`nix`].
|
||||||
async fn nix_output(dir: &Path, args: &[&str]) -> Result<std::process::Output> {
|
async fn nix_output(dir: &Path, args: &[&str]) -> Result<std::process::Output> {
|
||||||
Command::new("nix")
|
Command::new("nix")
|
||||||
.current_dir(dir)
|
.current_dir(dir)
|
||||||
|
|
@ -1540,66 +1412,11 @@ async fn nix(dir: &Path, args: &[&str]) -> Result<()> {
|
||||||
nix_check(args, &out)
|
nix_check(args, &out)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Like [`nix`] but records the invocation (cmdline + stdout + stderr +
|
|
||||||
/// terminal status) into a `build_logs.sqlite` row so a config-approval
|
|
||||||
/// eval/deploy step shows up on the dashboard. This is what makes a
|
|
||||||
/// failing eval-verify visible: it runs before any container build, so
|
|
||||||
/// without a row a rejected config approval leaves the operator with
|
|
||||||
/// zero build logs to look at. Best-effort logging: a missing global
|
|
||||||
/// handle or a failed `start()` just skips the row — the command still
|
|
||||||
/// runs and its exit status is still enforced.
|
|
||||||
async fn nix_logged(
|
|
||||||
dir: &Path,
|
|
||||||
args: &[&str],
|
|
||||||
agent: &str,
|
|
||||||
kind: &str,
|
|
||||||
node_id: Option<u64>,
|
|
||||||
) -> Result<()> {
|
|
||||||
let cmdline = format!("nix {}", nix_argv(args).join(" "));
|
|
||||||
let logs = crate::build_logs::global();
|
|
||||||
let log_id = logs.as_ref().and_then(|h| {
|
|
||||||
// `node_id` links the row to the queue node that ran it, so the
|
|
||||||
// dashboard can reach this log from the node instead of only from the
|
|
||||||
// agent+kind+time listing. Both call paths are queue-only today and
|
|
||||||
// pass `Some`; it stays an `Option` because these are `meta`'s public
|
|
||||||
// API and a future non-queue caller has no node to name.
|
|
||||||
h.start(agent, kind, &cmdline, node_id)
|
|
||||||
.map_err(|e| {
|
|
||||||
tracing::warn!(error = ?e, %kind, "build_logs: start failed (meta log dropped)");
|
|
||||||
})
|
|
||||||
.ok()
|
|
||||||
});
|
|
||||||
|
|
||||||
let out = nix_output(dir, args).await?;
|
|
||||||
|
|
||||||
if let (Some(h), Some(id)) = (&logs, log_id) {
|
|
||||||
for line in String::from_utf8_lossy(&out.stdout).lines() {
|
|
||||||
h.append_stdout(id, line);
|
|
||||||
}
|
|
||||||
for line in String::from_utf8_lossy(&out.stderr).lines() {
|
|
||||||
h.append_stderr(id, line);
|
|
||||||
}
|
|
||||||
h.finish(
|
|
||||||
id,
|
|
||||||
if out.status.success() {
|
|
||||||
crate::build_logs::BuildStatus::Ok
|
|
||||||
} else {
|
|
||||||
crate::build_logs::BuildStatus::Fail
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
nix_check(args, &out)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
/// The regression the deploy-window bug review surfaced: a
|
/// A path-limited commit must leave an unrelated staged file untouched.
|
||||||
/// path-limited commit must leave an unrelated staged file (the
|
|
||||||
/// prepare_deploy-staged `flake.lock`) untouched, so a later
|
|
||||||
/// `abort_deploy` still has something to restore.
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn path_limited_commit_leaves_unrelated_staged_file_alone() {
|
async fn path_limited_commit_leaves_unrelated_staged_file_alone() {
|
||||||
let tmp = tempfile::tempdir().expect("tempdir");
|
let tmp = tempfile::tempdir().expect("tempdir");
|
||||||
|
|
@ -1611,7 +1428,7 @@ mod tests {
|
||||||
std::fs::write(dir.join("flake.lock"), "v1").expect("write");
|
std::fs::write(dir.join("flake.lock"), "v1").expect("write");
|
||||||
git(dir, &["add", "-A"]).await.expect("add");
|
git(dir, &["add", "-A"]).await.expect("add");
|
||||||
git_commit(dir, "seed").await.expect("seed commit");
|
git_commit(dir, "seed").await.expect("seed commit");
|
||||||
// A deploy stages a new lock (uncommitted)…
|
// Something stages a new lock (uncommitted)…
|
||||||
std::fs::write(dir.join("flake.lock"), "v2-staged-by-deploy").expect("write");
|
std::fs::write(dir.join("flake.lock"), "v2-staged-by-deploy").expect("write");
|
||||||
git(dir, &["add", "flake.lock"]).await.expect("stage lock");
|
git(dir, &["add", "flake.lock"]).await.expect("stage lock");
|
||||||
// …and a perm change commits, path-limited.
|
// …and a perm change commits, path-limited.
|
||||||
|
|
@ -1620,7 +1437,7 @@ mod tests {
|
||||||
git_commit_paths(dir, "set tool-groups for alice", &["tool-groups.json"])
|
git_commit_paths(dir, "set tool-groups for alice", &["tool-groups.json"])
|
||||||
.await
|
.await
|
||||||
.expect("path-limited commit");
|
.expect("path-limited commit");
|
||||||
// The perm file is committed; the deploy's staged lock is not.
|
// The perm file is committed; the staged lock is not.
|
||||||
assert!(
|
assert!(
|
||||||
!paths_dirty(dir, &["tool-groups.json"])
|
!paths_dirty(dir, &["tool-groups.json"])
|
||||||
.await
|
.await
|
||||||
|
|
@ -1641,15 +1458,6 @@ mod tests {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn agent_input_override_pins_exact_rev() {
|
|
||||||
let p = Path::new("/var/lib/hyperhive/agents/iris/applied");
|
|
||||||
assert_eq!(
|
|
||||||
agent_input_override(p, "abc123def"),
|
|
||||||
"git+file:///var/lib/hyperhive/agents/iris/applied?rev=abc123def"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn applied_override_url_targets_local_mirror_main_head() {
|
fn applied_override_url_targets_local_mirror_main_head() {
|
||||||
// Deploy + rebuild re-lock against the local applied mirror's `main`
|
// Deploy + rebuild re-lock against the local applied mirror's `main`
|
||||||
|
|
|
||||||
|
|
@ -59,31 +59,12 @@ pub fn db_dir() -> PathBuf {
|
||||||
state_root().join("db")
|
state_root().join("db")
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `webhook-secret` — hex-encoded 32-byte HMAC secret shared between
|
|
||||||
/// hive-c0re's webhook handlers and the Forgejo webhook registrations.
|
|
||||||
/// Generated on first startup and persisted. The config-PR hook is replaced
|
|
||||||
/// at the next boot-time registration when this no longer matches
|
|
||||||
/// [`forge_config_pr_webhook_fingerprint`].
|
|
||||||
#[must_use]
|
|
||||||
pub fn webhook_secret_file() -> PathBuf {
|
|
||||||
state_root().join("webhook-secret")
|
|
||||||
}
|
|
||||||
|
|
||||||
/// `forge/` — hive-c0re's own forge provisioning markers.
|
/// `forge/` — hive-c0re's own forge provisioning markers.
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn forge_dir() -> PathBuf {
|
pub fn forge_dir() -> PathBuf {
|
||||||
state_root().join("forge")
|
state_root().join("forge")
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `forge/config-pr-webhook-secret-sha256` — SHA-256 of the webhook secret
|
|
||||||
/// the config-PR hook was last registered with. Forgejo never returns a
|
|
||||||
/// hook's secret, so this is the only record of which one it signs with;
|
|
||||||
/// delete to force the hook to be replaced.
|
|
||||||
#[must_use]
|
|
||||||
pub fn forge_config_pr_webhook_fingerprint() -> PathBuf {
|
|
||||||
forge_dir().join("config-pr-webhook-secret-sha256")
|
|
||||||
}
|
|
||||||
|
|
||||||
/// `forge/core-avatar-set` — marker: core account avatar uploaded.
|
/// `forge/core-avatar-set` — marker: core account avatar uploaded.
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn forge_core_avatar_marker() -> PathBuf {
|
pub fn forge_core_avatar_marker() -> PathBuf {
|
||||||
|
|
|
||||||
|
|
@ -424,7 +424,7 @@ async fn handle_set_resource_limits(
|
||||||
// Goes through `meta::commit_resource_limits`, not the bare
|
// Goes through `meta::commit_resource_limits`, not the bare
|
||||||
// `resource_limits::set_limits`: the write has to be staged +
|
// `resource_limits::set_limits`: the write has to be staged +
|
||||||
// committed under `META_LOCK` or it leaves the meta working tree
|
// committed under `META_LOCK` or it leaves the meta working tree
|
||||||
// dirty for the next `prepare_deploy` / `sync_agents` to trip over.
|
// dirty for the next `sync_agents` to trip over.
|
||||||
crate::meta::commit_resource_limits(name.as_str(), &limits).await?;
|
crate::meta::commit_resource_limits(name.as_str(), &limits).await?;
|
||||||
|
|
||||||
// Re-apply the drop-in straight away — same three lines as the job
|
// Re-apply the drop-in straight away — same three lines as the job
|
||||||
|
|
|
||||||
|
|
@ -1,103 +0,0 @@
|
||||||
//! Config-approval submit helper `submit_merge_config_pr`.
|
|
||||||
//!
|
|
||||||
//! `submit_merge_config_pr` is called from the dashboard webhook handler
|
|
||||||
//! (`dashboard::webhook`) — agents no longer need an MCP tool for config
|
|
||||||
//! changes; opening a config PR on `agent-configs/<agent>` is enough to
|
|
||||||
//! trigger hive-c0re's webhook-driven queue path.
|
|
||||||
|
|
||||||
use std::sync::Arc;
|
|
||||||
|
|
||||||
use crate::coordinator::Coordinator;
|
|
||||||
|
|
||||||
/// Submit-time half of the PR-merge flow: fetch the PR head sha from the
|
|
||||||
/// forge, queue the approval row, and emit the `approval_added` event so the
|
|
||||||
/// dashboard shows the pending card immediately.
|
|
||||||
///
|
|
||||||
/// The PR head sha is stored as `fetched_sha` on the approval row — the
|
|
||||||
/// "reviewed sha" the deploy's `MergeVerify` node drift-gates
|
|
||||||
/// against before doing anything irreversible. This does NOT fetch the commit
|
|
||||||
/// into the applied repo at submission time (that happens inside the approve
|
|
||||||
/// handler, step 2, after the drift check). No flake pre-flight either —
|
|
||||||
/// eval-verify happens at approval time too.
|
|
||||||
pub(crate) async fn submit_merge_config_pr(
|
|
||||||
coord: &Arc<Coordinator>,
|
|
||||||
agent: &str,
|
|
||||||
pr_number: u64,
|
|
||||||
description: Option<&str>,
|
|
||||||
submitter: &str,
|
|
||||||
) -> anyhow::Result<i64> {
|
|
||||||
let applied_dir = crate::paths::applied_dir(agent);
|
|
||||||
if !applied_dir.join(".git").exists() {
|
|
||||||
anyhow::bail!(
|
|
||||||
"applied repo missing for agent '{agent}' (expected at {}) — \
|
|
||||||
merge_config_pr requires the agent to be fully provisioned; \
|
|
||||||
create it first (`swarmctl agent create`) before opening config PRs",
|
|
||||||
applied_dir.display()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
let repo = crate::forge::config_repo(agent);
|
|
||||||
// Verify the PR is still open before queueing an approval that would
|
|
||||||
// fail at approve time anyway (a closed/merged PR has no live head ref
|
|
||||||
// for the drift gate to compare against).
|
|
||||||
if !crate::forge::pr_is_open(&repo, pr_number)
|
|
||||||
.await
|
|
||||||
.map_err(|e| anyhow::anyhow!("check PR state for {agent} PR #{pr_number}: {e}"))?
|
|
||||||
{
|
|
||||||
anyhow::bail!(
|
|
||||||
"PR #{pr_number} on {repo} is closed or already merged — \
|
|
||||||
merge_config_pr requires an open PR"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
// Fetch the current PR head sha — becomes the "reviewed" sha.
|
|
||||||
// Submitted together with the approval row (atomic single INSERT) so a
|
|
||||||
// crash mid-submit cannot leave a stranded sha-less row.
|
|
||||||
let sha = crate::forge::pr_head_sha(&repo, pr_number)
|
|
||||||
.await
|
|
||||||
.map_err(|e| anyhow::anyhow!("fetch PR head sha for {agent} PR #{pr_number}: {e}"))?;
|
|
||||||
// Both the webhook (`synchronize`) and the poll fallback call this on
|
|
||||||
// every PR update. If an approval for this PR is already pending, reconcile
|
|
||||||
// it against the live head sha rather than blindly queuing another:
|
|
||||||
// - same sha → the PR hasn't moved, so this is a duplicate signal — no-op.
|
|
||||||
// - drifted sha → the reviewed head is stale. Don't mutate the
|
|
||||||
// pending row in place (that races a concurrent approve); cancel it and
|
|
||||||
// fall through to queue a FRESH approval pinned to the new head.
|
|
||||||
if let Some((old_id, old_sha)) = coord.approvals.pending_merge_config_pr(agent, pr_number)? {
|
|
||||||
if old_sha.as_deref() == Some(sha.as_str()) {
|
|
||||||
return Ok(old_id);
|
|
||||||
}
|
|
||||||
let cancelled = coord
|
|
||||||
.approvals
|
|
||||||
.mark_cancelled(old_id, "config PR updated — superseded by a fresh approval")
|
|
||||||
.map_err(|e| anyhow::anyhow!("cancel superseded merge_config_pr approval: {e:#}"))?;
|
|
||||||
coord.emit_approval_resolved(crate::coordinator::ApprovalResolved {
|
|
||||||
id: old_id,
|
|
||||||
agent,
|
|
||||||
approval_kind: "merge_config_pr",
|
|
||||||
sha_short: old_sha.map(|s| s[..s.len().min(12)].to_owned()),
|
|
||||||
status: "cancelled",
|
|
||||||
note: Some("PR head moved; superseded by a fresh approval".to_owned()),
|
|
||||||
description: cancelled.description,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
let id = coord
|
|
||||||
.approvals
|
|
||||||
.submit_kind(
|
|
||||||
agent,
|
|
||||||
hive_sh4re::approvals::ApprovalKind::MergeConfigPr,
|
|
||||||
&pr_number.to_string(),
|
|
||||||
description,
|
|
||||||
submitter,
|
|
||||||
Some(&sha), // atomic: sha inserted with the row, not in a separate UPDATE
|
|
||||||
)
|
|
||||||
.map_err(|e| anyhow::anyhow!("queue merge_config_pr approval row: {e:#}"))?;
|
|
||||||
let sha_short = sha[..sha.len().min(12)].to_owned();
|
|
||||||
coord.emit_approval_added(crate::coordinator::ApprovalAdded {
|
|
||||||
id,
|
|
||||||
agent,
|
|
||||||
approval_kind: "merge_config_pr",
|
|
||||||
sha_short: Some(sha_short),
|
|
||||||
description: description.map(str::to_owned),
|
|
||||||
pr_number: Some(pr_number),
|
|
||||||
});
|
|
||||||
Ok(id)
|
|
||||||
}
|
|
||||||
|
|
@ -21,14 +21,10 @@ use tokio::task::JoinHandle;
|
||||||
|
|
||||||
use crate::coordinator::Coordinator;
|
use crate::coordinator::Coordinator;
|
||||||
|
|
||||||
mod config_approvals;
|
|
||||||
mod schedules;
|
mod schedules;
|
||||||
|
|
||||||
pub(crate) use config_approvals::submit_merge_config_pr;
|
|
||||||
pub(crate) use schedules::filter_ghost_schedule_targets;
|
pub(crate) use schedules::filter_ghost_schedule_targets;
|
||||||
pub use schedules::schedule_to_wire_public;
|
pub use schedules::schedule_to_wire_public;
|
||||||
#[cfg(test)]
|
|
||||||
pub(crate) use schedules::tests::coordinator;
|
|
||||||
|
|
||||||
use schedules::{
|
use schedules::{
|
||||||
EditSchedulePatch, handle_cancel_schedule, handle_edit_schedule, handle_fire_schedule_now,
|
EditSchedulePatch, handle_cancel_schedule, handle_edit_schedule, handle_fire_schedule_now,
|
||||||
|
|
@ -735,15 +731,10 @@ fn handle_cancel_loose_end(
|
||||||
.mark_cancelled(id, canceller)
|
.mark_cancelled(id, canceller)
|
||||||
.map_err(|e| format!("{e:#}"))?;
|
.map_err(|e| format!("{e:#}"))?;
|
||||||
tracing::info!(%id, %canceller, agent = %approval.agent, "approval cancelled");
|
tracing::info!(%id, %canceller, agent = %approval.agent, "approval cancelled");
|
||||||
let sha_short = approval
|
|
||||||
.fetched_sha
|
|
||||||
.as_deref()
|
|
||||||
.map(|s| s[..s.len().min(12)].to_owned());
|
|
||||||
coord.emit_approval_resolved(crate::coordinator::ApprovalResolved {
|
coord.emit_approval_resolved(crate::coordinator::ApprovalResolved {
|
||||||
id: approval.id,
|
id: approval.id,
|
||||||
agent: approval.agent.as_str(),
|
agent: approval.agent.as_str(),
|
||||||
approval_kind: <&str>::from(approval.kind),
|
approval_kind: <&str>::from(approval.kind),
|
||||||
sha_short,
|
|
||||||
status: "cancelled",
|
status: "cancelled",
|
||||||
note: approval.note,
|
note: approval.note,
|
||||||
description: approval.description,
|
description: approval.description,
|
||||||
|
|
|
||||||
|
|
@ -75,7 +75,6 @@ pub(super) fn handle_request_schedule_prompt(
|
||||||
&commit_ref,
|
&commit_ref,
|
||||||
payload.description.as_deref(),
|
payload.description.as_deref(),
|
||||||
requester,
|
requester,
|
||||||
None,
|
|
||||||
) {
|
) {
|
||||||
Ok(id) => id,
|
Ok(id) => id,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
|
|
@ -96,9 +95,7 @@ pub(super) fn handle_request_schedule_prompt(
|
||||||
id,
|
id,
|
||||||
agent: requester,
|
agent: requester,
|
||||||
approval_kind: "schedule_prompt",
|
approval_kind: "schedule_prompt",
|
||||||
sha_short: None,
|
|
||||||
description: payload.description.clone(),
|
description: payload.description.clone(),
|
||||||
pr_number: None,
|
|
||||||
});
|
});
|
||||||
Response::Ok
|
Response::Ok
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
//! Approval queue. Requests are submitted by an agent
|
//! Approval queue. Requests are submitted by an agent
|
||||||
//! (`RequestSchedulePrompt`) or the config-PR webhook (`MergeConfigPr`); the
|
//! (`RequestSchedulePrompt`); the user approves/denies via the host admin
|
||||||
//! user approves/denies via the host admin CLI; on approval the host runs the
|
//! CLI; on approval the host runs the corresponding action.
|
||||||
//! corresponding action.
|
|
||||||
//!
|
//!
|
||||||
//! `UpdateMetaInputs` rows are legacy: the MCP tool that queued them was
|
//! `UpdateMetaInputs` rows are legacy: the MCP tool that queued them was
|
||||||
//! removed and nothing produces the kind any more. The variant and
|
//! removed and nothing produces the kind any more. The variant and
|
||||||
|
|
@ -58,6 +57,13 @@ const MIGRATIONS: &[Migration] = &[
|
||||||
sql: "ALTER TABLE approvals ADD COLUMN submitter TEXT",
|
sql: "ALTER TABLE approvals ADD COLUMN submitter TEXT",
|
||||||
adds_column: Some(("approvals", "submitter")),
|
adds_column: Some(("approvals", "submitter")),
|
||||||
},
|
},
|
||||||
|
// v5: drop `merge_config_pr` rows. Config PRs merge on the forge, so no
|
||||||
|
// approval of that kind can be acted on, and `row_to_approval` rejects
|
||||||
|
// the kind.
|
||||||
|
Migration {
|
||||||
|
sql: "DELETE FROM approvals WHERE kind = 'merge_config_pr'",
|
||||||
|
adds_column: None,
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
pub struct Approvals {
|
pub struct Approvals {
|
||||||
|
|
@ -75,10 +81,7 @@ impl Approvals {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Insert a new pending approval row. `fetched_sha` may be supplied
|
/// Insert a new pending approval row.
|
||||||
/// when the sha is already known at submission time (e.g. `MergeConfigPr`
|
|
||||||
/// fetches the PR head before inserting), making the insert + sha-set
|
|
||||||
/// atomic. Pass `None` when the kind carries no sha (e.g. `SchedulePrompt`).
|
|
||||||
pub fn submit_kind(
|
pub fn submit_kind(
|
||||||
&self,
|
&self,
|
||||||
agent: &str,
|
agent: &str,
|
||||||
|
|
@ -86,14 +89,12 @@ impl Approvals {
|
||||||
commit_ref: &str,
|
commit_ref: &str,
|
||||||
description: Option<&str>,
|
description: Option<&str>,
|
||||||
submitter: &str,
|
submitter: &str,
|
||||||
fetched_sha: Option<&str>,
|
|
||||||
) -> Result<i64> {
|
) -> Result<i64> {
|
||||||
let conn = self.conn.lock().unwrap();
|
let conn = self.conn.lock().unwrap();
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"INSERT INTO approvals
|
"INSERT INTO approvals
|
||||||
(agent, kind, commit_ref, requested_at, status, description, submitter,
|
(agent, kind, commit_ref, requested_at, status, description, submitter)
|
||||||
fetched_sha)
|
VALUES (?1, ?2, ?3, ?4, 'pending', ?5, ?6)",
|
||||||
VALUES (?1, ?2, ?3, ?4, 'pending', ?5, ?6, ?7)",
|
|
||||||
params![
|
params![
|
||||||
agent,
|
agent,
|
||||||
<&str>::from(kind),
|
<&str>::from(kind),
|
||||||
|
|
@ -101,7 +102,6 @@ impl Approvals {
|
||||||
Utc::now().timestamp(),
|
Utc::now().timestamp(),
|
||||||
description,
|
description,
|
||||||
submitter,
|
submitter,
|
||||||
fetched_sha,
|
|
||||||
],
|
],
|
||||||
)?;
|
)?;
|
||||||
Ok(conn.last_insert_rowid())
|
Ok(conn.last_insert_rowid())
|
||||||
|
|
@ -128,37 +128,12 @@ impl Approvals {
|
||||||
Ok(submitter)
|
Ok(submitter)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Return the `(id, fetched_sha)` of the pending `merge_config_pr`
|
|
||||||
/// approval for `(agent, pr_number)`, if one exists. Drives
|
|
||||||
/// `submit_merge_config_pr`'s idempotency + PR-drift handling: same
|
|
||||||
/// `fetched_sha` → no new request (the webhook + poll both call submit,
|
|
||||||
/// so re-submits of an unchanged PR must be no-ops); a drifted head →
|
|
||||||
/// cancel this stale row and queue a fresh approval.
|
|
||||||
pub fn pending_merge_config_pr(
|
|
||||||
&self,
|
|
||||||
agent: &str,
|
|
||||||
pr_number: u64,
|
|
||||||
) -> Result<Option<(i64, Option<String>)>> {
|
|
||||||
let conn = self.conn.lock().unwrap();
|
|
||||||
let row = conn
|
|
||||||
.query_row(
|
|
||||||
"SELECT id, fetched_sha FROM approvals \
|
|
||||||
WHERE agent = ?1 AND kind = 'merge_config_pr' \
|
|
||||||
AND commit_ref = ?2 AND status = 'pending' \
|
|
||||||
ORDER BY id DESC LIMIT 1",
|
|
||||||
params![agent, pr_number.to_string()],
|
|
||||||
|row| Ok((row.get(0)?, row.get(1)?)),
|
|
||||||
)
|
|
||||||
.optional()?;
|
|
||||||
Ok(row)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Last `limit` resolved approvals (approved / denied / failed),
|
/// Last `limit` resolved approvals (approved / denied / failed),
|
||||||
/// newest-first. Drives the history tab on the dashboard.
|
/// newest-first. Drives the history tab on the dashboard.
|
||||||
pub fn recent_resolved(&self, limit: u64) -> Result<Vec<Approval>> {
|
pub fn recent_resolved(&self, limit: u64) -> Result<Vec<Approval>> {
|
||||||
let conn = self.conn.lock().unwrap();
|
let conn = self.conn.lock().unwrap();
|
||||||
let mut stmt = conn.prepare(
|
let mut stmt = conn.prepare(
|
||||||
"SELECT id, agent, kind, commit_ref, requested_at, status, resolved_at, note, fetched_sha, description
|
"SELECT id, agent, kind, commit_ref, requested_at, status, resolved_at, note, description
|
||||||
FROM approvals
|
FROM approvals
|
||||||
WHERE status IN ('approved', 'denied', 'failed', 'cancelled')
|
WHERE status IN ('approved', 'denied', 'failed', 'cancelled')
|
||||||
ORDER BY resolved_at DESC, id DESC
|
ORDER BY resolved_at DESC, id DESC
|
||||||
|
|
@ -171,7 +146,7 @@ impl Approvals {
|
||||||
pub fn pending(&self) -> Result<Vec<Approval>> {
|
pub fn pending(&self) -> Result<Vec<Approval>> {
|
||||||
let conn = self.conn.lock().unwrap();
|
let conn = self.conn.lock().unwrap();
|
||||||
let mut stmt = conn.prepare(
|
let mut stmt = conn.prepare(
|
||||||
"SELECT id, agent, kind, commit_ref, requested_at, status, resolved_at, note, fetched_sha, description
|
"SELECT id, agent, kind, commit_ref, requested_at, status, resolved_at, note, description
|
||||||
FROM approvals
|
FROM approvals
|
||||||
WHERE status = 'pending'
|
WHERE status = 'pending'
|
||||||
ORDER BY id ASC",
|
ORDER BY id ASC",
|
||||||
|
|
@ -183,7 +158,7 @@ impl Approvals {
|
||||||
pub fn get(&self, id: i64) -> Result<Option<Approval>> {
|
pub fn get(&self, id: i64) -> Result<Option<Approval>> {
|
||||||
let conn = self.conn.lock().unwrap();
|
let conn = self.conn.lock().unwrap();
|
||||||
conn.query_row(
|
conn.query_row(
|
||||||
"SELECT id, agent, kind, commit_ref, requested_at, status, resolved_at, note, fetched_sha, description
|
"SELECT id, agent, kind, commit_ref, requested_at, status, resolved_at, note, description
|
||||||
FROM approvals WHERE id = ?1",
|
FROM approvals WHERE id = ?1",
|
||||||
params![id],
|
params![id],
|
||||||
row_to_approval,
|
row_to_approval,
|
||||||
|
|
@ -223,7 +198,6 @@ impl Approvals {
|
||||||
status: ApprovalStatus::Approved,
|
status: ApprovalStatus::Approved,
|
||||||
resolved_at: Some(hive_sh4re::wire_time::from_secs(resolved_at)),
|
resolved_at: Some(hive_sh4re::wire_time::from_secs(resolved_at)),
|
||||||
note: None,
|
note: None,
|
||||||
fetched_sha: row.fetched_sha,
|
|
||||||
description: row.description,
|
description: row.description,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
@ -252,7 +226,7 @@ impl Approvals {
|
||||||
|
|
||||||
/// Withdraw a pending approval. Returns the now-updated
|
/// Withdraw a pending approval. Returns the now-updated
|
||||||
/// row so the caller can emit `ApprovalResolved` with the right
|
/// row so the caller can emit `ApprovalResolved` with the right
|
||||||
/// kind / agent / sha. Errors if the approval isn't pending — once
|
/// kind / agent. Errors if the approval isn't pending — once
|
||||||
/// it's approved/denied/failed/cancelled, the resolution is final.
|
/// it's approved/denied/failed/cancelled, the resolution is final.
|
||||||
pub fn mark_cancelled(&self, id: i64, canceller: &str) -> Result<Approval> {
|
pub fn mark_cancelled(&self, id: i64, canceller: &str) -> Result<Approval> {
|
||||||
let mut conn = self.conn.lock().unwrap();
|
let mut conn = self.conn.lock().unwrap();
|
||||||
|
|
@ -286,7 +260,6 @@ impl Approvals {
|
||||||
status: ApprovalStatus::Cancelled,
|
status: ApprovalStatus::Cancelled,
|
||||||
resolved_at: Some(hive_sh4re::wire_time::from_secs(resolved_at)),
|
resolved_at: Some(hive_sh4re::wire_time::from_secs(resolved_at)),
|
||||||
note: Some(note),
|
note: Some(note),
|
||||||
fetched_sha: row.fetched_sha,
|
|
||||||
description: row.description,
|
description: row.description,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
@ -315,15 +288,14 @@ struct ApprovalLookup {
|
||||||
commit_ref: String,
|
commit_ref: String,
|
||||||
requested_at: i64,
|
requested_at: i64,
|
||||||
status: String,
|
status: String,
|
||||||
fetched_sha: Option<String>,
|
|
||||||
description: Option<String>,
|
description: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ApprovalLookup {
|
impl ApprovalLookup {
|
||||||
/// The single-row lookup by id (`?1`). Column order matches
|
/// The single-row lookup by id (`?1`). Column order matches
|
||||||
/// [`ApprovalLookup::from_row`].
|
/// [`ApprovalLookup::from_row`].
|
||||||
const SELECT: &str = "SELECT agent, kind, commit_ref, requested_at, status, fetched_sha, \
|
const SELECT: &str = "SELECT agent, kind, commit_ref, requested_at, status, description \
|
||||||
description FROM approvals WHERE id = ?1";
|
FROM approvals WHERE id = ?1";
|
||||||
|
|
||||||
fn from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<Self> {
|
fn from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<Self> {
|
||||||
let agent: String = row.get(0)?;
|
let agent: String = row.get(0)?;
|
||||||
|
|
@ -340,8 +312,7 @@ impl ApprovalLookup {
|
||||||
commit_ref: row.get(2)?,
|
commit_ref: row.get(2)?,
|
||||||
requested_at: row.get(3)?,
|
requested_at: row.get(3)?,
|
||||||
status: row.get(4)?,
|
status: row.get(4)?,
|
||||||
fetched_sha: row.get(5)?,
|
description: row.get(5)?,
|
||||||
description: row.get(6)?,
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -379,12 +350,11 @@ fn collect_lenient(rows: impl Iterator<Item = rusqlite::Result<Approval>>) -> Ve
|
||||||
}
|
}
|
||||||
|
|
||||||
fn row_to_approval(row: &rusqlite::Row<'_>) -> rusqlite::Result<Approval> {
|
fn row_to_approval(row: &rusqlite::Row<'_>) -> rusqlite::Result<Approval> {
|
||||||
// Column order: id, agent, kind, commit_ref, requested_at, status, resolved_at, note, fetched_sha, description.
|
// Column order: id, agent, kind, commit_ref, requested_at, status, resolved_at, note, description.
|
||||||
let kind: String = row.get(2)?;
|
let kind: String = row.get(2)?;
|
||||||
let kind = match kind.as_str() {
|
let kind = match kind.as_str() {
|
||||||
"update_meta_inputs" => ApprovalKind::UpdateMetaInputs,
|
"update_meta_inputs" => ApprovalKind::UpdateMetaInputs,
|
||||||
"schedule_prompt" => ApprovalKind::SchedulePrompt,
|
"schedule_prompt" => ApprovalKind::SchedulePrompt,
|
||||||
"merge_config_pr" => ApprovalKind::MergeConfigPr,
|
|
||||||
other => {
|
other => {
|
||||||
return Err(rusqlite::Error::FromSqlConversionFailure(
|
return Err(rusqlite::Error::FromSqlConversionFailure(
|
||||||
2,
|
2,
|
||||||
|
|
@ -427,8 +397,7 @@ fn row_to_approval(row: &rusqlite::Row<'_>) -> rusqlite::Result<Approval> {
|
||||||
.get::<_, Option<i64>>(6)?
|
.get::<_, Option<i64>>(6)?
|
||||||
.map(hive_sh4re::wire_time::from_secs),
|
.map(hive_sh4re::wire_time::from_secs),
|
||||||
note: row.get(7)?,
|
note: row.get(7)?,
|
||||||
fetched_sha: row.get(8)?,
|
description: row.get(8)?,
|
||||||
description: row.get(9)?,
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -436,7 +405,6 @@ fn kind_from_str(s: &str) -> Result<ApprovalKind> {
|
||||||
Ok(match s {
|
Ok(match s {
|
||||||
"update_meta_inputs" => ApprovalKind::UpdateMetaInputs,
|
"update_meta_inputs" => ApprovalKind::UpdateMetaInputs,
|
||||||
"schedule_prompt" => ApprovalKind::SchedulePrompt,
|
"schedule_prompt" => ApprovalKind::SchedulePrompt,
|
||||||
"merge_config_pr" => ApprovalKind::MergeConfigPr,
|
|
||||||
other => bail!("unknown approval kind '{other}'"),
|
other => bail!("unknown approval kind '{other}'"),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
@ -456,21 +424,12 @@ mod tests {
|
||||||
#[test]
|
#[test]
|
||||||
fn mixed_kinds_all_listed() {
|
fn mixed_kinds_all_listed() {
|
||||||
let (_dir, _path, db) = open_temp();
|
let (_dir, _path, db) = open_temp();
|
||||||
db.submit_kind(
|
db.submit_kind("b", ApprovalKind::SchedulePrompt, "", None, "b")
|
||||||
"a",
|
|
||||||
ApprovalKind::MergeConfigPr,
|
|
||||||
"deadbeef",
|
|
||||||
None,
|
|
||||||
"a",
|
|
||||||
None,
|
|
||||||
)
|
|
||||||
.unwrap();
|
.unwrap();
|
||||||
db.submit_kind("b", ApprovalKind::SchedulePrompt, "", None, "b", None)
|
db.submit_kind("c", ApprovalKind::UpdateMetaInputs, "[]", None, "c")
|
||||||
.unwrap();
|
|
||||||
db.submit_kind("c", ApprovalKind::UpdateMetaInputs, "[]", None, "c", None)
|
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let pending = db.pending().expect("pending");
|
let pending = db.pending().expect("pending");
|
||||||
assert_eq!(pending.len(), 3, "all three kinds must be visible");
|
assert_eq!(pending.len(), 2, "both kinds must be visible");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|
@ -482,11 +441,10 @@ mod tests {
|
||||||
let id = db
|
let id = db
|
||||||
.submit_kind(
|
.submit_kind(
|
||||||
"bitburner",
|
"bitburner",
|
||||||
ApprovalKind::MergeConfigPr,
|
ApprovalKind::SchedulePrompt,
|
||||||
"cafef00d",
|
"{}",
|
||||||
Some("test"),
|
Some("test"),
|
||||||
"bitburner",
|
"bitburner",
|
||||||
None,
|
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let row = db.mark_cancelled(id, "manager").expect("cancel");
|
let row = db.mark_cancelled(id, "manager").expect("cancel");
|
||||||
|
|
@ -506,14 +464,7 @@ mod tests {
|
||||||
// final — re-cancelling errors instead of silently overwriting.
|
// final — re-cancelling errors instead of silently overwriting.
|
||||||
let (_dir, _path, db) = open_temp();
|
let (_dir, _path, db) = open_temp();
|
||||||
let id = db
|
let id = db
|
||||||
.submit_kind(
|
.submit_kind("a", ApprovalKind::SchedulePrompt, "{}", None, "a")
|
||||||
"a",
|
|
||||||
ApprovalKind::MergeConfigPr,
|
|
||||||
"deadbeef",
|
|
||||||
None,
|
|
||||||
"a",
|
|
||||||
None,
|
|
||||||
)
|
|
||||||
.unwrap();
|
.unwrap();
|
||||||
db.mark_cancelled(id, "manager").expect("first cancel");
|
db.mark_cancelled(id, "manager").expect("first cancel");
|
||||||
let err = db
|
let err = db
|
||||||
|
|
@ -528,14 +479,7 @@ mod tests {
|
||||||
// whole list — collect_lenient skips it instead of failing.
|
// whole list — collect_lenient skips it instead of failing.
|
||||||
let (_dir, path, db) = open_temp();
|
let (_dir, path, db) = open_temp();
|
||||||
let good = db
|
let good = db
|
||||||
.submit_kind(
|
.submit_kind("good", ApprovalKind::SchedulePrompt, "{}", None, "good")
|
||||||
"good",
|
|
||||||
ApprovalKind::MergeConfigPr,
|
|
||||||
"cafe",
|
|
||||||
None,
|
|
||||||
"good",
|
|
||||||
None,
|
|
||||||
)
|
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let raw = Connection::open(&path).unwrap();
|
let raw = Connection::open(&path).unwrap();
|
||||||
raw.execute(
|
raw.execute(
|
||||||
|
|
@ -558,21 +502,14 @@ mod tests {
|
||||||
// fall back to the root agent.
|
// fall back to the root agent.
|
||||||
let (_dir, path, db) = open_temp();
|
let (_dir, path, db) = open_temp();
|
||||||
let id = db
|
let id = db
|
||||||
.submit_kind(
|
.submit_kind("child", ApprovalKind::SchedulePrompt, "{}", None, "parent")
|
||||||
"child",
|
|
||||||
ApprovalKind::MergeConfigPr,
|
|
||||||
"cafe",
|
|
||||||
None,
|
|
||||||
"parent",
|
|
||||||
None,
|
|
||||||
)
|
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(db.submitter_of(id).unwrap().as_deref(), Some("parent"));
|
assert_eq!(db.submitter_of(id).unwrap().as_deref(), Some("parent"));
|
||||||
|
|
||||||
let raw = Connection::open(&path).unwrap();
|
let raw = Connection::open(&path).unwrap();
|
||||||
raw.execute(
|
raw.execute(
|
||||||
"INSERT INTO approvals (agent, kind, commit_ref, requested_at, status)
|
"INSERT INTO approvals (agent, kind, commit_ref, requested_at, status)
|
||||||
VALUES ('old', 'merge_config_pr', '', 0, 'pending')",
|
VALUES ('old', 'schedule_prompt', '', 0, 'pending')",
|
||||||
[],
|
[],
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
@ -580,24 +517,33 @@ mod tests {
|
||||||
assert_eq!(db.submitter_of(legacy_id).unwrap(), None);
|
assert_eq!(db.submitter_of(legacy_id).unwrap(), None);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A database written before v5 still holds `merge_config_pr` rows,
|
||||||
|
/// pending and resolved. Opening it drops them and keeps every other row,
|
||||||
|
/// so the lists and `get` read cleanly.
|
||||||
#[test]
|
#[test]
|
||||||
fn fetched_sha_in_insert_is_readable_via_get() {
|
fn a_pre_v5_merge_config_pr_row_is_dropped_on_open() {
|
||||||
// `submit_kind` with `Some(sha)` must store it atomically in the
|
let (_dir, path, db) = open_temp();
|
||||||
// INSERT — the `get()` row must reflect it without a separate
|
let kept = db
|
||||||
// sha-set step. This is the MergeConfigPr path.
|
.submit_kind("iris", ApprovalKind::SchedulePrompt, "{}", None, "iris")
|
||||||
let (_dir, _path, db) = open_temp();
|
.unwrap();
|
||||||
let sha = "abc1234567890abc1234567890abc1234567890ab";
|
drop(db);
|
||||||
let id = db
|
let raw = Connection::open(&path).unwrap();
|
||||||
.submit_kind(
|
raw.execute_batch(
|
||||||
"janet",
|
"INSERT INTO approvals (agent, kind, commit_ref, requested_at, status, fetched_sha)
|
||||||
ApprovalKind::MergeConfigPr,
|
VALUES ('iris', 'merge_config_pr', '42', 0, 'pending', 'abc123');
|
||||||
"42",
|
INSERT INTO approvals (agent, kind, commit_ref, requested_at, status, resolved_at)
|
||||||
None,
|
VALUES ('iris', 'merge_config_pr', '41', 0, 'approved', 1);
|
||||||
"ruth",
|
UPDATE schema_versions SET version = 4 WHERE store = 'approvals';",
|
||||||
Some(sha),
|
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let row = db.get(id).unwrap().expect("row must exist");
|
let old: i64 = raw.last_insert_rowid();
|
||||||
assert_eq!(row.fetched_sha.as_deref(), Some(sha));
|
drop(raw);
|
||||||
|
|
||||||
|
let db = Approvals::open(&path).expect("a pre-v5 db opens");
|
||||||
|
let pending = db.pending().expect("pending");
|
||||||
|
assert_eq!(pending.len(), 1);
|
||||||
|
assert_eq!(pending[0].id, kept);
|
||||||
|
assert!(db.recent_resolved(10).unwrap().is_empty());
|
||||||
|
assert!(db.get(old).unwrap().is_none());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -314,9 +314,9 @@ async fn handle_deploy_request(
|
||||||
|
|
||||||
/// Move `agent`'s `applied/main` to `rev` ahead of its rebuild, and say
|
/// Move `agent`'s `applied/main` to `rev` ahead of its rebuild, and say
|
||||||
/// whether that rebuild should be queued. A `rev` already applied is taken as
|
/// whether that rebuild should be queued. A `rev` already applied is taken as
|
||||||
/// deployed or deploying — a dashboard merge deploys the PR it merges — so
|
/// deployed or deploying, since every path that moves `applied/main` queues a
|
||||||
/// no second rebuild is queued. That also skips a rev put in place by
|
/// rebuild with it. The exception is `hivectl forge reconcile-config`, which
|
||||||
/// `hivectl forge reconcile-config`, which does not deploy.
|
/// does not deploy, so a rev it put in place is skipped too.
|
||||||
///
|
///
|
||||||
/// A refusal is commented on the PR that merged `rev`. A failed rebuild is
|
/// A refusal is commented on the PR that merged `rev`. A failed rebuild is
|
||||||
/// not: it surfaces where every rebuild failure does, on this hive.
|
/// not: it surfaces where every rebuild failure does, on this hive.
|
||||||
|
|
|
||||||
|
|
@ -1,295 +0,0 @@
|
||||||
//! Webhook HMAC secret — load-or-generate, persist, verify.
|
|
||||||
//!
|
|
||||||
//! A 32-byte secret is generated on first startup, hex-encoded, and stored at
|
|
||||||
//! [`crate::paths::webhook_secret_file()`]. On subsequent starts the same file
|
|
||||||
//! is read back so Forgejo and hive-c0re always share the same key without any
|
|
||||||
//! operator configuration.
|
|
||||||
//!
|
|
||||||
//! The secret is used in two places:
|
|
||||||
//! - **Registration**: passed as the `secret` config key when hive-c0re
|
|
||||||
//! creates (or re-creates) the Forgejo org/repo webhook.
|
|
||||||
//! - **Verification**: each incoming webhook POST is verified against the
|
|
||||||
//! `X-Hub-Signature-256` header Forgejo attaches (`sha256=<hex>`).
|
|
||||||
|
|
||||||
use anyhow::{Context as _, Result};
|
|
||||||
|
|
||||||
/// Load the webhook HMAC secret from disk; generate and persist it if absent.
|
|
||||||
///
|
|
||||||
/// Returns a hex-encoded 32-byte secret string (64 hex chars).
|
|
||||||
pub fn load_or_generate() -> Result<String> {
|
|
||||||
load_or_generate_at(&crate::paths::webhook_secret_file())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The path-taking half of [`load_or_generate`], split out so a test can
|
|
||||||
/// point it at a scratch file — same seam, for the same reason, as
|
|
||||||
/// `swarm-controller`'s `webhook::load_or_generate_at`.
|
|
||||||
fn load_or_generate_at(path: &std::path::Path) -> Result<String> {
|
|
||||||
if let Ok(raw) = std::fs::read_to_string(path) {
|
|
||||||
let trimmed = raw.trim().to_owned();
|
|
||||||
if trimmed.len() == 64 && trimmed.chars().all(|c| c.is_ascii_hexdigit()) {
|
|
||||||
return Ok(trimmed);
|
|
||||||
}
|
|
||||||
// File exists but is malformed — regenerate.
|
|
||||||
tracing::warn!(
|
|
||||||
path = %path.display(),
|
|
||||||
"webhook-secret file malformed (wrong length/chars); regenerating"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
let secret = generate_hex_secret()?;
|
|
||||||
std::fs::create_dir_all(path.parent().unwrap_or(path))
|
|
||||||
.with_context(|| format!("create dir for {}", path.display()))?;
|
|
||||||
std::fs::write(path, format!("{secret}\n"))
|
|
||||||
.with_context(|| format!("write webhook secret to {}", path.display()))?;
|
|
||||||
tracing::info!(path = %path.display(), "webhook secret generated and persisted");
|
|
||||||
Ok(secret)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Whether `secret` is the one the config-PR hook was last registered with,
|
|
||||||
/// per [`crate::paths::forge_config_pr_webhook_fingerprint()`]. A missing or
|
|
||||||
/// unreadable record counts as "not registered".
|
|
||||||
pub fn is_registered(secret: &str) -> bool {
|
|
||||||
is_registered_at(&crate::paths::forge_config_pr_webhook_fingerprint(), secret)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Record `secret` as the one the config-PR hook is now registered with.
|
|
||||||
pub fn record_registered(secret: &str) -> Result<()> {
|
|
||||||
record_registered_at(&crate::paths::forge_config_pr_webhook_fingerprint(), secret)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn is_registered_at(path: &std::path::Path, secret: &str) -> bool {
|
|
||||||
std::fs::read_to_string(path).is_ok_and(|raw| raw.trim() == fingerprint(secret))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn record_registered_at(path: &std::path::Path, secret: &str) -> Result<()> {
|
|
||||||
std::fs::create_dir_all(path.parent().unwrap_or(path))
|
|
||||||
.with_context(|| format!("create dir for {}", path.display()))?;
|
|
||||||
std::fs::write(path, format!("{}\n", fingerprint(secret)))
|
|
||||||
.with_context(|| format!("write webhook secret fingerprint to {}", path.display()))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Hex SHA-256 of `secret` — stored in its place so the record on disk is
|
|
||||||
/// not a second copy of the key.
|
|
||||||
fn fingerprint(secret: &str) -> String {
|
|
||||||
use sha2::{Digest as _, Sha256};
|
|
||||||
hex_encode(&Sha256::digest(secret.as_bytes()))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Read 32 random bytes from `/dev/urandom` and hex-encode them.
|
|
||||||
fn generate_hex_secret() -> Result<String> {
|
|
||||||
use std::io::Read as _;
|
|
||||||
|
|
||||||
let mut buf = [0u8; 32];
|
|
||||||
let mut f =
|
|
||||||
std::fs::File::open("/dev/urandom").context("open /dev/urandom for secret generation")?;
|
|
||||||
f.read_exact(&mut buf)
|
|
||||||
.context("read 32 bytes from /dev/urandom")?;
|
|
||||||
Ok(hex_encode(&buf))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Hex-encode `bytes` as a lowercase string.
|
|
||||||
fn hex_encode(bytes: &[u8]) -> String {
|
|
||||||
let mut out = String::with_capacity(bytes.len() * 2);
|
|
||||||
for b in bytes {
|
|
||||||
out.push(char::from_digit(u32::from(b >> 4), 16).unwrap_or('0'));
|
|
||||||
out.push(char::from_digit(u32::from(b & 0xf), 16).unwrap_or('0'));
|
|
||||||
}
|
|
||||||
out
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Verify a Forgejo `X-Hub-Signature-256` header against `body` using
|
|
||||||
/// `secret`. Returns `Ok(())` when the signature matches, or an error
|
|
||||||
/// describing the mismatch (safe to log; does not expose the secret).
|
|
||||||
///
|
|
||||||
/// Forgejo sends: `sha256=<hex>`.
|
|
||||||
pub fn verify_signature(secret: &str, body: &[u8], header: &str) -> Result<()> {
|
|
||||||
use hmac::{Hmac, KeyInit, Mac};
|
|
||||||
use sha2::Sha256;
|
|
||||||
|
|
||||||
let sig_hex = header
|
|
||||||
.strip_prefix("sha256=")
|
|
||||||
.ok_or_else(|| anyhow::anyhow!("X-Hub-Signature-256 missing 'sha256=' prefix"))?;
|
|
||||||
|
|
||||||
let expected = hex_decode(sig_hex)
|
|
||||||
.ok_or_else(|| anyhow::anyhow!("X-Hub-Signature-256 contains non-hex chars"))?;
|
|
||||||
|
|
||||||
let mut mac = Hmac::<Sha256>::new_from_slice(secret.as_bytes())
|
|
||||||
.map_err(|e| anyhow::anyhow!("HMAC key error: {e}"))?;
|
|
||||||
mac.update(body);
|
|
||||||
mac.verify_slice(&expected)
|
|
||||||
.map_err(|_| anyhow::anyhow!("X-Hub-Signature-256 mismatch"))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Decode a lowercase hex string into bytes; returns `None` on invalid input.
|
|
||||||
fn hex_decode(s: &str) -> Option<Vec<u8>> {
|
|
||||||
if !s.len().is_multiple_of(2) {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
let mut out = Vec::with_capacity(s.len() / 2);
|
|
||||||
let mut chars = s.chars();
|
|
||||||
while let (Some(hi), Some(lo)) = (chars.next(), chars.next()) {
|
|
||||||
let hi = u8::try_from(hi.to_digit(16)?).ok()?;
|
|
||||||
let lo = u8::try_from(lo.to_digit(16)?).ok()?;
|
|
||||||
out.push((hi << 4) | lo);
|
|
||||||
}
|
|
||||||
Some(out)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::{
|
|
||||||
hex_encode, is_registered_at, load_or_generate_at, record_registered_at, verify_signature,
|
|
||||||
};
|
|
||||||
|
|
||||||
/// No record is the state of every hive before its first replacement,
|
|
||||||
/// and must read as "not registered" so that hook gets replaced once.
|
|
||||||
#[test]
|
|
||||||
fn a_secret_with_no_fingerprint_on_record_is_not_registered() {
|
|
||||||
let dir = tempfile::tempdir().expect("tempdir");
|
|
||||||
let path = dir.path().join("forge").join("fingerprint");
|
|
||||||
assert!(!is_registered_at(&path, &"a".repeat(64)));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn a_recorded_secret_is_registered_and_a_changed_one_is_not() {
|
|
||||||
let dir = tempfile::tempdir().expect("tempdir");
|
|
||||||
let path = dir.path().join("forge").join("fingerprint");
|
|
||||||
let old = "a".repeat(64);
|
|
||||||
let new = "b".repeat(64);
|
|
||||||
|
|
||||||
record_registered_at(&path, &old).expect("record");
|
|
||||||
assert!(
|
|
||||||
is_registered_at(&path, &old),
|
|
||||||
"unchanged secret: keep the hook"
|
|
||||||
);
|
|
||||||
assert!(
|
|
||||||
!is_registered_at(&path, &new),
|
|
||||||
"changed secret: replace the hook"
|
|
||||||
);
|
|
||||||
assert!(
|
|
||||||
!std::fs::read_to_string(&path)
|
|
||||||
.expect("read back")
|
|
||||||
.contains(&old),
|
|
||||||
"the record must not be a copy of the secret"
|
|
||||||
);
|
|
||||||
|
|
||||||
record_registered_at(&path, &new).expect("re-record");
|
|
||||||
assert!(is_registered_at(&path, &new));
|
|
||||||
assert!(!is_registered_at(&path, &old));
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Compute the `sha256=<hex>` header Forgejo would send for `secret` +
|
|
||||||
/// `body`, so the "matches" test below isn't asserting against a
|
|
||||||
/// hand-picked string that happens to look like a signature.
|
|
||||||
fn sign(secret: &str, body: &[u8]) -> String {
|
|
||||||
use hmac::{Hmac, KeyInit, Mac};
|
|
||||||
use sha2::Sha256;
|
|
||||||
let mut mac = Hmac::<Sha256>::new_from_slice(secret.as_bytes()).unwrap();
|
|
||||||
mac.update(body);
|
|
||||||
format!("sha256={}", hex_encode(&mac.finalize().into_bytes()))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// `verify_hmac` (`dashboard/webhook.rs`) delegates the actual HMAC
|
|
||||||
/// comparison here — this is the only gate on an inbound Forgejo
|
|
||||||
/// webhook reachable via the public gateway. A signature computed
|
|
||||||
/// with the right secret over the right body must verify.
|
|
||||||
#[test]
|
|
||||||
fn verify_signature_accepts_the_correct_hmac() {
|
|
||||||
let header = sign("s3cr3t", b"payload");
|
|
||||||
assert!(verify_signature("s3cr3t", b"payload", &header).is_ok());
|
|
||||||
}
|
|
||||||
|
|
||||||
/// If `mac.verify_slice`'s result were ever inverted (accept on
|
|
||||||
/// mismatch), an unauthenticated actor could queue an operator
|
|
||||||
/// approval through the public webhook endpoint. Cover both ways a
|
|
||||||
/// signature can stop matching: wrong secret, and a body that no
|
|
||||||
/// longer matches the one that was signed.
|
|
||||||
#[test]
|
|
||||||
fn verify_signature_rejects_a_mismatched_hmac() {
|
|
||||||
let header = sign("s3cr3t", b"payload");
|
|
||||||
assert!(verify_signature("different-secret", b"payload", &header).is_err());
|
|
||||||
assert!(verify_signature("s3cr3t", b"tampered-payload", &header).is_err());
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A stored secret that already parses must be handed back exactly as
|
|
||||||
/// written, on every start. This is the property Forgejo's copy depends
|
|
||||||
/// on: the secret is registered *with Forgejo* once, so a load that
|
|
||||||
/// rotates a perfectly good value silently invalidates every webhook
|
|
||||||
/// delivery afterwards — and it surfaces as an outage, not as anything
|
|
||||||
/// security-shaped. The trailing newline is the one this module writes
|
|
||||||
/// itself (`format!("{secret}\n")`), so the trim is load-bearing rather
|
|
||||||
/// than defensive: without it the file this code just wrote reads back
|
|
||||||
/// as 65 chars and fails its own validity check on the next boot.
|
|
||||||
#[test]
|
|
||||||
fn a_valid_stored_secret_is_returned_verbatim_and_never_rotated() {
|
|
||||||
let dir = tempfile::tempdir().expect("tempdir");
|
|
||||||
let path = dir.path().join("webhook-secret");
|
|
||||||
let seeded = "a".repeat(64);
|
|
||||||
std::fs::write(&path, format!("{seeded}\n")).expect("seed");
|
|
||||||
|
|
||||||
assert_eq!(
|
|
||||||
load_or_generate_at(&path).expect("load"),
|
|
||||||
seeded,
|
|
||||||
"a valid secret must be read back, not regenerated"
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
load_or_generate_at(&path).expect("second load"),
|
|
||||||
seeded,
|
|
||||||
"and still on the next start"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The regeneration branch has two halves and only one of them is in the
|
|
||||||
/// return value: the replacement must also be *persisted*, or every
|
|
||||||
/// restart mints a fresh secret and the registration in Forgejo is never
|
|
||||||
/// the one being verified against.
|
|
||||||
#[test]
|
|
||||||
fn a_malformed_secret_file_is_replaced_by_a_valid_persisted_one() {
|
|
||||||
let dir = tempfile::tempdir().expect("tempdir");
|
|
||||||
let path = dir.path().join("webhook-secret");
|
|
||||||
std::fs::write(&path, "not-a-hex-secret\n").expect("seed");
|
|
||||||
|
|
||||||
let secret = load_or_generate_at(&path).expect("regenerates");
|
|
||||||
assert_eq!(secret.len(), 64, "hex-encoded 32 bytes");
|
|
||||||
assert!(secret.chars().all(|c| c.is_ascii_hexdigit()));
|
|
||||||
assert_eq!(
|
|
||||||
std::fs::read_to_string(&path).expect("read back").trim(),
|
|
||||||
secret,
|
|
||||||
"the regenerated secret must reach disk, not just the caller"
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
load_or_generate_at(&path).expect("second load"),
|
|
||||||
secret,
|
|
||||||
"and must then be stable across a restart"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The length + charset check is what stops a truncated or half-written
|
|
||||||
/// file from being adopted as the HMAC key. `Hmac::new_from_slice`
|
|
||||||
/// accepts a key of *any* length, empty included — so relaxing this
|
|
||||||
/// check does not fail anywhere, it just silently keys every signature
|
|
||||||
/// off a value an attacker can guess. Each near miss is listed
|
|
||||||
/// separately so a check that stops distinguishing one of them shows up
|
|
||||||
/// as that case rather than as a single opaque failure.
|
|
||||||
#[test]
|
|
||||||
fn a_near_miss_secret_file_is_not_adopted_as_the_key() {
|
|
||||||
for (label, seeded) in [
|
|
||||||
("empty file", String::new()),
|
|
||||||
("whitespace only", " \n".to_owned()),
|
|
||||||
("one hex digit short", "b".repeat(63)),
|
|
||||||
("one hex digit long", "b".repeat(65)),
|
|
||||||
("right length, non-hex char", format!("z{}", "b".repeat(63))),
|
|
||||||
] {
|
|
||||||
let dir = tempfile::tempdir().expect("tempdir");
|
|
||||||
let path = dir.path().join("webhook-secret");
|
|
||||||
std::fs::write(&path, &seeded).expect("seed");
|
|
||||||
|
|
||||||
let secret = load_or_generate_at(&path).expect("regenerates");
|
|
||||||
assert_ne!(secret, seeded.trim(), "{label} must not become the key");
|
|
||||||
assert_eq!(secret.len(), 64, "{label}: replacement is 64 hex chars");
|
|
||||||
assert!(
|
|
||||||
secret.chars().all(|c| c.is_ascii_hexdigit()),
|
|
||||||
"{label}: replacement is hex"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -453,14 +453,13 @@ fn boot_action(wanted: Option<crate::power::Wanted>, fresh: bool, running: bool)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Submit the boot-time forge/matrix/webhook/knowledge/wanted-state sweeps as
|
/// Submit the boot-time forge/matrix/knowledge/wanted-state sweeps as DAG
|
||||||
/// DAG nodes — `ForgeSweep`, `MatrixSweep`, `WebhookRegister`,
|
/// nodes — `ForgeSweep`, `MatrixSweep`, `KnowledgePull`, `WantedPull`. Unlike
|
||||||
/// `KnowledgePull`, `WantedPull`. Unlike
|
|
||||||
/// [`submit_boot_tree`] this runs on **every** boot, quiet or not: these
|
/// [`submit_boot_tree`] this runs on **every** boot, quiet or not: these
|
||||||
/// aren't config-drift work, they're startup housekeeping that always needs
|
/// aren't config-drift work, they're startup housekeeping that always needs
|
||||||
/// to happen, and the point of moving them here is exactly so they show up
|
/// to happen, and the point of moving them here is exactly so they show up
|
||||||
/// as real work on the dashboard instead of an invisible `tokio::spawn` that
|
/// as real work on the dashboard instead of an invisible `tokio::spawn` that
|
||||||
/// only surfaces on failure. Five independent, build-slot- and lease-exempt
|
/// only surfaces on failure. Four independent, build-slot- and lease-exempt
|
||||||
/// roots — no dependency edges between them, matching the existing
|
/// roots — no dependency edges between them, matching the existing
|
||||||
/// `Reconcile`-root pattern in [`boot_nodes`]. `MatrixSweep` and
|
/// `Reconcile`-root pattern in [`boot_nodes`]. `MatrixSweep` and
|
||||||
/// `KnowledgePull` hold their sweep's own resource, so each queues behind any
|
/// `KnowledgePull` hold their sweep's own resource, so each queues behind any
|
||||||
|
|
@ -471,7 +470,6 @@ fn submit_startup_sweep_nodes(coord: &Arc<Coordinator>) {
|
||||||
if let Err(e) = coord.job_queue.insert_job(|b| {
|
if let Err(e) = coord.job_queue.insert_job(|b| {
|
||||||
let _ = b.node(NodeKind::ForgeSweep);
|
let _ = b.node(NodeKind::ForgeSweep);
|
||||||
let _ = crate::job_queue::templates::matrix_sweep(b);
|
let _ = crate::job_queue::templates::matrix_sweep(b);
|
||||||
let _ = b.node(NodeKind::WebhookRegister);
|
|
||||||
let _ = crate::job_queue::templates::knowledge_pull(b);
|
let _ = crate::job_queue::templates::knowledge_pull(b);
|
||||||
let _ = b.node(NodeKind::WantedPull);
|
let _ = b.node(NodeKind::WantedPull);
|
||||||
Vec::new()
|
Vec::new()
|
||||||
|
|
|
||||||
|
|
@ -23,7 +23,7 @@ const DEFAULT_URL: &str = "http://localhost:3000";
|
||||||
/// Bound on reaching the forge.
|
/// Bound on reaching the forge.
|
||||||
const HTTP_CONNECT_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(5);
|
const HTTP_CONNECT_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(5);
|
||||||
/// Whole-request budget for a small JSON call (an `/api/v1` GET, or the
|
/// Whole-request budget for a small JSON call (an `/api/v1` GET, or the
|
||||||
/// run-view log streamer's snapshot POST) — `config_pr_poll`'s forge budget.
|
/// run-view log streamer's snapshot POST).
|
||||||
const JSON_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(15);
|
const JSON_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(15);
|
||||||
/// Whole-request budget for a GET that downloads a file body — an
|
/// Whole-request budget for a GET that downloads a file body — an
|
||||||
/// attachment, an Actions artifact zip, or a persisted job log. Sized
|
/// attachment, an Actions artifact zip, or a persisted job log. Sized
|
||||||
|
|
|
||||||
|
|
@ -14,17 +14,10 @@ use serde::{Deserialize, Serialize};
|
||||||
pub struct Approval {
|
pub struct Approval {
|
||||||
pub id: i64,
|
pub id: i64,
|
||||||
pub agent: Ident,
|
pub agent: Ident,
|
||||||
#[serde(default)]
|
|
||||||
pub kind: ApprovalKind,
|
pub kind: ApprovalKind,
|
||||||
/// Kind-specific payload (git sha / inputs array / schedule
|
/// Kind-specific payload (git sha / inputs array / schedule
|
||||||
/// payload / empty). See the Approval struct doc.
|
/// payload / empty). See the Approval struct doc.
|
||||||
pub commit_ref: String,
|
pub commit_ref: String,
|
||||||
/// The canonical hive-c0re-vouched sha. For `MergeConfigPr`: the
|
|
||||||
/// reviewed PR head pinned at submit; if the PR head drifts off it
|
|
||||||
/// before merge, hive-c0re cancels the stale approval and re-queues a
|
|
||||||
/// fresh one for re-review.
|
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
||||||
pub fetched_sha: Option<String>,
|
|
||||||
pub requested_at: DateTime<Utc>,
|
pub requested_at: DateTime<Utc>,
|
||||||
pub status: ApprovalStatus,
|
pub status: ApprovalStatus,
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
|
@ -40,9 +33,7 @@ pub struct Approval {
|
||||||
/// What action the approval, when granted, will trigger.
|
/// What action the approval, when granted, will trigger.
|
||||||
/// Variant-specific payload encoding + flow lives in
|
/// Variant-specific payload encoding + flow lives in
|
||||||
/// `docs/agent-lifecycle/approvals.md::Approval kinds (wire shapes)`.
|
/// `docs/agent-lifecycle/approvals.md::Approval kinds (wire shapes)`.
|
||||||
#[derive(
|
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, strum::IntoStaticStr)]
|
||||||
Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq, strum::IntoStaticStr,
|
|
||||||
)]
|
|
||||||
#[serde(rename_all = "snake_case")]
|
#[serde(rename_all = "snake_case")]
|
||||||
#[strum(serialize_all = "snake_case")]
|
#[strum(serialize_all = "snake_case")]
|
||||||
pub enum ApprovalKind {
|
pub enum ApprovalKind {
|
||||||
|
|
@ -51,15 +42,6 @@ pub enum ApprovalKind {
|
||||||
UpdateMetaInputs,
|
UpdateMetaInputs,
|
||||||
/// Add a scheduled prompt to the broker queue.
|
/// Add a scheduled prompt to the broker queue.
|
||||||
SchedulePrompt,
|
SchedulePrompt,
|
||||||
/// Merge an operator-reviewed config PR: hive-c0re verifies the
|
|
||||||
/// reviewed PR head, fast-forwards the forge config repo's `main`
|
|
||||||
/// to it, marks the PR merged, then runs the deploy tail. This is the
|
|
||||||
/// sole config-change flow — a manager opens a PR on its
|
|
||||||
/// `agent-configs/<agent>` repo and the operator reviews + approves it.
|
|
||||||
/// `commit_ref` = PR number; `fetched_sha` = the reviewed PR head
|
|
||||||
/// pinned at submit. See `docs/agent-lifecycle/approvals.md`.
|
|
||||||
#[default]
|
|
||||||
MergeConfigPr,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
|
|
||||||
|
|
@ -55,10 +55,6 @@ pub enum HelperEvent {
|
||||||
status: ApprovalStatus,
|
status: ApprovalStatus,
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
note: Option<String>,
|
note: Option<String>,
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
||||||
sha: Option<String>,
|
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
||||||
tag: Option<String>,
|
|
||||||
},
|
},
|
||||||
/// A sub-agent's recorded flake rev is stale relative to hyperhive.
|
/// A sub-agent's recorded flake rev is stale relative to hyperhive.
|
||||||
NeedsUpdate { agent: String },
|
NeedsUpdate { agent: String },
|
||||||
|
|
|
||||||
|
|
@ -135,9 +135,7 @@ let
|
||||||
|
|
||||||
# Shared auth block — separate locations don't inherit auth_basic, so
|
# Shared auth block — separate locations don't inherit auth_basic, so
|
||||||
# each dashboard location (`/`, `/api/`) needs it or that surface is
|
# each dashboard location (`/`, `/api/`) needs it or that surface is
|
||||||
# unauthed. `/webhook/` is intentionally excluded: Forgejo cannot
|
# unauthed.
|
||||||
# send HTTP Basic credentials with webhook deliveries, and the HMAC
|
|
||||||
# secret (`X-Hub-Signature-256`) protects those endpoints instead.
|
|
||||||
dashboardAuth = lib.optionalString cfg.auth.enable ''
|
dashboardAuth = lib.optionalString cfg.auth.enable ''
|
||||||
auth_basic "${cfg.auth.realm}";
|
auth_basic "${cfg.auth.realm}";
|
||||||
auth_basic_user_file /var/lib/hive-gateway/conf/gateway.htpasswd;
|
auth_basic_user_file /var/lib/hive-gateway/conf/gateway.htpasswd;
|
||||||
|
|
@ -147,15 +145,14 @@ let
|
||||||
'';
|
'';
|
||||||
|
|
||||||
# Dashboard: nginx static-serves the dist, c0re is API-only. Routing
|
# Dashboard: nginx static-serves the dist, c0re is API-only. Routing
|
||||||
# is by PATH, never content-type. c0re serves exactly three prefixes —
|
# is by PATH, never content-type. c0re serves exactly two prefixes —
|
||||||
# `/api/` (all dashboard data + actions + the SSE streams), `/webhook/`
|
# `/api/` (all dashboard data + actions + the SSE streams) and
|
||||||
# (knowledge push + config-PR approval triggers, HMAC-guarded), and
|
|
||||||
# `/health/` (liveness + readiness) — so those proxy to c0re and
|
# `/health/` (liveness + readiness) — so those proxy to c0re and
|
||||||
# everything else serves the dist with an SPA fallback to index.html.
|
# everything else serves the dist with an SPA fallback to index.html.
|
||||||
# Path routing is deterministic where an Accept-header split would make
|
# Path routing is deterministic where an Accept-header split would make
|
||||||
# the SAME url behave differently by content-type (e.g. `/api/state`
|
# the SAME url behave differently by content-type (e.g. `/api/state`
|
||||||
# fetched with `Accept: text/html` wrongly getting index.html). A new
|
# fetched with `Accept: text/html` wrongly getting index.html). A new
|
||||||
# top-level c0re route prefix (beyond /api + /webhook + /health) needs
|
# top-level c0re route prefix (beyond /api + /health) needs
|
||||||
# a matching location added here.
|
# a matching location added here.
|
||||||
dashboardProxyLocation = {
|
dashboardProxyLocation = {
|
||||||
"/" = {
|
"/" = {
|
||||||
|
|
@ -176,15 +173,8 @@ let
|
||||||
${dashboardAuth}
|
${dashboardAuth}
|
||||||
'';
|
'';
|
||||||
};
|
};
|
||||||
"/webhook/" = {
|
|
||||||
# No dashboardAuth here: Forgejo cannot send HTTP Basic credentials
|
|
||||||
# with webhook deliveries. HMAC (X-Hub-Signature-256) is the auth
|
|
||||||
# for these endpoints; hive-c0re verifies it in the handler.
|
|
||||||
proxyPass = "http://${cfg.upstreamHost}:${toString cfg.upstreamPort}";
|
|
||||||
};
|
|
||||||
"/health/" = {
|
"/health/" = {
|
||||||
# No dashboardAuth here either, for a different reason than
|
# No dashboardAuth here: an external uptime monitor generally can't do
|
||||||
# /webhook/: an external uptime monitor generally can't do
|
|
||||||
# interactive HTTP Basic. The endpoints themselves are scoped to
|
# interactive HTTP Basic. The endpoints themselves are scoped to
|
||||||
# status + warning kind/message (see hive-c0re/src/dashboard/
|
# status + warning kind/message (see hive-c0re/src/dashboard/
|
||||||
# health.rs) — no tokens, no agent detail — so exposing them
|
# health.rs) — no tokens, no agent detail — so exposing them
|
||||||
|
|
|
||||||
|
|
@ -1,18 +1,15 @@
|
||||||
//! Swarm-level config-PR status, kept current by both a webhook nudge and a
|
//! Swarm-level config-PR status, kept current by both a webhook nudge and a
|
||||||
//! periodic poll.
|
//! periodic poll.
|
||||||
//!
|
//!
|
||||||
//! `hive-c0re::forge::config_pr_poll` already scans `agent-configs/*` for
|
|
||||||
//! open PRs — but it does that once per hive, to queue that hive's own
|
|
||||||
//! `MergeConfigPr` approval, and nothing at swarm level reads the result.
|
|
||||||
//! swarm-ui's config-PR panel needs a *swarm*-level answer to "does agent X
|
//! swarm-ui's config-PR panel needs a *swarm*-level answer to "does agent X
|
||||||
//! have an open config PR" that does not depend on which hive currently
|
//! have an open config PR" that does not depend on which hive currently
|
||||||
//! hosts X being reachable.
|
//! hosts X being reachable.
|
||||||
//!
|
//!
|
||||||
//! Both paths write the same [`ConfigPrCache`]:
|
//! Both paths write the same [`ConfigPrCache`]:
|
||||||
//!
|
//!
|
||||||
//! - [`spawn`] — a periodic full rescan, mirroring `hive-c0re`'s own poll
|
//! - [`spawn`] — a periodic full rescan. The backstop: catches anything a
|
||||||
//! shape. The backstop: catches anything a missed delivery loses, and is
|
//! missed delivery loses, and is what populates the cache before the first
|
||||||
//! what populates the cache before the first delivery ever arrives.
|
//! delivery ever arrives.
|
||||||
//! - [`ConfigPrCache::apply_webhook_delivery`] — called from
|
//! - [`ConfigPrCache::apply_webhook_delivery`] — called from
|
||||||
//! `crate::webhook::post_webhook_forge` on a verified `ConfigPr` delivery.
|
//! `crate::webhook::post_webhook_forge` on a verified `ConfigPr` delivery.
|
||||||
//! The low-latency path: a PR opening or closing shows up immediately
|
//! The low-latency path: a PR opening or closing shows up immediately
|
||||||
|
|
@ -21,11 +18,6 @@
|
||||||
//! [`merged`] reads the same delivery for a merge, which the webhook handler
|
//! [`merged`] reads the same delivery for a merge, which the webhook handler
|
||||||
//! turns into a deploy. The poll has no counterpart: it lists open PRs only,
|
//! turns into a deploy. The poll has no counterpart: it lists open PRs only,
|
||||||
//! so a merge whose delivery is lost deploys nothing.
|
//! so a merge whose delivery is lost deploys nothing.
|
||||||
//!
|
|
||||||
//! Per mara's review call: ship both from the start rather than the poll
|
|
||||||
//! alone — the eventual swarm-level replacement for `hive-c0re`'s own
|
|
||||||
//! poll+webhook pair needs both anyway, so building only half here would be
|
|
||||||
//! work redone rather than work reused.
|
|
||||||
|
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::sync::{Arc, Mutex};
|
use std::sync::{Arc, Mutex};
|
||||||
|
|
@ -119,9 +111,7 @@ struct WebhookRepository {
|
||||||
name: String,
|
name: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// How often to rescan `agent-configs/*`. Matches the interval named in
|
/// How often to rescan `agent-configs/*`.
|
||||||
/// `hive-c0re::forge::config_pr_poll`'s own doc comment — same org, same
|
|
||||||
/// staleness tolerance, no reason for the two to disagree.
|
|
||||||
const POLL_INTERVAL: std::time::Duration = std::time::Duration::from_mins(5);
|
const POLL_INTERVAL: std::time::Duration = std::time::Duration::from_mins(5);
|
||||||
|
|
||||||
/// The latest full scan, replaced atomically each cycle.
|
/// The latest full scan, replaced atomically each cycle.
|
||||||
|
|
|
||||||
|
|
@ -603,10 +603,7 @@ impl Client {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Every agent in [`CONFIG_ORG`] with an open config PR, keyed by agent
|
/// Every agent in [`CONFIG_ORG`] with an open config PR, keyed by agent
|
||||||
/// name. Mirrors `hive-c0re::forge::config_pr_poll::poll_open_config_prs`'s
|
/// name: list repos in the org, then open PRs per repo. It exists so `GET
|
||||||
/// scan shape (list repos in the org, list open PRs per repo) but returns
|
|
||||||
/// data instead of side-effecting an approval queue — this daemon has no
|
|
||||||
/// approval system of its own; it exists so `GET
|
|
||||||
/// /api/agents/{name}/config-pr` has something to answer from,
|
/// /api/agents/{name}/config-pr` has something to answer from,
|
||||||
/// independent of any one hive being up.
|
/// independent of any one hive being up.
|
||||||
///
|
///
|
||||||
|
|
@ -649,9 +646,7 @@ impl Client {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
// Only the first open PR matters for the panel — a config repo
|
// Only the first open PR matters for the panel — a config repo
|
||||||
// is meant to carry at most one live proposal at a time (the
|
// is meant to carry at most one live proposal at a time.
|
||||||
// same assumption `hive-c0re`'s poller and the `MergeConfigPr`
|
|
||||||
// approval flow both make).
|
|
||||||
if let Some(pr) = prs.into_iter().next() {
|
if let Some(pr) = prs.into_iter().next() {
|
||||||
let Some(pr_number) = pr.number.and_then(|n| u64::try_from(n).ok()) else {
|
let Some(pr_number) = pr.number.and_then(|n| u64::try_from(n).ok()) else {
|
||||||
continue;
|
continue;
|
||||||
|
|
@ -900,19 +895,9 @@ impl Client {
|
||||||
/// forge event reaches [`crate::webhook`] instead of the endpoint only
|
/// forge event reaches [`crate::webhook`] instead of the endpoint only
|
||||||
/// being reachable by hand.
|
/// being reachable by hand.
|
||||||
///
|
///
|
||||||
/// **These are registered ALONGSIDE the per-hive hooks, not instead of
|
/// Adds its own hooks only. A hive's `/webhook/config-pr` hook left on
|
||||||
/// them.** Every hive keeps receiving and acting on its own deliveries
|
/// the `agent-configs` org by an older release stays registered and
|
||||||
/// exactly as today; the controller receives a copy and (for now) logs
|
/// delivers to a route no hive serves.
|
||||||
/// 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
|
/// Idempotent: an existing hook with the same `target_url` is left
|
||||||
/// alone, so this is safe on every boot.
|
/// alone, so this is safe on every boot.
|
||||||
|
|
@ -921,8 +906,7 @@ impl Client {
|
||||||
///
|
///
|
||||||
/// Returns the first failure. A listing failure is not fatal — it falls
|
/// Returns the first failure. A listing failure is not fatal — it falls
|
||||||
/// through to the create attempt, which is idempotent server-side by way
|
/// through to the create attempt, which is idempotent server-side by way
|
||||||
/// of the already-exists fold, the same best-effort shape `hive-c0re`
|
/// of the already-exists fold.
|
||||||
/// uses.
|
|
||||||
pub async fn ensure_swarm_webhooks(&self, public_base: &str, secret: &str) -> Result<()> {
|
pub async fn ensure_swarm_webhooks(&self, public_base: &str, secret: &str) -> Result<()> {
|
||||||
for kind in DeliveryKind::ALL {
|
for kind in DeliveryKind::ALL {
|
||||||
let target_url = kind.target_url(public_base);
|
let target_url = kind.target_url(public_base);
|
||||||
|
|
@ -989,7 +973,7 @@ impl Client {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
// Best-effort, same as the per-hive registrars: a forge that
|
// Best-effort: a forge that
|
||||||
// cannot be listed may still accept a create, and a
|
// cannot be listed may still accept a create, and a
|
||||||
// duplicate create is folded into success below. `warn!`
|
// duplicate create is folded into success below. `warn!`
|
||||||
// because that fold is an assumption about the forge, not a
|
// because that fold is an assumption about the forge, not a
|
||||||
|
|
@ -1054,8 +1038,7 @@ fn transitive_reach(start: i64, adj: &HashMap<i64, Vec<i64>>) -> usize {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Where a hook lives. The knowledge hook is repo-scoped and the config-PR
|
/// 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
|
/// hook is org-scoped — a hook on the wrong scope would never fire, and forgejo would
|
||||||
/// theirs — a hook on the wrong scope would never fire, and forgejo would
|
|
||||||
/// report that as a perfectly healthy hook with no deliveries.
|
/// report that as a perfectly healthy hook with no deliveries.
|
||||||
enum HookScope<'a> {
|
enum HookScope<'a> {
|
||||||
Repo {
|
Repo {
|
||||||
|
|
|
||||||
|
|
@ -10,16 +10,6 @@
|
||||||
//! the payload and emits a semantic message — *the knowledge repo changed*,
|
//! the payload and emits a semantic message — *the knowledge repo changed*,
|
||||||
//! *deploy agent X at rev Y* — addressed to the hives that need it. One place
|
//! *deploy agent X at rev Y* — addressed to the hives that need it. One place
|
||||||
//! decides what a forge payload means, so no hive re-derives it.
|
//! decides what a forge payload means, so no hive re-derives it.
|
||||||
//!
|
|
||||||
//! Receipt is all that is wired today: the payload is opaque bytes keyed by a
|
|
||||||
//! [`DeliveryKind`] from the URL path, and nothing consumes it until the
|
|
||||||
//! swarm→hive channel exists.
|
|
||||||
//!
|
|
||||||
//! The HMAC code is deliberately **not** shared with `hive-c0re`: that copy is
|
|
||||||
//! leaving, and a shared crate is right only when a second consumer arrives.
|
|
||||||
//!
|
|
||||||
//! **These hooks are registered ALONGSIDE the per-hive ones** — see
|
|
||||||
//! [`crate::forge::Client::ensure_swarm_webhooks`].
|
|
||||||
|
|
||||||
use anyhow::{Context as _, Result};
|
use anyhow::{Context as _, Result};
|
||||||
use axum::{
|
use axum::{
|
||||||
|
|
@ -199,15 +189,11 @@ pub(super) enum DeliveryKind {
|
||||||
|
|
||||||
/// The route prefix a registered `target_url` must point at.
|
/// The route prefix a registered `target_url` must point at.
|
||||||
///
|
///
|
||||||
/// ⚠️ Deliberately **not** `/webhook/knowledge` or `/webhook/config-pr`, the
|
/// ⚠️ Deliberately **not** `/webhook/knowledge` or `/webhook/config-pr`.
|
||||||
/// paths the per-hive receivers use. A hive-side registrar deletes any hook
|
/// Older `hive-c0re` releases delete, on every boot, any hook whose URL ends
|
||||||
/// whose URL ends with *its* path but has a different base — see
|
/// with one of those paths on a base other than their own; a swarm-level
|
||||||
/// `hive-c0re`'s `forge::ensure_config_pr_webhook`. A swarm-level hook under
|
/// hook there would silently stop existing while such a hive runs.
|
||||||
/// such a path would therefore be deleted by every hive on every boot, and
|
/// `webhook_urls_survive_the_hive_side_reapers` pins that.
|
||||||
/// the symptom is a hook that silently stops existing.
|
|
||||||
/// `webhook_urls_survive_the_hive_side_reapers` pins that, and its own doc
|
|
||||||
/// records why `/webhook/knowledge` stays in the check even though the
|
|
||||||
/// knowledge registrar no longer reaps.
|
|
||||||
const ROUTE_PREFIX: &str = "/webhook/forge/";
|
const ROUTE_PREFIX: &str = "/webhook/forge/";
|
||||||
|
|
||||||
impl DeliveryKind {
|
impl DeliveryKind {
|
||||||
|
|
@ -661,22 +647,18 @@ mod tests {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A cross-daemon invariant with nothing else to enforce it: a per-hive
|
/// A cross-daemon invariant with nothing else to enforce it: older
|
||||||
/// registrar in `hive-c0re` **deletes** hooks whose URL ends with its
|
/// `hive-c0re` releases **delete** hooks whose URL ends with
|
||||||
/// own path but carries a different base. A controller URL matching such
|
/// `/webhook/knowledge` or `/webhook/config-pr` but carries a base other
|
||||||
/// a suffix would be deleted by every hive on every boot — the swarm hook
|
/// than their own. A controller URL matching such a suffix would be
|
||||||
/// would simply cease to exist, with the cause in a different daemon's
|
/// deleted by every such hive on every boot, with the cause in a
|
||||||
/// startup sweep. Serving these under `/webhook/forge/` is what avoids
|
/// different daemon's startup sweep. Serving these under
|
||||||
/// it, and this is the only place that says so in a form that fails.
|
/// `/webhook/forge/` is what avoids it, and this is the only place that
|
||||||
|
/// says so in a form that fails.
|
||||||
///
|
///
|
||||||
/// `forge::ensure_config_pr_webhook` still reaps that way. The knowledge
|
/// Current `hive-c0re` removes only hooks matching its own full URL. The
|
||||||
/// registrar no longer does — it was replaced by a removal that matches
|
/// hazard is what is *deployed*, not this tree: drop this test once no
|
||||||
/// the full URL, so it cannot touch another hive's hook. **The
|
/// hive running an older release can exist.
|
||||||
/// `/webhook/knowledge` arm is kept anyway**, because the hazard is not
|
|
||||||
/// this repository's current code: it is whatever is *deployed*, and a
|
|
||||||
/// hive still running the previous version reaps by suffix until it is
|
|
||||||
/// upgraded. Drop that arm once no such hive can exist, not when the
|
|
||||||
/// source stops mentioning it.
|
|
||||||
#[test]
|
#[test]
|
||||||
fn webhook_urls_survive_the_hive_side_reapers() {
|
fn webhook_urls_survive_the_hive_side_reapers() {
|
||||||
for suffix in ["/webhook/knowledge", "/webhook/config-pr"] {
|
for suffix in ["/webhook/knowledge", "/webhook/config-pr"] {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue