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:
parent
0e061067dd
commit
d989636d98
3 changed files with 109 additions and 19 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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<String>,
|
||||
pub freshness: Freshness,
|
||||
/// When the snapshot was stored, unix seconds — the bucket's own
|
||||
|
|
|
|||
|
|
@ -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<String> =
|
||||
let mut hives: std::collections::BTreeSet<String> =
|
||||
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<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
|
||||
/// 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<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]
|
||||
fn a_live_declaration_on_another_hive_is_a_placement() {
|
||||
use swarm_wire::wanted::AgentState;
|
||||
|
|
|
|||
Loading…
Reference in a new issue