diff --git a/swarm-controller/README.md b/swarm-controller/README.md index 0176caf7..db51bc73 100644 --- a/swarm-controller/README.md +++ b/swarm-controller/README.md @@ -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 (`GET /api/agents`, `/api/agents/status`), and the state it declares for 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`. Every provisioning step below runs as a node in it. - **Agent creation** — `POST /api/agents` queues the SSO identity (through diff --git a/swarm-controller/src/agent_status.rs b/swarm-controller/src/agent_status.rs index 62bb4416..4f703627 100644 --- a/swarm-controller/src/agent_status.rs +++ b/swarm-controller/src/agent_status.rs @@ -12,10 +12,10 @@ //! 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 //! its bucket key (`swarm_wire::agent_status::split_key`), and a -//! *never-reported* agent's hive is unknowable regardless of what any -//! roster might claim, so [`AgentStatusRow::hive`] is `None` in exactly -//! that one case rather than a second lookup this module would have to -//! keep in sync with the bucket. +//! *never-reported* agent has no bucket key, so [`AgentStatusRow::hive`] +//! is `None` out of this module in exactly that one case. The +//! `GET /api/agents/status` handler fills it from the wanted declaration +//! that places the agent. use std::collections::BTreeMap; use std::time::{Duration, SystemTime, UNIX_EPOCH}; @@ -32,8 +32,9 @@ use crate::status::Freshness; pub struct AgentStatusRow { pub name: String, /// The hive this agent reported from, taken from its bucket key. - /// `None` when the agent has never reported — see the module doc for - /// why that is not answered from anywhere else. + /// `None` when the agent has never reported. Out of this reader only: + /// `GET /api/agents/status` fills it from the declaration that places + /// the agent. pub hive: Option, pub freshness: Freshness, /// When the snapshot was stored, unix seconds — the bucket's own diff --git a/swarm-controller/src/routes/agents.rs b/swarm-controller/src/routes/agents.rs index 7dfc42aa..139ca5cf 100644 --- a/swarm-controller/src/routes/agents.rs +++ b/swarm-controller/src/routes/agents.rs @@ -363,8 +363,9 @@ pub(crate) async fn get_hive_wanted( /// 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 -/// reported — see the `agent_status` module for why, and for why its hive -/// comes from its own bucket key rather than a second lookup. `config_pr` +/// reported — see the `agent_status` module for why. Its hive comes from its +/// 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 /// `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 @@ -419,8 +420,13 @@ pub(crate) async fn get_agents_status( // Owned `String` keys, not `&str` borrowed from `rows` — // `declarations` outlives the loop below that needs `rows` // mutably, so it cannot hold a live borrow into it. - let hives: std::collections::BTreeSet = + let mut hives: std::collections::BTreeSet = 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< String, swarm_wire::wanted::HiveWanted, @@ -442,15 +448,7 @@ pub(crate) async fn get_agents_status( } } } - for row in &mut rows { - row.wanted = row.hive.as_deref().and_then(|hive| { - declarations - .get(hive)? - .agents - .get(&row.name) - .map(|w| <&str>::from(w.state).to_owned()) - }); - } + merge_wanted(&mut rows, &declarations); } if let Some(login) = state.agent_login.as_ref() { 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, +) { + 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 /// roots, `CreateIdentity` and `CreateForgeUser`, since an authelia /// subject and a forge account need nothing from each other. `CreateRepo` @@ -1685,6 +1712,66 @@ mod tests { 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 { + 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] fn a_live_declaration_on_another_hive_is_a_placement() { use swarm_wire::wanted::AgentState;