diff --git a/swarmctl/src/permissions.rs b/swarmctl/src/permissions.rs index 219dbf35..51994f14 100644 --- a/swarmctl/src/permissions.rs +++ b/swarmctl/src/permissions.rs @@ -43,6 +43,18 @@ pub(crate) fn get(target: &Target, agent: &str) -> Result<()> { Ok(()) } +/// The stored record of `agent`, printing nothing. +pub(crate) fn fetch(target: &Target, agent: &str) -> Result { + let uri = member(agent)?; + runtime()?.block_on(send( + target, + hyper::Method::GET, + &uri, + None::<&()>, + "permissions-get", + )) +} + /// Run `swarmctl agent permissions set`: store the given permissions, which /// replace the agent's record whole. pub(crate) fn set( diff --git a/swarmctl/src/terminal.rs b/swarmctl/src/terminal.rs index ab1eec89..6ae476ab 100644 --- a/swarmctl/src/terminal.rs +++ b/swarmctl/src/terminal.rs @@ -15,6 +15,7 @@ use std::time::{Duration, Instant}; use anyhow::{Context as _, Result, anyhow, bail}; use hive_types::Permission; +use swarm_controller_api::permissions::Permissions; use swarm_wire::wanted::AgentState; use zeroize::Zeroizing; @@ -199,6 +200,44 @@ fn retry(attempts: usize, every: Duration, mut f: impl FnMut() -> Result) Err(last.unwrap_or_else(|| anyhow!("no attempt was made"))) } +/// Gives ruth `manage_root_agent` without taking anything else from her. +/// +/// A new ruth gets the default set plus `manage_root_agent`. An existing one +/// keeps what the swarm stored for her and gains `manage_root_agent` only if +/// it is missing; one that holds it already is not written at all. Both calls +/// retry, since the controller declares a new agent in the background. +fn ensure_manager_permission( + exists: bool, + fetch: impl FnMut() -> Result, + mut store: impl FnMut(&[String]) -> Result<()>, + every: Duration, +) -> Result<()> { + let stored = if exists { + retry(30, every, fetch)?.permissions + } else { + None + }; + let Some(granted) = with_manage_root_agent(stored) else { + return Ok(()); + }; + let names: Vec = granted + .iter() + .map(|p| <&str>::from(*p).to_owned()) + .collect(); + retry(30, every, || store(&names)) +} + +/// `stored` plus `manage_root_agent`, or `None` when it holds it already. An +/// unset record means the default set. +fn with_manage_root_agent(stored: Option>) -> Option> { + let mut granted = stored.unwrap_or_else(|| Permission::DEFAULT.to_vec()); + if granted.contains(&Permission::ManageRootAgent) { + return None; + } + granted.push(Permission::ManageRootAgent); + Some(granted) +} + /// Whether `program` is an executable file in a `PATH` directory. fn on_path(program: &str) -> bool { std::env::var_os("PATH").is_some_and(|path| on_path_in(program, &path)) @@ -284,15 +323,13 @@ impl Actions for Live { if !exists { agent::request_create(target, RUTH, &hive, None)?; } - let granted: Vec = Permission::DEFAULT - .iter() - .chain([&Permission::ManageRootAgent]) - .map(|p| <&str>::from(*p).to_owned()) - .collect(); let every = Duration::from_secs(2); - retry(30, every, || { - permissions::store(target, RUTH, &granted, None) - })?; + ensure_manager_permission( + exists, + || permissions::fetch(target, RUTH), + |granted| permissions::store(target, RUTH, granted, None).map(drop), + every, + )?; retry(30, every, || { agent::request_state(target, RUTH, &hive, AgentState::Up) })?; @@ -389,6 +426,86 @@ mod tests { assert_eq!((calls, err.to_string().as_str()), (4, "never")); } + fn stored(permissions: Option>) -> Permissions { + Permissions { + permissions, + ..Permissions::default() + } + } + + /// Runs the ruth step against `record`; returns what it stored, if anything. + fn ruth_step(exists: bool, record: &Permissions) -> (Option>, usize) { + let mut written = None; + let mut fetches = 0; + ensure_manager_permission( + exists, + || { + fetches += 1; + Ok(record.clone()) + }, + |granted| { + written = Some(granted.to_vec()); + Ok(()) + }, + Duration::ZERO, + ) + .expect("the step succeeds"); + (written, fetches) + } + + #[test] + fn an_existing_ruth_keeps_her_permissions_and_gains_manage_root_agent() { + let held = vec![ + Permission::Messaging, + Permission::Logs, + Permission::WebTools, + ]; + let (written, _) = ruth_step(true, &stored(Some(held))); + assert_eq!( + written.expect("manage_root_agent was missing, so it is written"), + ["messaging", "logs", "web_tools", "manage_root_agent"] + ); + } + + #[test] + fn an_existing_ruth_that_holds_manage_root_agent_is_not_written() { + let held = vec![Permission::Logs, Permission::ManageRootAgent]; + let (written, fetches) = ruth_step(true, &stored(Some(held))); + assert_eq!(written, None); + assert_eq!(fetches, 1, "her record was read, not assumed"); + } + + #[test] + fn a_new_ruth_gets_the_defaults_and_manage_root_agent_without_a_read() { + let (written, fetches) = ruth_step(false, &stored(None)); + assert_eq!( + written.expect("written"), + [ + "messaging", + "meta", + "inbox", + "execution", + "manage_root_agent" + ] + ); + assert_eq!(fetches, 0); + } + + #[test] + fn an_existing_ruth_on_the_default_set_gets_the_defaults_and_manage_root_agent() { + let (written, _) = ruth_step(true, &stored(None)); + assert_eq!( + written.expect("written"), + [ + "messaging", + "meta", + "inbox", + "execution", + "manage_root_agent" + ] + ); + } + #[test] fn a_program_on_path_is_found_only_when_it_is_executable() { let dir = std::env::temp_dir().join(format!("swarmctl-onpath-{}", std::process::id()));