Watch
0
0
Fork
You've already forked hyperhive
0

config PRs: an operator's Forgejo merge deploys the merged rev

A config PR merged in the Forgejo UI changed nothing on the hive: the
hive's webhook ignores `closed`, its poll then cancels the dashboard
card, and `applied/main` stays where it was.

swarm-controller reads `merged`/`merge_commit_sha` off the
`pull_request` delivery it already receives for `agent-configs`, finds
the hive placing the agent by scanning every hive's wanted state (the
scan `declarations_elsewhere` already ran, factored out), and queues a
`TriggerDeploy` carrying the rev. Zero or several claimants deploy
nothing and log the claimants.

`DeployRequest` gains `rev: Option<String>` with `serde(default)`, so
rev-less payloads from either side keep decoding.

hive-c0re, given a rev for an agent it runs: a no-op when
`applied/main` already is the rev (a dashboard merge deploys its own
PR); otherwise it fetches the forge `main` with the core token,
requires the rev to descend from `applied/main` (the ancestry gate,
factored out of `run_deploy_merge_verify`), fast-forwards by CAS and
queues the usual relocking rebuild. No eval-verify on this path, per
mara (#4850 c90075). A refusal is commented on the PR that merged the
rev, found by commit.

swarm-controller's forge-objects pass converges every config repo's
`main` rule to merge whitelist `operators` + `core` and approval
whitelist `operators`. The hive's boot PATCH stops forcing
`enable_approvals_whitelist` off, so the two do not fight.

Refs #4850
This commit is contained in:
atlas 2026-10-02 18:45:18 +02:00
commit f1c695c212
11 changed files with 732 additions and 76 deletions

View file

@ -147,7 +147,13 @@ enum SwarmNodeKind {
/// `InitAgentConfigRepo`'s doc points at when it says the hive belongs
/// on the node that sends the deploy message. It names the subject the
/// message goes to, one per hive, so no other hive is woken by it.
TriggerDeploy { hive: String, agent: String },
TriggerDeploy {
hive: String,
agent: String,
/// The config commit to deploy, from a merged config PR. `None` from
/// agent creation, where the hive deploys what it seeds.
rev: Option<String>,
},
}
impl hive_jobq_wire::WireNode for SwarmNodeKind {
@ -190,8 +196,10 @@ impl hive_jobq_wire::WireNode for SwarmNodeKind {
serde_json::json!({ "agent": agent })
}
SwarmNodeKind::MintHiveSenderToken { hive } => serde_json::json!({ "hive": hive }),
SwarmNodeKind::TriggerDeploy { hive, agent }
| SwarmNodeKind::SetAgentWanted { hive, agent } => {
SwarmNodeKind::TriggerDeploy { hive, agent, rev } => {
serde_json::json!({ "agent": agent, "hive": hive, "rev": rev })
}
SwarmNodeKind::SetAgentWanted { hive, agent } => {
serde_json::json!({ "agent": agent, "hive": hive })
}
}
@ -355,12 +363,12 @@ async fn run_swarm_node(
),
Some(writer) => declare_new_agent(&writer, &hive, &agent).await,
},
SwarmNodeKind::TriggerDeploy { hive, agent } => match deps.queue {
SwarmNodeKind::TriggerDeploy { hive, agent, rev } => match deps.queue {
None => Outcome::Failed(
"no swarm queue is configured on this host, so no hive can be told to deploy"
.to_owned(),
),
Some(client) => publish_deploy(&client, &hive, &agent).await,
Some(client) => publish_deploy(&client, &hive, &agent, rev).await,
},
};
(builder, outcome)
@ -482,6 +490,7 @@ async fn publish_deploy(
client: &async_nats::Client,
hive: &str,
agent: &str,
rev: Option<String>,
) -> hive_jobq::scheduler::Outcome {
use hive_jobq::scheduler::Outcome;
@ -489,6 +498,7 @@ async fn publish_deploy(
let subject = swarm_queue_client::deploy_subject(hive);
let request = swarm_queue_client::DeployRequest {
agent: agent.to_owned(),
rev,
};
let payload = match serde_json::to_vec(&request) {
Ok(payload) => payload,
@ -502,7 +512,7 @@ async fn publish_deploy(
"flushing the deploy event to {subject} failed: {e}"
));
}
tracing::info!(%subject, %hive, %agent, "swarm jobq: deploy requested");
tracing::info!(%subject, %hive, %agent, rev = ?request.rev, "swarm jobq: deploy requested");
Outcome::Done
}
@ -1644,27 +1654,103 @@ async fn declarations_elsewhere(
hive: &str,
) -> Result<Vec<(String, swarm_queue_client::wanted::HiveWanted)>, problem_details::ProblemDetails>
{
read_declarations(state, state.hives.iter().filter(|h| h.name != hive))
.await
.map_err(|(other, e)| {
let detail = format!(
"cannot tell whether {agent:?} already exists on hive {other:?}, so it was not \
placed: {e:#}"
);
error_problem(wanted_error_status(&e), &detail)
})
}
/// The published wanted state of each of `hives` that has one. No queue wired
/// up reads as nothing declared anywhere. The error names the hive whose read
/// failed.
async fn read_declarations<'a>(
state: &AppState,
hives: impl Iterator<Item = &'a HiveEntry>,
) -> Result<Vec<(String, swarm_queue_client::wanted::HiveWanted)>, (String, anyhow::Error)> {
let Some(writer) = state.wanted.as_deref() else {
return Ok(Vec::new());
};
let mut declared = Vec::new();
for other in state.hives.iter().filter(|h| h.name != hive) {
match writer.view(&other.name).await {
Ok(Some(declaration)) => declared.push((other.name.clone(), declaration)),
for hive in hives {
match writer.view(&hive.name).await {
Ok(Some(declaration)) => declared.push((hive.name.clone(), declaration)),
Ok(None) => {}
Err(e) => {
let detail = format!(
"cannot tell whether {agent:?} already exists on hive {:?}, so it was not \
placed: {e:#}",
other.name
);
return Err(error_problem(wanted_error_status(&e), &detail));
}
Err(e) => return Err((hive.name.clone(), e)),
}
}
Ok(declared)
}
/// Every hive whose declaration places `agent` there.
fn claiming_hives(
agent: &str,
declared: &[(String, swarm_queue_client::wanted::HiveWanted)],
) -> Vec<String> {
declared
.iter()
.filter(|(_, declaration)| {
declaration
.agents
.get(agent)
.is_some_and(|wanted| places_agent(wanted.state))
})
.map(|(hive, _)| hive.clone())
.collect()
}
/// Queue the deploy of a merged config PR on the one hive that runs its agent.
///
/// The hive is found by scanning every hive's wanted state, since nothing
/// records an agent's hive (see `forge::initial_agent_nix`). Zero claimants or
/// several deploy nothing: there is no hive to send it to, or no telling which.
async fn queue_merged_config_deploy(state: &AppState, merged: config_pr::MergedConfigPr) {
let config_pr::MergedConfigPr { agent, rev } = merged;
let declared = match read_declarations(state, state.hives.iter()).await {
Ok(declared) => declared,
Err((hive, e)) => {
tracing::warn!(
%agent, %rev, %hive, error = %format!("{e:#}"),
"config-pr merge: cannot read where the agent is placed; nothing deployed"
);
return;
}
};
let claimants = claiming_hives(&agent, &declared);
let [hive] = claimants.as_slice() else {
tracing::warn!(
%agent, %rev, ?claimants,
"config-pr merge: not exactly one hive places this agent; nothing deployed"
);
return;
};
let hive = hive.clone();
let inserted = state
.jobq
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.insert_job(None, |b| {
vec![
b.node(SwarmNodeKind::TriggerDeploy {
hive: hive.clone(),
agent: agent.clone(),
rev: Some(rev.clone()),
})
.guid(),
]
});
match inserted {
Ok(_) => tracing::info!(%agent, %rev, %hive, "config-pr merge: deploy queued"),
Err(e) => {
tracing::warn!(%agent, %rev, %hive, error = %e, "config-pr merge: queueing the deploy failed");
}
}
}
/// [`queued_placements`] of the swarm's own queue, as it is now.
fn queued_placements_now(state: &AppState) -> Vec<(String, String)> {
queued_placements(
@ -1917,6 +2003,7 @@ fn declare_agent_job(
.node(SwarmNodeKind::TriggerDeploy {
hive: hive.to_owned(),
agent: agent.to_owned(),
rev: None,
})
.after_ok(init_config)
.after_any(mint_identity)
@ -3349,6 +3436,31 @@ mod tests {
assert!(super::placed_elsewhere("atlas", "pr1ma", &declared, &queued).is_empty());
}
#[test]
fn the_hive_placing_an_agent_claims_it_and_no_other_does() {
use swarm_queue_client::wanted::AgentState;
let declared = [
declaring("pr1ma", "atlas", AgentState::Paused),
declaring("sec0nd", "argus", AgentState::Up),
declaring("th1rd", "atlas", AgentState::Destroyed),
];
assert_eq!(super::claiming_hives("atlas", &declared), ["pr1ma"]);
assert!(super::claiming_hives("iris", &declared).is_empty());
}
#[test]
fn two_hives_placing_one_agent_both_claim_it() {
use swarm_queue_client::wanted::AgentState;
let declared = [
declaring("pr1ma", "atlas", AgentState::Up),
declaring("sec0nd", "atlas", AgentState::Up),
];
assert_eq!(
super::claiming_hives("atlas", &declared),
["pr1ma", "sec0nd"]
);
}
/// `refuse_placement` of `agent` on `pr1ma`, with `admin` reserved,
/// `roster` as the roster read, and `declared` as every hive's wanted
/// state, `pr1ma`'s included.