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:
parent
9224c0bd15
commit
f1c695c212
11 changed files with 732 additions and 76 deletions
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Reference in a new issue