swarm UI: one request for every agent's linked accounts
GET /api/agents/linked-accounts returns one entry per agent that
/api/agents/status has a row for, as {hive, agent, accounts}, from one
store login. The agents page fetches it once (and again when a link dialog
closes) and hands each table row and the detail panel its agent's slice,
so the page makes no per-agent request. The per-agent route had no caller
left and is removed. The bao grant is unchanged: the same list on each
agent's matrix and forge metadata directories.
Refs #4855
This commit is contained in:
parent
4fd1380a5f
commit
7ccde4647b
5 changed files with 165 additions and 66 deletions
|
|
@ -32,8 +32,9 @@ The swarm's control plane. The swarm UI and `swarmctl` are its clients.
|
|||
GitHub account for one agent, stored in the swarm secret store
|
||||
(`PUT /api/hives/{hive}/agents/{agent}/matrix-accounts/{account}`,
|
||||
`.../forge-accounts/{label}`, `.../github-account`); distinct from the agent's own swarm-minted
|
||||
accounts above. `GET .../linked-accounts` lists them, plus the agent's own
|
||||
`main` matrix account, by kind, name and host, never with a credential.
|
||||
accounts above. `GET /api/agents/linked-accounts` lists them for every agent in one
|
||||
response, plus each agent's own `main` matrix account, by kind, name and
|
||||
host, never with a credential.
|
||||
- **Config PR status** — each agent's open config-repo PR, cached from forge
|
||||
webhooks (`GET /api/config-prs`, `/api/agents/{name}/config-pr`).
|
||||
- **Swarm-wide forge objects and webhooks** →
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
//! The accounts linked to one agent, as kind, name and host: what the swarm
|
||||
//! UI's agent panel lists.
|
||||
//! The accounts linked to each agent, as kind, name and host: what the swarm
|
||||
//! UI's agents page lists, served for every agent in one response.
|
||||
//!
|
||||
//! Read from the paths [`crate::matrix_account`], [`crate::forge_account`] and
|
||||
//! [`crate::github_account`] write, plus the agent's own `main` matrix account,
|
||||
|
|
@ -21,7 +21,7 @@ use serde::de::DeserializeOwned;
|
|||
use swarm_secret_client::{Error, SecretStore, forge, github, matrix};
|
||||
use utoipa::ToSchema;
|
||||
|
||||
use super::{AppState, error_problem, swarm_hive};
|
||||
use super::{AppState, error_problem};
|
||||
|
||||
/// The host shown for a GitHub token. The store keeps none: a token is only
|
||||
/// ever used against github.com.
|
||||
|
|
@ -141,36 +141,78 @@ pub(crate) async fn linked_accounts(
|
|||
Ok(out)
|
||||
}
|
||||
|
||||
/// List the accounts linked to an agent: names and hosts, never a credential.
|
||||
/// One agent's linked accounts, beside the hive it reported from.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, ToSchema)]
|
||||
pub struct AgentLinkedAccounts {
|
||||
/// `None` for an agent that has never reported, as in `/api/agents/status`.
|
||||
hive: Option<String>,
|
||||
agent: String,
|
||||
accounts: Vec<LinkedAccount>,
|
||||
}
|
||||
|
||||
/// [`linked_accounts`] for each `(hive, agent)`, in the order given.
|
||||
///
|
||||
/// # Errors
|
||||
/// The first error [`linked_accounts`] returns for any agent.
|
||||
pub(crate) async fn every_agent(
|
||||
store: &impl AccountStore,
|
||||
agents: Vec<(Option<String>, String)>,
|
||||
) -> Result<Vec<AgentLinkedAccounts>, Error> {
|
||||
let mut out = Vec::with_capacity(agents.len());
|
||||
for (hive, agent) in agents {
|
||||
let accounts = linked_accounts(store, &agent).await?;
|
||||
out.push(AgentLinkedAccounts {
|
||||
hive,
|
||||
agent,
|
||||
accounts,
|
||||
});
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// The accounts linked to every agent, one entry per agent `/api/agents/status`
|
||||
/// returns a row for: names and hosts, never a credential.
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/api/hives/{hive}/agents/{agent}/linked-accounts",
|
||||
params(
|
||||
("hive" = String, Path, description = "hive the agent runs on"),
|
||||
("agent" = String, Path, description = "agent whose accounts to list"),
|
||||
),
|
||||
path = "/api/agents/linked-accounts",
|
||||
responses(
|
||||
(status = 200, description = "the agent's linked accounts, empty when it has none", body = Vec<LinkedAccount>),
|
||||
(status = 400, description = "the agent is not an identifier, or the hive is not in this swarm (problem+json)", body = String),
|
||||
(status = 500, description = "the store could not be read (problem+json)", body = String),
|
||||
(status = 200, description = "one entry per agent, its accounts empty when it has none", body = Vec<AgentLinkedAccounts>),
|
||||
(status = 500, description = "the secret store could not be read (problem+json)", body = String),
|
||||
(status = 503, description = "no swarm queue or identity bridge is configured here, or either could not be read (problem+json)", body = String),
|
||||
),
|
||||
tag = "agents"
|
||||
)]
|
||||
pub async fn get_linked_accounts(
|
||||
State(state): State<AppState>,
|
||||
axum::extract::Path((hive, agent)): axum::extract::Path<(String, String)>,
|
||||
) -> Result<Json<Vec<LinkedAccount>>, problem_details::ProblemDetails> {
|
||||
let hive = swarm_hive(&state, &hive).map_err(|(s, d)| error_problem(s, &d))?;
|
||||
let agent = hive_types::Ident::parse(&agent)
|
||||
.map_err(|reason| error_problem(StatusCode::BAD_REQUEST, reason))?
|
||||
.into_string();
|
||||
) -> Result<Json<Vec<AgentLinkedAccounts>>, problem_details::ProblemDetails> {
|
||||
let unavailable = |detail: &str| error_problem(StatusCode::SERVICE_UNAVAILABLE, detail);
|
||||
let Some(reader) = state.agent_status.as_ref() else {
|
||||
return Err(unavailable("no swarm queue is configured on this host"));
|
||||
};
|
||||
let Some(bridge) = state.auth.as_ref() else {
|
||||
return Err(unavailable("no identity bridge is configured on this host"));
|
||||
};
|
||||
let roster = bridge.list_agent_identities().await.map_err(|e| {
|
||||
let detail = format!("{e:#}");
|
||||
tracing::warn!(error = %detail, "reading the agent roster failed");
|
||||
unavailable(&detail)
|
||||
})?;
|
||||
let rows = reader
|
||||
.view(&roster, std::time::SystemTime::now())
|
||||
.await
|
||||
.map_err(|e| {
|
||||
let detail = format!("{e:#}");
|
||||
tracing::warn!(error = %detail, "reading the agent-status bucket failed");
|
||||
unavailable(&detail)
|
||||
})?;
|
||||
|
||||
let store = crate::store::connect().await.map_err(|e| {
|
||||
tracing::warn!(error = %e, "connecting to the swarm secret store failed");
|
||||
error_problem(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string())
|
||||
})?;
|
||||
let accounts = linked_accounts(&store, &agent).await.map_err(|e| {
|
||||
tracing::warn!(%hive, %agent, error = %e, "listing linked accounts failed");
|
||||
let agents = rows.into_iter().map(|r| (r.hive, r.name)).collect();
|
||||
let accounts = every_agent(&store, agents).await.map_err(|e| {
|
||||
tracing::warn!(error = %e, "listing linked accounts failed");
|
||||
error_problem(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string())
|
||||
})?;
|
||||
Ok(Json(accounts))
|
||||
|
|
@ -184,7 +226,7 @@ mod tests {
|
|||
use serde_json::{Value, json};
|
||||
use swarm_secret_client::Error;
|
||||
|
||||
use super::{AccountKind, AccountStore, LinkedAccount, linked_accounts};
|
||||
use super::{AccountKind, AccountStore, LinkedAccount, every_agent, linked_accounts};
|
||||
|
||||
/// Objects by path. A list answers the next segment of every path under
|
||||
/// the directory, with a trailing `/` when it goes deeper, as the store
|
||||
|
|
@ -334,6 +376,43 @@ mod tests {
|
|||
assert!(text.contains("catgirl"), "{text}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn every_agent_is_one_entry_per_hive_and_agent_with_no_credential() {
|
||||
let got = every_agent(
|
||||
&every_kind(),
|
||||
vec![
|
||||
(Some("pr1ma".to_owned()), "atlas".to_owned()),
|
||||
(Some("pr1ma".to_owned()), "red".to_owned()),
|
||||
(None, "quiet".to_owned()),
|
||||
],
|
||||
)
|
||||
.await
|
||||
.expect("store answers");
|
||||
let body = serde_json::to_value(&got).expect("serialises");
|
||||
let entries = body.as_array().expect("an array");
|
||||
assert_eq!(entries.len(), 3, "{body}");
|
||||
for entry in entries {
|
||||
let mut keys: Vec<&str> = entry
|
||||
.as_object()
|
||||
.expect("an object")
|
||||
.keys()
|
||||
.map(String::as_str)
|
||||
.collect();
|
||||
keys.sort_unstable();
|
||||
assert_eq!(keys, ["accounts", "agent", "hive"], "{entry}");
|
||||
}
|
||||
assert_eq!(body[0]["hive"], "pr1ma");
|
||||
assert_eq!(body[0]["agent"], "atlas");
|
||||
assert_eq!(body[0]["accounts"].as_array().map(Vec::len), Some(5));
|
||||
assert_eq!(body[1]["agent"], "red");
|
||||
assert_eq!(body[1]["accounts"][0]["name"], "catgirl");
|
||||
assert_eq!(body[2]["hive"], serde_json::Value::Null);
|
||||
assert_eq!(body[2]["accounts"], serde_json::json!([]));
|
||||
let text = body.to_string();
|
||||
assert!(!text.contains("value"), "{text}");
|
||||
assert!(!text.contains("t0k3n"), "{text}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn an_agent_with_nothing_stored_lists_nothing() {
|
||||
let got = linked_accounts(&FakeStore::default(), "atlas")
|
||||
|
|
|
|||
Loading…
Reference in a new issue