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
|
|
@ -124,10 +124,6 @@ pub async fn ensure_root_agent(coord: &Arc<Coordinator>) -> Result<()> {
|
|||
);
|
||||
return Ok(());
|
||||
}
|
||||
// Before the create/start branch, not inside it: on a hive whose root
|
||||
// container already exists this is the only run that can still seed the
|
||||
// grant, and it has to land before her next rebuild bakes the binds.
|
||||
seed_manager_capabilities();
|
||||
let list_result = lifecycle::list().await;
|
||||
if let Err(e) = &list_result {
|
||||
tracing::warn!(
|
||||
|
|
@ -257,68 +253,6 @@ fn seed_manager_tool_groups() {
|
|||
}
|
||||
}
|
||||
|
||||
/// The capability the root agent is seeded with, as the `snake_case` string
|
||||
/// `capabilities.json` stores and `capabilities::has_cap` compares against.
|
||||
/// Taken from the enum rather than written out, so the seed cannot drift into
|
||||
/// a name `capabilities::prune_unknown` would silently drop.
|
||||
fn manager_seed_caps() -> Vec<String> {
|
||||
vec![<&str>::from(hive_sh4re::permissions::Capability::ManageRootAgent).to_owned()]
|
||||
}
|
||||
|
||||
/// Whether the root agent's default capability grant should be written.
|
||||
///
|
||||
/// `store_written` is "`capabilities.json` exists" — see
|
||||
/// [`seed_manager_capabilities`] for why that, and not the absence of an
|
||||
/// entry for the manager, is the condition.
|
||||
fn should_seed_manager_caps(store_written: bool) -> bool {
|
||||
!store_written
|
||||
}
|
||||
|
||||
/// Give ruth the `ManageRootAgent` capability on the path that deploys her.
|
||||
///
|
||||
/// This is the capability-store half of the grant `roles.json` used to make:
|
||||
/// `topology::reconcile_roles` seeded `can_manage_top_level_agents` onto
|
||||
/// `MANAGER_NAME` on every meta sync, and that role is what put the other
|
||||
/// agents' state/config dirs, `/applied` and `/meta` into her nspawn binds.
|
||||
/// Collapsing the role into the capability means that without a seed here she
|
||||
/// loses those recovery mounts at her next container rebuild — silently, and
|
||||
/// only then, because nspawn bakes bind flags at container start.
|
||||
///
|
||||
/// **Seeded once, not re-ensured on every boot.** The role kept an empty-list
|
||||
/// tombstone so an explicit revoke stuck; the capability store *deletes* an
|
||||
/// entry that has been emptied rather than tombstoning it, so "the manager
|
||||
/// has no entry" cannot tell a fresh hive apart from a deliberate revoke.
|
||||
/// File existence can: every grant and revoke goes through
|
||||
/// `meta::commit_capabilities` → `capabilities::set_caps`, which
|
||||
/// writes the file even when the result is an empty `{}`. So while the file
|
||||
/// is absent nobody has ever had a say, and once it exists this is inert
|
||||
/// forever — including on the destroy+recreate path, matching
|
||||
/// [`seed_manager_tool_groups`]'s refusal to reset an operator's choice.
|
||||
///
|
||||
/// Written with plain `set_caps` rather than `meta::commit_capabilities`: the
|
||||
/// role's own seed wrote the file and left it for `stage_generated_meta_files`
|
||||
/// to `git add` into the next deploy commit, and taking `META_LOCK` on the
|
||||
/// boot path would be a new ordering constraint for no gain.
|
||||
fn seed_manager_capabilities() {
|
||||
if !should_seed_manager_caps(crate::capabilities::capabilities_path().exists()) {
|
||||
tracing::debug!(
|
||||
"capabilities.json already written — leaving the manager's capabilities as-is"
|
||||
);
|
||||
return;
|
||||
}
|
||||
let caps = manager_seed_caps();
|
||||
match crate::capabilities::set_caps(MANAGER_NAME, &caps) {
|
||||
Ok(()) => tracing::info!(
|
||||
caps = ?caps,
|
||||
"seeded ruth's default capabilities (recovery bind mounts for every agent)"
|
||||
),
|
||||
Err(e) => tracing::warn!(
|
||||
error = ?e,
|
||||
"failed to seed ruth's capabilities — she will come up without the recovery mounts"
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
/// Boot reconcile (see the module doc): classify every agent by rev
|
||||
/// freshness + persisted `wanted` intent, submit one `Boot` DAG
|
||||
/// (hyperhive lock bump growing an in-DAG rebuild subgraph per stale
|
||||
|
|
@ -572,10 +506,7 @@ fn submit_boot_tree(
|
|||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{
|
||||
BootAction, MANAGER_NAME, RootAgentPlan, boot_action, manager_seed_caps, plan_root_agent,
|
||||
should_seed_manager_caps,
|
||||
};
|
||||
use super::{BootAction, MANAGER_NAME, RootAgentPlan, boot_action, plan_root_agent};
|
||||
use crate::lifecycle::AGENT_PREFIX;
|
||||
use crate::power::Wanted;
|
||||
|
||||
|
|
@ -604,45 +535,6 @@ mod tests {
|
|||
assert_eq!(plan_root_agent(result), RootAgentPlan::Present);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Root-agent capability seed. `capabilities_path()` resolves under
|
||||
// `paths::meta_root()`, which is hardcoded to `/var/lib/hyperhive` with no
|
||||
// test override, so — same as `capabilities.rs`'s own tests — these pin
|
||||
// the pure decision (`should_seed_manager_caps`, `manager_seed_caps`)
|
||||
// rather than round-tripping the real file. Together they are the
|
||||
// replacement for the deleted `reconcile_roles_in_seeds_root_when_absent`
|
||||
// and `reconcile_roles_in_does_not_reseed_after_explicit_revoke`.
|
||||
|
||||
#[test]
|
||||
fn the_manager_is_seeded_while_the_capability_store_has_never_been_written() {
|
||||
assert!(should_seed_manager_caps(false));
|
||||
assert_eq!(manager_seed_caps(), vec!["manage_root_agent".to_owned()]);
|
||||
}
|
||||
|
||||
/// The revoke-sticks half, and the reason the condition is the file rather
|
||||
/// than the manager's entry: the store deletes an emptied entry instead of
|
||||
/// keeping a tombstone, but a revoke still writes the file — so once it
|
||||
/// exists the seed must never fire again.
|
||||
#[test]
|
||||
fn a_written_capability_store_is_never_reseeded() {
|
||||
assert!(!should_seed_manager_caps(true));
|
||||
}
|
||||
|
||||
/// A seed the store would drop as unrecognised grants nothing while
|
||||
/// logging success, so pin that what we write is a name `prune_unknown`
|
||||
/// keeps — i.e. one of `Capability::ALL`'s `snake_case` spellings.
|
||||
#[test]
|
||||
fn the_seeded_name_is_a_capability_the_store_recognises() {
|
||||
for cap in manager_seed_caps() {
|
||||
assert!(
|
||||
hive_sh4re::permissions::Capability::ALL
|
||||
.iter()
|
||||
.any(|known| <&str>::from(*known) == cap),
|
||||
"{cap} is not a known capability"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// The regression the `Unreadable` variant exists for: the arm used to
|
||||
// substitute `from_running(running)`, which is `Noop` against BOTH
|
||||
// observations — so no combination of inputs could reach a reconcile.
|
||||
|
|
|
|||
Loading…
Reference in a new issue