hive-c0re: fail on an unparseable permission file, write it atomically
tool_groups::read and capabilities::read returned an empty map when
their file existed but didn't parse. Every set_*/remove_agent is a
read-modify-write, and write() rewrote the file in place, so a crash or
ENOSPC mid-write left a truncated file, and the next write (e.g. the
manager-spawn seed of ruth's tool groups) replaced it with a map holding
only one agent. The scheduling and approval gates then denied every
other agent, recoverable only from meta git history.
- Both registries now read through agent_config::read_map: a missing
file is still the empty map, any other read failure or a parse
failure is an io::Error. set_groups / set_caps / remove_agent fail
without writing.
- Writes go through agent_config::write_map: temp file in the same
directory, fsync, rename, fsync the directory. hive-c0re had no
shared atomic-write helper (the existing tmp+rename sites are inline
and don't fsync).
- Callers of read / groups_for / has_cap now handle the error:
* dashboard GET /api/tool-groups, /api/capabilities,
/api/permissions/stale return 500 instead of an empty table;
* the SSE permission snapshots are skipped with a warn;
* render_flake returns Result, so sync_agents fails instead of
rendering every agent without its tool groups / capabilities;
* set_nspawn_flags propagates has_cap's error;
* the socket tool-group gates deny with the read error as message;
* seed_manager_tool_groups logs and does not seed.
- capabilities::write had no callers left once set_caps writes through
write_map, and is removed.
Closes #4719
This commit is contained in:
parent
4f3209d8c7
commit
e0b08fe362
9 changed files with 341 additions and 129 deletions
|
|
@ -42,12 +42,15 @@ pub(super) struct ToolGroupsSnapshot {
|
|||
#[utoipa::path(
|
||||
get,
|
||||
path = "/api/tool-groups",
|
||||
responses((status = 200, description = "tool-group catalogue + assignments", body = ToolGroupsSnapshot)),
|
||||
responses(
|
||||
(status = 200, description = "tool-group catalogue + assignments", body = ToolGroupsSnapshot),
|
||||
(status = 500, description = "tool-groups file unreadable"),
|
||||
),
|
||||
tag = "permissions"
|
||||
)]
|
||||
pub(super) async fn get_tool_groups(
|
||||
State(state): State<AppState>,
|
||||
) -> axum::Json<ToolGroupsSnapshot> {
|
||||
) -> Result<axum::Json<ToolGroupsSnapshot>, ProblemDetails> {
|
||||
let groups = hive_sh4re::permissions::ToolGroup::ALL
|
||||
.iter()
|
||||
.map(|g| <&str>::from(*g))
|
||||
|
|
@ -56,7 +59,7 @@ pub(super) async fn get_tool_groups(
|
|||
.iter()
|
||||
.map(|g| (<&str>::from(*g), g.description()))
|
||||
.collect();
|
||||
let assignments = crate::tool_groups::read();
|
||||
let assignments = crate::tool_groups::read().map_err(|e| unreadable(&e))?;
|
||||
let roster = state
|
||||
.coord
|
||||
.containers_snapshot()
|
||||
|
|
@ -65,13 +68,20 @@ pub(super) async fn get_tool_groups(
|
|||
.map(|c| c.name);
|
||||
let (agents, effective) =
|
||||
roster_and_effective(roster, &assignments, &tool_group_default_names());
|
||||
axum::Json(ToolGroupsSnapshot {
|
||||
Ok(axum::Json(ToolGroupsSnapshot {
|
||||
groups,
|
||||
descriptions,
|
||||
assignments,
|
||||
agents,
|
||||
effective,
|
||||
})
|
||||
}))
|
||||
}
|
||||
|
||||
/// A permission file that exists but can't be read or parsed. Surfaced
|
||||
/// as a 500 rather than an empty table, which would read as "nobody has
|
||||
/// any grants".
|
||||
fn unreadable(e: &std::io::Error) -> ProblemDetails {
|
||||
ProblemDetails::from_status_code(StatusCode::INTERNAL_SERVER_ERROR).with_detail(e.to_string())
|
||||
}
|
||||
|
||||
/// The role-default tool-group names the harness falls back to for an
|
||||
|
|
@ -198,19 +208,22 @@ pub(super) struct CapabilitiesSnapshot {
|
|||
#[utoipa::path(
|
||||
get,
|
||||
path = "/api/capabilities",
|
||||
responses((status = 200, description = "capability catalogue + assignments", body = CapabilitiesSnapshot)),
|
||||
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>,
|
||||
) -> axum::Json<CapabilitiesSnapshot> {
|
||||
) -> 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();
|
||||
let assignments = crate::capabilities::read().map_err(|e| unreadable(&e))?;
|
||||
let roster = state
|
||||
.coord
|
||||
.containers_snapshot()
|
||||
|
|
@ -219,13 +232,13 @@ pub(super) async fn get_capabilities(
|
|||
.map(|c| c.name);
|
||||
// Capability default is "no extra caps" — empty default slice.
|
||||
let (agents, effective) = roster_and_effective(roster, &assignments, &[]);
|
||||
axum::Json(CapabilitiesSnapshot {
|
||||
Ok(axum::Json(CapabilitiesSnapshot {
|
||||
caps,
|
||||
descriptions,
|
||||
assignments,
|
||||
agents,
|
||||
effective,
|
||||
})
|
||||
}))
|
||||
}
|
||||
|
||||
#[derive(Deserialize, ToSchema)]
|
||||
|
|
@ -406,12 +419,15 @@ pub(super) struct StalePermsResponse {
|
|||
#[utoipa::path(
|
||||
get,
|
||||
path = "/api/permissions/stale",
|
||||
responses((status = 200, description = "ghost agent names with stale permission entries", body = StalePermsResponse)),
|
||||
responses(
|
||||
(status = 200, description = "ghost agent names with stale permission entries", body = StalePermsResponse),
|
||||
(status = 500, description = "tool-groups/capabilities file unreadable"),
|
||||
),
|
||||
tag = "permissions"
|
||||
)]
|
||||
pub(super) async fn get_stale_permissions(
|
||||
State(state): State<AppState>,
|
||||
) -> axum::Json<StalePermsResponse> {
|
||||
) -> Result<axum::Json<StalePermsResponse>, ProblemDetails> {
|
||||
// Live container names — includes stopped-but-configured containers.
|
||||
let live: std::collections::HashSet<String> = state
|
||||
.coord
|
||||
|
|
@ -432,8 +448,8 @@ pub(super) async fn get_stale_permissions(
|
|||
// 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();
|
||||
let tgs = crate::tool_groups::read();
|
||||
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())
|
||||
|
|
@ -443,7 +459,7 @@ pub(super) async fn get_stale_permissions(
|
|||
.into_iter()
|
||||
.collect();
|
||||
ghost_names.sort();
|
||||
axum::Json(StalePermsResponse { stale: ghost_names })
|
||||
Ok(axum::Json(StalePermsResponse { stale: ghost_names }))
|
||||
}
|
||||
|
||||
/// Clear all explicit permission entries for a named agent without
|
||||
|
|
|
|||
Loading…
Reference in a new issue