Watch
0
0
Fork
You've already forked hyperhive
0

swarm roster shows a declared agent's state before it has ever reported

A declared agent that never reported (e.g. its first deploy was lost) had no hive in the roster row, so the wanted merge skipped it and swarm-ui showed 'no declaration' with the state control disabled. GET /api/agents/status now takes such a row's hive from the one hive whose declaration places the agent, so the declared state shows and the control works on deploy.
This commit is contained in:
atlas 2026-10-21 00:48:55 +02:00 • committed by mara
commit d989636d98
3 changed files with 109 additions and 19 deletions

View file

@ -18,7 +18,9 @@ The swarm's control plane. The swarm UI and `swarmctl` are its clients.
- **Agent roster and wanted state** — every agent the swarm knows - **Agent roster and wanted state** — every agent the swarm knows
(`GET /api/agents`, `/api/agents/status`), and the state it declares for (`GET /api/agents`, `/api/agents/status`), and the state it declares for
each one on its hive (`up`/`offline`/`paused`/`destroyed`, each one on its hive (`up`/`offline`/`paused`/`destroyed`,
`PUT /api/hives/{hive}/agents/{agent}/state`). `PUT /api/hives/{hive}/agents/{agent}/state`). An agent that has never
reported takes its hive and declared state in `/api/agents/status` from the
one hive whose declaration places it.
- **Job graph** — a `hive-jobq` scheduler, served at `GET /api/jobq/graph`. - **Job graph** — a `hive-jobq` scheduler, served at `GET /api/jobq/graph`.
Every provisioning step below runs as a node in it. Every provisioning step below runs as a node in it.
- **Agent creation** — `POST /api/agents` queues the SSO identity (through - **Agent creation** — `POST /api/agents` queues the SSO identity (through

View file

@ -12,10 +12,10 @@
//! it does not say which hive an agent lives on. That is not a gap this //! it does not say which hive an agent lives on. That is not a gap this
//! module needs to close: a *reporting* agent's hive comes straight out of //! module needs to close: a *reporting* agent's hive comes straight out of
//! its bucket key (`swarm_wire::agent_status::split_key`), and a //! its bucket key (`swarm_wire::agent_status::split_key`), and a
//! *never-reported* agent's hive is unknowable regardless of what any //! *never-reported* agent has no bucket key, so [`AgentStatusRow::hive`]
//! roster might claim, so [`AgentStatusRow::hive`] is `None` in exactly //! is `None` out of this module in exactly that one case. The
//! that one case rather than a second lookup this module would have to //! `GET /api/agents/status` handler fills it from the wanted declaration
//! keep in sync with the bucket. //! that places the agent.
use std::collections::BTreeMap; use std::collections::BTreeMap;
use std::time::{Duration, SystemTime, UNIX_EPOCH}; use std::time::{Duration, SystemTime, UNIX_EPOCH};
@ -32,8 +32,9 @@ use crate::status::Freshness;
pub struct AgentStatusRow { pub struct AgentStatusRow {
pub name: String, pub name: String,
/// The hive this agent reported from, taken from its bucket key. /// The hive this agent reported from, taken from its bucket key.
/// `None` when the agent has never reported — see the module doc for /// `None` when the agent has never reported. Out of this reader only:
/// why that is not answered from anywhere else. /// `GET /api/agents/status` fills it from the declaration that places
/// the agent.
pub hive: Option<String>, pub hive: Option<String>,
pub freshness: Freshness, pub freshness: Freshness,
/// When the snapshot was stored, unix seconds — the bucket's own /// When the snapshot was stored, unix seconds — the bucket's own

View file

@ -363,8 +363,9 @@ pub(crate) async fn get_hive_wanted(
/// 2026-09-02: "the view should be filled by a single backend call"). /// 2026-09-02: "the view should be filled by a single backend call").
/// ///
/// Every agent in the roster gets a row whether or not it has ever /// Every agent in the roster gets a row whether or not it has ever
/// reported — see the `agent_status` module for why, and for why its hive /// reported — see the `agent_status` module for why. Its hive comes from its
/// comes from its own bucket key rather than a second lookup. `config_pr` /// own bucket key; a never-reported row takes it from the declaration that
/// places it (`merge_wanted`). `config_pr`
/// and `wanted` are both merged in here rather than inside /// and `wanted` are both merged in here rather than inside
/// `agent_status::AgentStatusReader`: that module has no forge client or /// `agent_status::AgentStatusReader`: that module has no forge client or
/// wanted-state writer and is not the place to add one just for a single /// wanted-state writer and is not the place to add one just for a single
@ -419,8 +420,13 @@ pub(crate) async fn get_agents_status(
// Owned `String` keys, not `&str` borrowed from `rows` — // Owned `String` keys, not `&str` borrowed from `rows` —
// `declarations` outlives the loop below that needs `rows` // `declarations` outlives the loop below that needs `rows`
// mutably, so it cannot hold a live borrow into it. // mutably, so it cannot hold a live borrow into it.
let hives: std::collections::BTreeSet<String> = let mut hives: std::collections::BTreeSet<String> =
rows.iter().filter_map(|r| r.hive.clone()).collect(); rows.iter().filter_map(|r| r.hive.clone()).collect();
// A row with no hive has never reported; the hive that
// declares it is the only place its hive is on record.
if rows.iter().any(|r| r.hive.is_none()) {
hives.extend(state.hives.iter().map(|h| h.name.clone()));
}
let mut declarations: std::collections::HashMap< let mut declarations: std::collections::HashMap<
String, String,
swarm_wire::wanted::HiveWanted, swarm_wire::wanted::HiveWanted,
@ -442,15 +448,7 @@ pub(crate) async fn get_agents_status(
} }
} }
} }
for row in &mut rows { merge_wanted(&mut rows, &declarations);
row.wanted = row.hive.as_deref().and_then(|hive| {
declarations
.get(hive)?
.agents
.get(&row.name)
.map(|w| <&str>::from(w.state).to_owned())
});
}
} }
if let Some(login) = state.agent_login.as_ref() { if let Some(login) = state.agent_login.as_ref() {
for row in &mut rows { for row in &mut rows {
@ -467,6 +465,35 @@ pub(crate) async fn get_agents_status(
} }
} }
/// Fill `wanted` on every row from `declarations` (hive name -> its
/// published declaration). A row that has never reported has no hive of its
/// own; it takes the one hive whose declaration places it, so the declared
/// state shows and can be changed before the agent first reports. A name
/// placed on several hives is ambiguous and stays unplaced.
fn merge_wanted(
rows: &mut [agent_status::AgentStatusRow],
declarations: &std::collections::HashMap<String, swarm_wire::wanted::HiveWanted>,
) {
let declared: Vec<(String, swarm_wire::wanted::HiveWanted)> = declarations
.iter()
.map(|(hive, declaration)| (hive.clone(), declaration.clone()))
.collect();
for row in rows {
if row.hive.is_none()
&& let [hive] = claiming_hives(&row.name, &declared).as_slice()
{
row.hive = Some(hive.clone());
}
row.wanted = row.hive.as_deref().and_then(|hive| {
declarations
.get(hive)?
.agents
.get(&row.name)
.map(|w| <&str>::from(w.state).to_owned())
});
}
}
/// Queue the whole agent-creation job graph for `name` — two independent /// Queue the whole agent-creation job graph for `name` — two independent
/// roots, `CreateIdentity` and `CreateForgeUser`, since an authelia /// roots, `CreateIdentity` and `CreateForgeUser`, since an authelia
/// subject and a forge account need nothing from each other. `CreateRepo` /// subject and a forge account need nothing from each other. `CreateRepo`
@ -1685,6 +1712,66 @@ mod tests {
assert_eq!(elsewhere, ["sec0nd"]); assert_eq!(elsewhere, ["sec0nd"]);
} }
fn roster_row(name: &str, hive: Option<&str>) -> crate::agent_status::AgentStatusRow {
crate::agent_status::AgentStatusRow {
name: name.to_owned(),
hive: hive.map(str::to_owned),
freshness: if hive.is_some() {
crate::status::Freshness::Fresh
} else {
crate::status::Freshness::NeverReported
},
last_seen_unix: None,
age_seconds: None,
snapshot: None,
config_pr: None,
wanted: None,
needs_login: None,
}
}
fn declared(
entries: Vec<(String, swarm_wire::wanted::HiveWanted)>,
) -> std::collections::HashMap<String, swarm_wire::wanted::HiveWanted> {
entries.into_iter().collect()
}
#[test]
fn a_never_reported_agent_takes_its_hive_and_state_from_its_declaration() {
use swarm_wire::wanted::AgentState;
let declarations = declared(vec![declaring("pr1ma", "punky", AgentState::Paused)]);
let mut rows = vec![
roster_row("punky", None),
// Control: a reporting agent keeps its own hive and gets its state.
roster_row("atlas", Some("pr1ma")),
];
super::merge_wanted(&mut rows, &declarations);
assert_eq!(rows[0].hive.as_deref(), Some("pr1ma"));
assert_eq!(rows[0].wanted.as_deref(), Some("paused"));
assert_eq!(rows[1].hive.as_deref(), Some("pr1ma"));
assert_eq!(rows[1].wanted, None, "atlas is not declared");
}
#[test]
fn a_never_reported_agent_nobody_places_stays_unplaced() {
use swarm_wire::wanted::AgentState;
let declarations = declared(vec![
declaring("pr1ma", "gone", AgentState::Destroyed),
declaring("pr1ma", "twin", AgentState::Up),
declaring("sec0nd", "twin", AgentState::Up),
]);
let mut rows = vec![
roster_row("unknown", None),
roster_row("gone", None),
roster_row("twin", None),
];
super::merge_wanted(&mut rows, &declarations);
for row in &rows {
assert_eq!(row.hive, None, "{}", row.name);
assert_eq!(row.wanted, None, "{}", row.name);
}
}
#[test] #[test]
fn a_live_declaration_on_another_hive_is_a_placement() { fn a_live_declaration_on_another_hive_is_a_placement() {
use swarm_wire::wanted::AgentState; use swarm_wire::wanted::AgentState;