agent capabilities live in a swarm KV record, set from swarmctl
An agent's capabilities move from the hive's meta repo
(`meta/capabilities.json`, injected as `HIVE_CAPABILITIES`) into the
`capabilities` field of its key in a new `agent-permissions` KV bucket,
which swarm-controller alone writes.
- swarm-queue-client: `agent_permissions` (bucket, key, `AgentPermissions`,
direct-get read, the import subject and its wire types).
- swarm-controller: creates the bucket at start; `GET|PUT|DELETE
/api/agents/{name}/permissions` (deny_unknown_fields and unknown
capability ⇒ 422, duplicate ⇒ 400, agent must be declared by a hive);
answers each hive's one-shot import, writing a key only where there is
none and only for agents that hive's wanted state declares.
- swarmctl: `agent permissions get|set|clear`.
- swarm-nats-auth: a hive reads every key of the bucket and sends its
import on its own subject; the controller subscribes to the imports and
may answer them (`responses`).
- hive-c0re: reads each of its agents' keys every 30s into
/var/lib/hyperhive/swarm-agent-permissions.json and restarts an agent,
gracefully, when its capabilities change. Unset is the compiled-in
default (ruth: manage_root_agent; everyone else: none). A failed read
keeps the stored value. On first boot it seeds that file from
meta/capabilities.json, sends the grants to the controller, and deletes
the file once the controller answers.
Deleted: the capabilities.json reader/writer, `HIVE_CAPABILITIES` in the
rendered meta flake, ruth's capability seed, the capabilities half of the
permissions rebuild job (`PermPayload::Capabilities`/`Combined`), the
dashboard's capabilities table and `capabilities_changed` event, and the
now-unused `Capability::description`.
Part of #5170.
This commit is contained in:
parent
fcda1f4a04
commit
f862354e43
47 changed files with 1943 additions and 1146 deletions
|
|
@ -37,7 +37,7 @@ use crate::lifecycle;
|
|||
(name = "lifecycle_ops", description = "agent container lifecycle: rebuild/restart/start/stop/pause/limits"),
|
||||
(name = "meta_inputs", description = "bulk flake-input update for the meta flake"),
|
||||
(name = "misc_api", description = "mark-all-read, compose, spawn-request, hive stats"),
|
||||
(name = "permissions", description = "tool-group + capability assignment for agents"),
|
||||
(name = "permissions", description = "tool-group assignment for agents"),
|
||||
(name = "rebuild_queue", description = "rebuild-queue cancel"),
|
||||
(name = "state_files", description = "proxied reads of allow-listed per-agent state files"),
|
||||
(name = "state_snapshot", description = "cold-load dashboard snapshot"),
|
||||
|
|
@ -126,8 +126,6 @@ fn app(coord: Arc<Coordinator>) -> axum::Router {
|
|||
.routes(routes!(build_logs::get_build_log_raw))
|
||||
.routes(routes!(permissions::get_tool_groups))
|
||||
.routes(routes!(permissions::post_tool_groups))
|
||||
.routes(routes!(permissions::get_capabilities))
|
||||
.routes(routes!(permissions::post_capabilities))
|
||||
.routes(routes!(permissions::post_permissions))
|
||||
.routes(routes!(permissions::get_stale_permissions))
|
||||
.routes(routes!(permissions::delete_agent_permissions))
|
||||
|
|
@ -380,8 +378,6 @@ mod router_build_probe {
|
|||
.routes(routes!(build_logs::get_build_log_raw))
|
||||
.routes(routes!(permissions::get_tool_groups))
|
||||
.routes(routes!(permissions::post_tool_groups))
|
||||
.routes(routes!(permissions::get_capabilities))
|
||||
.routes(routes!(permissions::post_capabilities))
|
||||
.routes(routes!(permissions::post_permissions))
|
||||
.routes(routes!(permissions::get_stale_permissions))
|
||||
.routes(routes!(permissions::delete_agent_permissions))
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
//! Tool-group + capability permission endpoints for the dashboard.
|
||||
//! Tool-group permission endpoints for the dashboard.
|
||||
//!
|
||||
//! Read endpoints return the full set of known groups/capabilities plus
|
||||
//! Read endpoints return the full set of known groups plus
|
||||
//! descriptions and the per-agent assignment map (the UI never hard-codes
|
||||
//! the lists). Write endpoints validate, then enqueue a `PermChange` so the
|
||||
//! JSON file write is serialised through the FIFO rebuild worker.
|
||||
|
|
@ -20,7 +20,7 @@ use super::{AppState, Ident, guard_agent_name, strip_container_prefix};
|
|||
#[derive(Serialize, ToSchema)]
|
||||
pub(super) struct ToolGroupsSnapshot {
|
||||
/// Ordered list of all known tool-group names. Drives the column
|
||||
/// headers in the capabilities table — the UI does not hard-code them.
|
||||
/// headers in the tool-groups table — the UI does not hard-code them.
|
||||
groups: Vec<&'static str>,
|
||||
/// Short description for each group name. Keys match `groups` entries.
|
||||
descriptions: std::collections::BTreeMap<&'static str, &'static str>,
|
||||
|
|
@ -100,8 +100,8 @@ pub(crate) fn tool_group_default_names() -> Vec<&'static str> {
|
|||
/// `agents` is the sorted union of the live roster and any agent that
|
||||
/// already has an explicit entry; `effective[agent]` is the explicit
|
||||
/// assignment when present, else `default` (the role fallback). Shared by
|
||||
/// the capabilities + tool-groups snapshots on both the HTTP and SSE
|
||||
/// paths so the two surfaces never drift.
|
||||
/// the tool-groups snapshots on both the HTTP and SSE paths so the two
|
||||
/// surfaces never drift.
|
||||
#[must_use]
|
||||
pub(crate) fn roster_and_effective(
|
||||
roster: impl IntoIterator<Item = String>,
|
||||
|
|
@ -184,135 +184,15 @@ pub(super) async fn post_tool_groups(
|
|||
Ok((StatusCode::OK, "ok").into_response())
|
||||
}
|
||||
|
||||
#[derive(Serialize, ToSchema)]
|
||||
pub(super) struct CapabilitiesSnapshot {
|
||||
/// Ordered list of all known capability names. Drives the column
|
||||
/// headers in the capabilities table — the UI does not hard-code them.
|
||||
caps: Vec<&'static str>,
|
||||
/// Short description for each capability name. Keys match `caps` entries.
|
||||
descriptions: std::collections::BTreeMap<&'static str, &'static str>,
|
||||
/// Per-agent *explicit* capability grant map. Absent agents have no
|
||||
/// extra caps — the UI uses presence here to badge "(default)".
|
||||
assignments: std::collections::BTreeMap<String, Vec<String>>,
|
||||
/// Every agent the operator can manage (live roster ∪ explicit keys),
|
||||
/// sorted — so default agents always list.
|
||||
agents: Vec<String>,
|
||||
/// Per-agent *effective* caps: explicit entry when present, else the
|
||||
/// default (no caps). Keeps the snapshot shape symmetric with
|
||||
/// tool-groups; the default here is always empty.
|
||||
effective: std::collections::BTreeMap<String, Vec<String>>,
|
||||
}
|
||||
|
||||
/// Every known capability name + description,
|
||||
/// plus the per-agent explicit/effective grant maps.
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/api/capabilities",
|
||||
responses(
|
||||
(status = 200, description = "capability catalogue + assignments", body = CapabilitiesSnapshot),
|
||||
(status = 500, description = "capabilities file unreadable"),
|
||||
),
|
||||
tag = "permissions"
|
||||
)]
|
||||
pub(super) async fn get_capabilities(
|
||||
State(state): State<AppState>,
|
||||
) -> Result<axum::Json<CapabilitiesSnapshot>, ProblemDetails> {
|
||||
use hive_sh4re::permissions::Capability;
|
||||
let caps = Capability::ALL.iter().map(|c| <&str>::from(*c)).collect();
|
||||
let descriptions = Capability::ALL
|
||||
.iter()
|
||||
.map(|c| (<&str>::from(*c), c.description()))
|
||||
.collect();
|
||||
let assignments = crate::capabilities::read().map_err(|e| unreadable(&e))?;
|
||||
let roster = state
|
||||
.coord
|
||||
.containers_snapshot()
|
||||
.await
|
||||
.into_iter()
|
||||
.map(|c| c.name);
|
||||
// Capability default is "no extra caps" — empty default slice.
|
||||
let (agents, effective) = roster_and_effective(roster, &assignments, &[]);
|
||||
Ok(axum::Json(CapabilitiesSnapshot {
|
||||
caps,
|
||||
descriptions,
|
||||
assignments,
|
||||
agents,
|
||||
effective,
|
||||
}))
|
||||
}
|
||||
|
||||
#[derive(Deserialize, ToSchema)]
|
||||
pub(super) struct SetCapabilitiesBody {
|
||||
caps: Vec<String>,
|
||||
}
|
||||
|
||||
/// Replace `agent`'s explicit capability
|
||||
/// grant set (JSON body `{"caps": [...]}`).
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/api/capabilities/{agent}",
|
||||
params(("agent" = String, Path, description = "agent name")),
|
||||
request_body = SetCapabilitiesBody,
|
||||
responses(
|
||||
(status = 200, description = "capabilities queued for write", body = String),
|
||||
(status = 400, description = "unknown capability name"),
|
||||
(status = 404, description = "no such agent"),
|
||||
),
|
||||
tag = "permissions"
|
||||
)]
|
||||
pub(super) async fn post_capabilities(
|
||||
State(state): State<AppState>,
|
||||
AxumPath(name): AxumPath<String>,
|
||||
axum::Json(body): axum::Json<SetCapabilitiesBody>,
|
||||
) -> Result<Response, ProblemDetails> {
|
||||
let logical = strip_container_prefix(&name);
|
||||
if let Some(reject) = guard_agent_name(&state, &logical).await {
|
||||
return Ok(reject);
|
||||
}
|
||||
let known: Vec<&str> = hive_sh4re::permissions::Capability::ALL
|
||||
.iter()
|
||||
.map(|c| <&str>::from(*c))
|
||||
.collect();
|
||||
for cap in &body.caps {
|
||||
if !known.contains(&cap.as_str()) {
|
||||
return Err(ProblemDetails::from_status_code(StatusCode::BAD_REQUEST)
|
||||
.with_detail(format!("unknown capability: {cap}")));
|
||||
}
|
||||
}
|
||||
// Submit a PermChange DAG: the JSON file write commits under
|
||||
// META_LOCK inside the WritePermFile node, so concurrent
|
||||
// batch-apply actions for different agents never race on the
|
||||
// shared capabilities.json.
|
||||
state
|
||||
.coord
|
||||
.job_queue
|
||||
.insert_job(|b| {
|
||||
crate::job_queue::templates::perm_change(
|
||||
b,
|
||||
&logical,
|
||||
crate::job_queue::PermPayload::Capabilities {
|
||||
caps: body.caps.clone(),
|
||||
},
|
||||
);
|
||||
Vec::new()
|
||||
})
|
||||
.expect("template-declared shapes are acyclic");
|
||||
state.coord.emit_rebuild_queue_snapshot();
|
||||
tracing::info!(agent = %logical, caps = ?body.caps, "operator: set capabilities via dashboard");
|
||||
Ok((StatusCode::OK, "ok").into_response())
|
||||
}
|
||||
|
||||
/// One agent's slice of a batch permission change. Sparse: an omitted
|
||||
/// field leaves that perm-type untouched, an empty array clears it, a
|
||||
/// One agent's slice of a batch permission change. An omitted
|
||||
/// `tool_groups` leaves the agent untouched, an empty array clears it, a
|
||||
/// populated array fully replaces it (same replace semantics as the
|
||||
/// per-agent endpoints).
|
||||
/// per-agent endpoint).
|
||||
#[derive(Deserialize, ToSchema)]
|
||||
pub(super) struct PermChangeBody {
|
||||
agent: String,
|
||||
#[serde(default)]
|
||||
tool_groups: Option<Vec<String>>,
|
||||
#[serde(default)]
|
||||
capabilities: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, ToSchema)]
|
||||
|
|
@ -320,25 +200,19 @@ pub(super) struct BatchPermsBody {
|
|||
changes: Vec<PermChangeBody>,
|
||||
}
|
||||
|
||||
/// A validated, non-empty change staged for enqueue:
|
||||
/// `(logical agent, new groups?, new caps?)`.
|
||||
type StagedPerm = (String, Option<Vec<String>>, Option<Vec<String>>);
|
||||
|
||||
/// Batch permission apply — `POST /api/permissions`.
|
||||
///
|
||||
/// The save-all permissions UI sends only the perm-types that actually
|
||||
/// changed per agent; each affected agent gets ONE combined
|
||||
/// `PermChange`, so the dedup key collapses to `(kind, agent)` and an
|
||||
/// agent whose caps AND groups both changed rebuilds once, not twice.
|
||||
/// The whole batch is atomic: every change is validated up front and
|
||||
/// on any validation error nothing is written or enqueued.
|
||||
/// The save-all permissions UI sends only the agents whose groups
|
||||
/// changed; each gets one `PermChange`. The whole batch is atomic: every
|
||||
/// change is validated up front and on any validation error nothing is
|
||||
/// written or enqueued.
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/api/permissions",
|
||||
request_body = BatchPermsBody,
|
||||
responses(
|
||||
(status = 200, description = "batch permission change queued", body = String),
|
||||
(status = 400, description = "unknown tool-group/capability name, or no such agent"),
|
||||
(status = 400, description = "unknown tool-group name, or no such agent"),
|
||||
(status = 404, description = "no such agent"),
|
||||
),
|
||||
tag = "permissions"
|
||||
|
|
@ -347,43 +221,25 @@ pub(super) async fn post_permissions(
|
|||
State(state): State<AppState>,
|
||||
axum::Json(body): axum::Json<BatchPermsBody>,
|
||||
) -> Result<Response, ProblemDetails> {
|
||||
let known_caps: Vec<&str> = hive_sh4re::permissions::Capability::ALL
|
||||
.iter()
|
||||
.map(|c| <&str>::from(*c))
|
||||
.collect();
|
||||
// Phase 1 — validate everything before touching any file or the
|
||||
// queue, so a bad entry fails the whole POST with zero side effects.
|
||||
// No-op rows (both fields omitted) are skipped, not errors.
|
||||
let mut staged: Vec<StagedPerm> = Vec::new();
|
||||
// No-op rows (field omitted) are skipped, not errors.
|
||||
let mut staged: Vec<(String, Vec<String>)> = Vec::new();
|
||||
for change in &body.changes {
|
||||
let logical = strip_container_prefix(&change.agent);
|
||||
if let Some(reject) = guard_agent_name(&state, &logical).await {
|
||||
return Ok(reject);
|
||||
}
|
||||
if let Some(groups) = &change.tool_groups
|
||||
&& let Err(e) = crate::tool_groups::validate_groups(groups)
|
||||
{
|
||||
return Err(ProblemDetails::from_status_code(StatusCode::BAD_REQUEST)
|
||||
.with_detail(format!("invalid tool-groups for {logical}: {e}")));
|
||||
}
|
||||
if let Some(caps) = &change.capabilities {
|
||||
for cap in caps {
|
||||
if !known_caps.contains(&cap.as_str()) {
|
||||
return Err(ProblemDetails::from_status_code(StatusCode::BAD_REQUEST)
|
||||
.with_detail(format!("unknown capability for {logical}: {cap}")));
|
||||
}
|
||||
if let Some(groups) = &change.tool_groups {
|
||||
if let Err(e) = crate::tool_groups::validate_groups(groups) {
|
||||
return Err(ProblemDetails::from_status_code(StatusCode::BAD_REQUEST)
|
||||
.with_detail(format!("invalid tool-groups for {logical}: {e}")));
|
||||
}
|
||||
}
|
||||
if change.tool_groups.is_some() || change.capabilities.is_some() {
|
||||
staged.push((
|
||||
logical,
|
||||
change.tool_groups.clone(),
|
||||
change.capabilities.clone(),
|
||||
));
|
||||
staged.push((logical, groups.clone()));
|
||||
}
|
||||
}
|
||||
// Phase 2 — submit one combined PermChange DAG per affected agent.
|
||||
for (logical, groups, caps) in staged {
|
||||
// Phase 2 — submit one PermChange DAG per affected agent.
|
||||
for (logical, groups) in staged {
|
||||
state
|
||||
.coord
|
||||
.job_queue
|
||||
|
|
@ -391,7 +247,7 @@ pub(super) async fn post_permissions(
|
|||
crate::job_queue::templates::perm_change(
|
||||
b,
|
||||
&logical,
|
||||
crate::job_queue::PermPayload::Combined { groups, caps },
|
||||
crate::job_queue::PermPayload::ToolGroups { groups },
|
||||
);
|
||||
Vec::new()
|
||||
})
|
||||
|
|
@ -402,7 +258,7 @@ pub(super) async fn post_permissions(
|
|||
Ok((StatusCode::OK, "ok").into_response())
|
||||
}
|
||||
|
||||
/// Agent names that have explicit capability/tool-group entries but are
|
||||
/// Agent names that have explicit tool-group entries but are
|
||||
/// not in the live container roster AND not in the kept-state directory
|
||||
/// list (i.e. truly gone — renamed or destroyed agents whose JSON entries
|
||||
/// persisted). The client uses this to drive the "stale permission entries"
|
||||
|
|
@ -421,7 +277,7 @@ pub(super) struct StalePermsResponse {
|
|||
path = "/api/permissions/stale",
|
||||
responses(
|
||||
(status = 200, description = "ghost agent names with stale permission entries", body = StalePermsResponse),
|
||||
(status = 500, description = "tool-groups/capabilities file unreadable"),
|
||||
(status = 500, description = "tool-groups file unreadable"),
|
||||
),
|
||||
tag = "permissions"
|
||||
)]
|
||||
|
|
@ -447,18 +303,9 @@ pub(super) async fn get_stale_permissions(
|
|||
.collect();
|
||||
// Known = live roster ∪ kept-state names.
|
||||
let known: std::collections::HashSet<&String> = live.iter().chain(kept.iter()).collect();
|
||||
// Explicit entries in either JSON file.
|
||||
let caps = crate::capabilities::read().map_err(|e| unreadable(&e))?;
|
||||
let tgs = crate::tool_groups::read().map_err(|e| unreadable(&e))?;
|
||||
let mut ghost_names: Vec<String> = caps
|
||||
.keys()
|
||||
.chain(tgs.keys())
|
||||
.filter(|n| !known.contains(n))
|
||||
.cloned()
|
||||
.collect::<std::collections::HashSet<_>>()
|
||||
.into_iter()
|
||||
.collect();
|
||||
ghost_names.sort();
|
||||
// `tgs` is a `BTreeMap`, so its keys are already sorted.
|
||||
let ghost_names: Vec<String> = tgs.keys().filter(|n| !known.contains(n)).cloned().collect();
|
||||
Ok(axum::Json(StalePermsResponse { stale: ghost_names }))
|
||||
}
|
||||
|
||||
|
|
@ -466,15 +313,15 @@ pub(super) async fn get_stale_permissions(
|
|||
/// requiring it to exist in the live roster.
|
||||
///
|
||||
/// Used by the P3RM1SS10NS tab's "remove" button for agents that have
|
||||
/// stale explicit entries in `tool-groups.json` / `capabilities.json`
|
||||
/// but are no longer running (e.g. an agent that was renamed or
|
||||
/// destroyed while its JSON entries persisted).
|
||||
/// stale explicit entries in `tool-groups.json` but are no longer
|
||||
/// running (e.g. an agent that was renamed or destroyed while its JSON
|
||||
/// entries persisted).
|
||||
///
|
||||
/// Bypasses `guard_agent_name`'s live-roster check intentionally —
|
||||
/// the whole point is to remove entries for non-roster agents. Only
|
||||
/// the format check ([`Ident::parse`]) is applied. No rebuild is
|
||||
/// enqueued (the agent doesn't exist to rebuild); the SSE snapshots
|
||||
/// update the P3RM1SS10NS tab live.
|
||||
/// enqueued (the agent doesn't exist to rebuild); the SSE snapshot
|
||||
/// updates the P3RM1SS10NS tab live.
|
||||
#[utoipa::path(
|
||||
delete,
|
||||
path = "/api/permissions/{agent}",
|
||||
|
|
@ -482,7 +329,7 @@ pub(super) async fn get_stale_permissions(
|
|||
responses(
|
||||
(status = 200, description = "stale permission entries cleared", body = String),
|
||||
(status = 400, description = "bad agent name"),
|
||||
(status = 500, description = "tool-groups/capabilities file write failed"),
|
||||
(status = 500, description = "tool-groups file write failed"),
|
||||
),
|
||||
tag = "permissions"
|
||||
)]
|
||||
|
|
@ -496,24 +343,13 @@ pub(super) async fn delete_agent_permissions(
|
|||
return (StatusCode::BAD_REQUEST, format!("bad agent name: {reason}")).into_response();
|
||||
}
|
||||
};
|
||||
// Run both removals regardless so we clean up as much as possible
|
||||
// even on partial I/O errors. Collect errors to surface below.
|
||||
let tg_err = crate::tool_groups::remove_agent(logical.as_str()).err();
|
||||
if let Some(ref e) = tg_err {
|
||||
tracing::warn!(agent = %logical, error = ?e, "failed to remove tool-groups entry");
|
||||
}
|
||||
let cap_err = crate::capabilities::remove_agent(logical.as_str()).err();
|
||||
if let Some(ref e) = cap_err {
|
||||
tracing::warn!(agent = %logical, error = ?e, "failed to remove capabilities entry");
|
||||
}
|
||||
// Emit live snapshots even on partial failure so the UI stays as
|
||||
// accurate as possible — the surviving table gets updated immediately.
|
||||
let removed = crate::tool_groups::remove_agent(logical.as_str());
|
||||
state.coord.emit_tool_groups_snapshot();
|
||||
state.coord.emit_capabilities_snapshot();
|
||||
// Surface any I/O error as 500 so the frontend's `!resp.ok` path
|
||||
// Surface an I/O error as 500 so the frontend's `!resp.ok` path
|
||||
// fires and the operator sees a meaningful message rather than a
|
||||
// silent "success" followed by the row reappearing unchanged.
|
||||
if let Some(e) = tg_err.or(cap_err) {
|
||||
if let Err(e) = removed {
|
||||
tracing::warn!(agent = %logical, error = ?e, "failed to remove tool-groups entry");
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("failed to remove permission entries for {logical}: {e}"),
|
||||
|
|
|
|||
Loading…
Reference in a new issue