//! The one-shot move of the capabilities this hive's meta repo held //! (`meta/capabilities.json`) into the swarm's `agent-permissions` bucket. //! //! At boot, before any agent starts, [`seed_local`] copies the file's grants //! into [`crate::capabilities`] when nothing is stored there yet, so agents //! keep their mounts until the swarm answers. [`send_pending`] then sends the //! grants to swarm-controller, which writes each agent's key only where there //! is none, and on its answer deletes the file from the meta repo. Nothing //! reads the file after that. use std::collections::BTreeMap; use std::path::{Path, PathBuf}; use anyhow::{Context, Result}; use hive_sh4re::permissions::Capability; use swarm_queue_client::agent_permissions::{ self, AgentPermissions, IMPORT_REPLY_DEADLINE, Import, ImportReply, }; /// The meta repo file the grants are moved out of. pub const META_FILE: &str = "capabilities.json"; fn meta_path() -> PathBuf { crate::paths::meta_root().join(META_FILE) } /// The grants the meta file holds, by agent, as the swarm stores them. A name /// that is not a [`Capability`] is dropped, with a warning. /// /// `ruth` with no entry in a file that exists had her grant revoked: the file /// was created holding it, and a revoke removed the entry. She is sent an /// empty list, since unset would give her the default back. fn grants(file: BTreeMap>) -> BTreeMap> { let mut out: BTreeMap> = file .into_iter() .map(|(agent, names)| { let caps = names .iter() .filter_map(|name| { let cap = Capability::ALL .iter() .copied() .find(|c| name.eq_ignore_ascii_case(<&str>::from(*c))); if cap.is_none() { tracing::warn!(%agent, capability = %name, "import: dropping unrecognised capability"); } cap }) .collect(); (agent, caps) }) .collect(); out.entry(crate::lifecycle::MANAGER_NAME.to_owned()) .or_default(); out } fn read_grants(path: &Path) -> Result>>> { if !path.exists() { return Ok(None); } let file: BTreeMap> = serde_json::from_slice( &std::fs::read(path).with_context(|| format!("reading {}", path.display()))?, ) .with_context(|| format!("parsing {}", path.display()))?; Ok(Some(grants(file))) } /// Store the meta file's grants as the last-read swarm permissions, when the /// file is still there and nothing has been stored yet. pub fn seed_local() { if let Err(e) = seed_local_at(&meta_path(), &crate::capabilities::path()) { tracing::warn!(error = ?e, "import: seeding the stored permissions from the meta repo failed"); } } fn seed_local_at(meta: &Path, stored: &Path) -> Result<()> { if stored.exists() { return Ok(()); } let Some(grants) = read_grants(meta)? else { return Ok(()); }; let records = grants .into_iter() .map(|(agent, caps)| { ( agent, AgentPermissions { capabilities: Some(caps), }, ) }) .collect(); crate::capabilities::write_at(stored, &records).context("storing the seeded permissions")?; tracing::info!("import: seeded the stored permissions from the meta repo's capabilities"); Ok(()) } /// Send the meta file's grants to swarm-controller while the file is there, /// and delete it once the controller has answered that it handled them. /// /// # Errors /// /// The file is still there: the import failed, or deleting the file did. The /// next pass sends again; the controller keeps a key it already has. pub async fn send_pending(client: &async_nats::Client, hive: &str) -> Result<()> { send_at(client, hive, &meta_path()) .await .context("importing the meta repo's capabilities") } async fn send_at(client: &async_nats::Client, hive: &str, path: &Path) -> Result<()> { let Some(capabilities) = read_grants(path)? else { return Ok(()); }; swarm_queue_client::ensure_connected(client)?; let payload = serde_json::to_vec(&Import { capabilities })?; let reply = tokio::time::timeout( IMPORT_REPLY_DEADLINE, client.request(agent_permissions::import_subject(hive), payload.into()), ) .await .context("the controller did not answer the import")? .context("sending the import")?; match serde_json::from_slice(&reply.payload).context("decoding the controller's answer")? { ImportReply::Imported { written, kept, skipped, } => { tracing::info!( ?written, ?kept, ?skipped, "import: the controller took the meta repo's capabilities" ); crate::meta::remove_capabilities_file() .await .context("deleting the imported capabilities file") } ImportReply::Failed { error } => { anyhow::bail!("the controller refused the import: {error}") } } } #[cfg(test)] mod tests { use std::collections::BTreeMap; use super::{grants, seed_local_at}; use hive_sh4re::permissions::Capability; fn file(json: &str) -> BTreeMap> { serde_json::from_str(json).expect("decodes") } #[test] fn grants_keep_known_names_and_a_revoked_ruth_stays_revoked() { let out = grants(file( r#"{"atlas": ["manage_root_agent", "fly_to_the_moon"], "iris": []}"#, )); assert_eq!(out["atlas"], vec![Capability::ManageRootAgent]); assert_eq!(out["iris"], Vec::::new()); assert_eq!(out["ruth"], Vec::::new()); let out = grants(file(r#"{"ruth": ["manage_root_agent"]}"#)); assert_eq!(out["ruth"], vec![Capability::ManageRootAgent]); } /// The seed writes once, from the file, and never over a stored read. #[test] fn the_seed_copies_the_file_only_when_nothing_is_stored() { let dir = tempfile::tempdir().expect("tempdir"); let meta = dir.path().join("capabilities.json"); let stored = dir.path().join("stored.json"); seed_local_at(&meta, &stored).expect("no file is no seed"); assert!(!stored.exists()); std::fs::write(&meta, r#"{"atlas": ["manage_root_agent"]}"#).expect("seed"); seed_local_at(&meta, &stored).expect("seeded"); assert!( crate::capabilities::holds(&stored, "atlas", Capability::ManageRootAgent) .expect("read") ); assert!( !crate::capabilities::holds(&stored, "ruth", Capability::ManageRootAgent) .expect("read") ); std::fs::write(&stored, "{}").expect("a later read"); seed_local_at(&meta, &stored).expect("left alone"); assert_eq!(std::fs::read_to_string(&stored).expect("read"), "{}"); } }