seed the root agent's ManageRootAgent capability on its deploy path
`roles.json` had a seeding path: `topology::reconcile_roles` granted
`can_manage_top_level_agents` to MANAGER_NAME on every meta sync, and that
role is what put every agent's state/config dir, `/applied` and `/meta` into
the root agent's nspawn binds. Collapsing the role into the capability left
the capability store with no equivalent, so the root agent would have lost
its recovery mounts at its next container rebuild — silently, and only then,
since nspawn bakes bind flags at container start.
Seed it in `ensure_root_agent`, before the create/start branch so a hive
whose root container already exists still gets the grant. Seeded once rather
than re-ensured every boot: the role kept an empty-list tombstone so a revoke
stuck, and the capability store deletes an emptied entry instead, so "no
entry for the manager" cannot tell a fresh hive from a deliberate revoke.
File existence can — every grant and revoke writes capabilities.json, even
when the result is `{}` — so the seed fires only while the store has never
been written, and is inert forever after.
The target is `lifecycle::MANAGER_NAME`, the same const the deleted role seed
used and the one definition of "the root agent" in the tree; no name literal
at the seeding site. The written string comes from
`Capability::ManageRootAgent` via `IntoStaticStr` rather than being spelled
out, so it cannot drift into a name `prune_unknown` would drop.
This commit is contained in:
parent
4f6407fdea
commit
1c53dc1935
2 changed files with 113 additions and 1 deletions
|
|
@ -515,6 +515,13 @@ dirs.
|
|||
revoking this capability does not change any mount until that agent's
|
||||
container is rebuilt/restarted.
|
||||
|
||||
The root agent gets the capability by default, seeded on its auto-deploy
|
||||
path (`workers::auto_update::ensure_root_agent`) so the recovery mounts
|
||||
are there from its first container. That seed only fires while
|
||||
`capabilities.json` has never been written: any grant or revoke through
|
||||
the dashboard creates the file, so a revoked root-agent grant stays
|
||||
revoked and is not re-applied on the next hive-c0re restart.
|
||||
|
||||
Each proposed repo (`/agents/<n>/config/`) is pre-configured
|
||||
with `applied` as a git remote pointing at
|
||||
`/applied/<n>/.git`. Useful incantations from inside an agent with
|
||||
|
|
|
|||
|
|
@ -94,6 +94,10 @@ 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 existing = lifecycle::list().await.unwrap_or_default();
|
||||
let current_rev = current_flake_rev(&coord.hyperhive_flake);
|
||||
if existing
|
||||
|
|
@ -184,6 +188,68 @@ 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.
|
||||
/// #4477 collapsed the role into the capability, so 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` → `write`, 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"
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
/// Sort `names` in-place so parents precede their children in the topology.
|
||||
/// Uses BFS from root agents (depth 0). Agents absent from `topo` sort last,
|
||||
/// alphabetically within their tier. Stable within each depth tier.
|
||||
|
|
@ -475,9 +541,48 @@ fn submit_boot_tree(
|
|||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{BootAction, boot_action};
|
||||
use super::{BootAction, boot_action, manager_seed_caps, should_seed_manager_caps};
|
||||
use crate::power::Wanted;
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// 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