diff --git a/iroh-cli/src/commands.rs b/iroh-cli/src/commands.rs index 2e53bd3e0e..7e3502c275 100644 --- a/iroh-cli/src/commands.rs +++ b/iroh-cli/src/commands.rs @@ -115,36 +115,44 @@ impl Cli { match self.command { Commands::Console => { - let env = ConsoleEnv::for_console(data_dir)?; + let data_dir_owned = data_dir.to_owned(); if self.start { let config = NodeConfig::load(self.config.as_deref()).await?; start::run_with_command( &config, data_dir, RunType::SingleCommandNoAbort, - |iroh| async move { console::run(&iroh, &env).await }, + |iroh| async move { + let env = ConsoleEnv::for_console(data_dir_owned, &iroh).await?; + console::run(&iroh, &env).await + }, ) .await } else { crate::logging::init_terminal_logging()?; let iroh = QuicIroh::connect(data_dir).await.context("rpc connect")?; + let env = ConsoleEnv::for_console(data_dir_owned, &iroh).await?; console::run(&iroh, &env).await } } Commands::Rpc(command) => { - let env = ConsoleEnv::for_cli(data_dir)?; + let data_dir_owned = data_dir.to_owned(); if self.start { let config = NodeConfig::load(self.config.as_deref()).await?; start::run_with_command( &config, data_dir, RunType::SingleCommandAbortable, - |iroh| async move { command.run(&iroh, &env).await }, + move |iroh| async move { + let env = ConsoleEnv::for_cli(data_dir_owned, &iroh).await?; + command.run(&iroh, &env).await + }, ) .await } else { crate::logging::init_terminal_logging()?; let iroh = QuicIroh::connect(data_dir).await.context("rpc connect")?; + let env = ConsoleEnv::for_cli(data_dir_owned, &iroh).await?; command.run(&iroh, &env).await } } diff --git a/iroh-cli/src/commands/author.rs b/iroh-cli/src/commands/author.rs index 1499a523c7..8da797845c 100644 --- a/iroh-cli/src/commands/author.rs +++ b/iroh-cli/src/commands/author.rs @@ -26,6 +26,12 @@ pub enum AuthorCommands { Export { author: AuthorId }, /// Import an author Import { author: String }, + /// Print the default author for this node. + Default { + /// Switch to the default author (only in the Iroh console). + #[clap(long)] + switch: bool, + }, /// List authors. #[clap(alias = "ls")] List, @@ -47,6 +53,17 @@ impl AuthorCommands { println!("{}", author_id); } } + Self::Default { switch } => { + if switch && !env.is_console() { + bail!("The --switch flag is only supported within the Iroh console."); + } + let author_id = iroh.authors.default().await?; + println!("{}", author_id); + if switch { + env.set_author(author_id)?; + println!("Active author is now {}", fmt_short(author_id.as_bytes())); + } + } Self::New { switch } => { if switch && !env.is_console() { bail!("The --switch flag is only supported within the Iroh console."); diff --git a/iroh-cli/src/commands/console.rs b/iroh-cli/src/commands/console.rs index f087e6d490..df10126d75 100644 --- a/iroh-cli/src/commands/console.rs +++ b/iroh-cli/src/commands/console.rs @@ -53,7 +53,7 @@ impl Repl { pub fn run(self) -> anyhow::Result<()> { let mut rl = DefaultEditor::with_config(Config::builder().check_cursor_position(true).build())?; - let history_path = ConsolePaths::History.with_root(self.env.iroh_data_dir()); + let history_path = ConsolePaths::History.with_iroh_data_dir(self.env.iroh_data_dir()); rl.load_history(&history_path).ok(); loop { // prepare a channel to receive a signal from the main thread when a command completed @@ -87,13 +87,12 @@ impl Repl { pub fn prompt(&self) -> String { let mut pwd = String::new(); - if let Some(author) = &self.env.author(None).ok() { - pwd.push_str(&format!( - "{}{} ", - "author:".blue(), - fmt_short(author.as_bytes()).blue().bold(), - )); - } + let author = self.env.author(); + pwd.push_str(&format!( + "{}{} ", + "author:".blue(), + fmt_short(author.as_bytes()).blue().bold(), + )); if let Some(doc) = &self.env.doc(None).ok() { pwd.push_str(&format!( "{}{} ", diff --git a/iroh-cli/src/commands/doc.rs b/iroh-cli/src/commands/doc.rs index a461685f7b..7c6465b592 100644 --- a/iroh-cli/src/commands/doc.rs +++ b/iroh-cli/src/commands/doc.rs @@ -360,7 +360,7 @@ impl DocCommands { value, } => { let doc = get_doc(iroh, env, doc).await?; - let author = env.author(author)?; + let author = author.unwrap_or(env.author()); let key = key.as_bytes().to_vec(); let value = value.as_bytes().to_vec(); let hash = doc.set_bytes(author, key, value).await?; @@ -372,7 +372,7 @@ impl DocCommands { prefix, } => { let doc = get_doc(iroh, env, doc).await?; - let author = env.author(author)?; + let author = author.unwrap_or(env.author()); let prompt = format!("Deleting all entries whose key starts with {prefix}. Continue?"); if Confirm::new() @@ -453,7 +453,7 @@ impl DocCommands { no_prompt, } => { let doc = get_doc(iroh, env, doc).await?; - let author = env.author(author)?; + let author = author.unwrap_or(env.author()); let mut prefix = prefix.unwrap_or_else(|| String::from("")); if prefix.ends_with('/') { @@ -979,7 +979,9 @@ mod tests { let author = client.authors.create().await.context("author create")?; // set up command, getting iroh node - let cli = ConsoleEnv::for_console(data_dir.path()).context("ConsoleEnv")?; + let cli = ConsoleEnv::for_console(data_dir.path().to_owned(), &node) + .await + .context("ConsoleEnv")?; let iroh = iroh::client::QuicIroh::connect(data_dir.path()) .await .context("rpc connect")?; diff --git a/iroh-cli/src/config.rs b/iroh-cli/src/config.rs index a2bc03fc1d..861c2ec5ad 100644 --- a/iroh-cli/src/config.rs +++ b/iroh-cli/src/config.rs @@ -1,7 +1,7 @@ //! Configuration for the iroh CLI. use std::{ - env, fmt, + env, net::SocketAddr, path::{Path, PathBuf}, str::FromStr, @@ -9,14 +9,19 @@ use std::{ }; use anyhow::{anyhow, bail, Context, Result}; -use iroh::docs::{AuthorId, NamespaceId}; use iroh::net::{ defaults::{default_eu_relay_node, default_na_relay_node}, relay::{RelayMap, RelayNode}, }; use iroh::node::GcPolicy; +use iroh::{ + client::{Iroh, RpcService}, + docs::{AuthorId, NamespaceId}, +}; use parking_lot::RwLock; +use quic_rpc::ServiceConnection; use serde::{Deserialize, Serialize}; +use tracing::warn; const ENV_AUTHOR: &str = "IROH_AUTHOR"; const ENV_DOC: &str = "IROH_DOC"; @@ -26,47 +31,20 @@ const ENV_FILE_RUST_LOG: &str = "IROH_FILE_RUST_LOG"; /// CONFIG_FILE_NAME is the name of the optional config file located in the iroh home directory pub(crate) const CONFIG_FILE_NAME: &str = "iroh.config.toml"; -#[derive(Debug, Clone, Copy)] +#[derive(Debug, Clone, Copy, Eq, PartialEq, strum::AsRefStr, strum::EnumString, strum::Display)] pub(crate) enum ConsolePaths { - DefaultAuthor, + #[strum(serialize = "current-author")] + CurrentAuthor, + #[strum(serialize = "history")] History, } -impl From<&ConsolePaths> for &'static str { - fn from(value: &ConsolePaths) -> Self { - match value { - ConsolePaths::DefaultAuthor => "default_author.pubkey", - ConsolePaths::History => "history", - } - } -} -impl FromStr for ConsolePaths { - type Err = anyhow::Error; - fn from_str(s: &str) -> Result { - Ok(match s { - "default_author.pubkey" => Self::DefaultAuthor, - "history" => Self::History, - _ => bail!("unknown file or directory"), - }) - } -} - -impl fmt::Display for ConsolePaths { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - let s: &str = self.into(); - write!(f, "{s}") - } -} -impl AsRef for ConsolePaths { - fn as_ref(&self) -> &Path { - let s: &str = self.into(); - Path::new(s) - } -} - impl ConsolePaths { - pub fn with_root(self, root: impl AsRef) -> PathBuf { - PathBuf::from(root.as_ref()).join(self) + fn root(iroh_data_dir: impl AsRef) -> PathBuf { + PathBuf::from(iroh_data_dir.as_ref()).join("console") + } + pub fn with_iroh_data_dir(self, iroh_data_dir: impl AsRef) -> PathBuf { + Self::root(iroh_data_dir).join(self.as_ref()) } } @@ -145,7 +123,8 @@ pub(crate) struct ConsoleEnv(Arc>); struct ConsoleEnvInner { /// Active author. Read from IROH_AUTHOR env variable. /// For console also read from/persisted to a file (see [`ConsolePaths::DefaultAuthor`]) - author: Option, + /// Defaults to the node's default author if both are empty. + author: AuthorId, /// Active doc. Read from IROH_DOC env variable. Not persisted. doc: Option, is_console: bool, @@ -154,43 +133,57 @@ struct ConsoleEnvInner { impl ConsoleEnv { /// Read from environment variables and the console config file. - pub(crate) fn for_console(iroh_data_root: &Path) -> Result { - let author = match env_author()? { - Some(author) => Some(author), - None => Self::get_console_default_author(iroh_data_root)?, - }; + pub(crate) async fn for_console>( + iroh_data_dir: PathBuf, + iroh: &Iroh, + ) -> Result { + let console_data_dir = ConsolePaths::root(&iroh_data_dir); + tokio::fs::create_dir_all(&console_data_dir) + .await + .with_context(|| { + format!( + "failed to create console data directory at `{}`", + console_data_dir.to_string_lossy() + ) + })?; + + Self::migrate_console_files_016_017(&iroh_data_dir).await?; + + let configured_author = Self::get_console_default_author(&iroh_data_dir)?; + let author = env_author(configured_author, iroh).await?; let env = ConsoleEnvInner { author, doc: env_doc()?, is_console: true, - iroh_data_dir: iroh_data_root.to_path_buf(), + iroh_data_dir, }; Ok(Self(Arc::new(RwLock::new(env)))) } /// Read only from environment variables. - pub(crate) fn for_cli(iroh_data_root: &Path) -> Result { + pub(crate) async fn for_cli>( + iroh_data_dir: PathBuf, + iroh: &Iroh, + ) -> Result { + let author = env_author(None, iroh).await?; let env = ConsoleEnvInner { - author: env_author()?, + author, doc: env_doc()?, is_console: false, - iroh_data_dir: iroh_data_root.to_path_buf(), + iroh_data_dir, }; Ok(Self(Arc::new(RwLock::new(env)))) } - fn get_console_default_author(root: &Path) -> anyhow::Result> { - let author_path = ConsolePaths::DefaultAuthor.with_root(root); - if let Ok(s) = std::fs::read(&author_path) { - let author = String::from_utf8(s) - .map_err(Into::into) - .and_then(|s| AuthorId::from_str(&s)) - .with_context(|| { - format!( - "Failed to parse author file at {}", - author_path.to_string_lossy() - ) - })?; + fn get_console_default_author(iroh_data_root: &Path) -> anyhow::Result> { + let author_path = ConsolePaths::CurrentAuthor.with_iroh_data_dir(iroh_data_root); + if let Ok(s) = std::fs::read_to_string(&author_path) { + let author = AuthorId::from_str(&s).with_context(|| { + format!( + "Failed to parse author file at {}", + author_path.to_string_lossy() + ) + })?; Ok(Some(author)) } else { Ok(None) @@ -212,12 +205,12 @@ impl ConsoleEnv { /// Will error if not running in the Iroh console. /// Will persist to a file in the Iroh data dir otherwise. pub(crate) fn set_author(&self, author: AuthorId) -> anyhow::Result<()> { - let author_path = ConsolePaths::DefaultAuthor.with_root(self.iroh_data_dir()); + let author_path = ConsolePaths::CurrentAuthor.with_iroh_data_dir(self.iroh_data_dir()); let mut inner = self.0.write(); if !inner.is_console { bail!("Switching the author is only supported within the Iroh console, not on the command line"); } - inner.author = Some(author); + inner.author = author; std::fs::write(author_path, author.to_string().as_bytes())?; Ok(()) } @@ -248,26 +241,60 @@ impl ConsoleEnv { } /// Get the active author. - pub(crate) fn author(&self, arg: Option) -> anyhow::Result { + /// + /// This is either the node's default author, or in the console optionally the author manually + /// switched to. + pub(crate) fn author(&self) -> AuthorId { let inner = self.0.read(); - let author_id = arg.or(inner.author).ok_or_else(|| { - anyhow!( - "Missing author id. Set the active author with the `IROH_AUTHOR` environment variable or the `-a` option.\n\ - In the console, you can also set the active author with `author switch`." + inner.author + } + + pub(crate) async fn migrate_console_files_016_017(iroh_data_dir: &Path) -> Result<()> { + // In iroh up to 0.16, we stored console settings directly in the data directory. Starting + // from 0.17, they live in a subdirectory and have new paths. + let old_current_author = iroh_data_dir.join("default_author.pubkey"); + if old_current_author.is_file() { + if let Err(err) = tokio::fs::rename( + &old_current_author, + ConsolePaths::CurrentAuthor.with_iroh_data_dir(iroh_data_dir), ) - })?; - Ok(author_id) + .await + { + warn!(path=%old_current_author.to_string_lossy(), "failed to migrate the console's current author file: {err}"); + } + } + let old_history = iroh_data_dir.join("history"); + if old_history.is_file() { + if let Err(err) = tokio::fs::rename( + &old_history, + ConsolePaths::History.with_iroh_data_dir(iroh_data_dir), + ) + .await + { + warn!(path=%old_history.to_string_lossy(), "failed to migrate the console's history file: {err}"); + } + } + Ok(()) } } -fn env_author() -> Result> { - env::var(ENV_AUTHOR) +async fn env_author>( + from_config: Option, + iroh: &Iroh, +) -> Result { + if let Some(author) = env::var(ENV_AUTHOR) .ok() .map(|s| { s.parse() .context("Failed to parse IROH_AUTHOR environment variable") }) - .transpose() + .transpose()? + .or(from_config) + { + Ok(author) + } else { + iroh.authors.default().await + } } fn env_doc() -> Result> { diff --git a/iroh/examples/client.rs b/iroh/examples/client.rs index c0eeb952f6..3e4d018aed 100644 --- a/iroh/examples/client.rs +++ b/iroh/examples/client.rs @@ -17,7 +17,7 @@ async fn main() -> anyhow::Result<()> { let client = node.client(); let doc = client.docs.create().await?; - let author = client.authors.create().await?; + let author = client.authors.default().await?; doc.set_bytes(author, "hello", "world").await?; diff --git a/iroh/src/client/authors.rs b/iroh/src/client/authors.rs index 690ae228da..b695b3da7c 100644 --- a/iroh/src/client/authors.rs +++ b/iroh/src/client/authors.rs @@ -6,8 +6,8 @@ use iroh_docs::{Author, AuthorId}; use quic_rpc::{RpcClient, ServiceConnection}; use crate::rpc_protocol::{ - AuthorCreateRequest, AuthorDeleteRequest, AuthorExportRequest, AuthorImportRequest, - AuthorListRequest, RpcService, + AuthorCreateRequest, AuthorDeleteRequest, AuthorExportRequest, AuthorGetDefaultRequest, + AuthorImportRequest, AuthorListRequest, AuthorSetDefaultRequest, RpcService, }; use super::flatten; @@ -23,11 +23,40 @@ where C: ServiceConnection, { /// Create a new document author. + /// + /// You likely want to save the returned [`AuthorId`] somewhere so that you can use this author + /// again. + /// + /// If you need only a single author, use [`Self::default`]. pub async fn create(&self) -> Result { let res = self.rpc.rpc(AuthorCreateRequest).await??; Ok(res.author_id) } + /// Returns the default document author of this node. + /// + /// On persistent nodes, the author is created on first start and its public key is saved + /// in the data directory. + /// + /// The default author can be set with [`Self::set_default`]. + pub async fn default(&self) -> Result { + let res = self.rpc.rpc(AuthorGetDefaultRequest).await?; + Ok(res.author_id) + } + + /// Set the node-wide default author. + /// + /// If the author does not exist, an error is returned. + /// + /// On a persistent node, the author id will be saved to a file in the data directory and + /// reloaded after a restart. + pub async fn set_default(&self, author_id: AuthorId) -> Result<()> { + self.rpc + .rpc(AuthorSetDefaultRequest { author_id }) + .await??; + Ok(()) + } + /// List document authors for which we have a secret key. pub async fn list(&self) -> Result>> { let stream = self.rpc.server_streaming(AuthorListRequest {}).await?; @@ -53,6 +82,8 @@ where /// Deletes the given author by id. /// /// Warning: This permanently removes this author. + /// + /// Returns an error if attempting to delete the default author. pub async fn delete(&self, author: AuthorId) -> Result<()> { self.rpc.rpc(AuthorDeleteRequest { author }).await??; Ok(()) @@ -69,10 +100,16 @@ mod tests { async fn test_authors() -> Result<()> { let node = Node::memory().spawn().await?; + // default author always exists + let authors: Vec<_> = node.authors.list().await?.try_collect().await?; + assert_eq!(authors.len(), 1); + let default_author = node.authors.default().await?; + assert_eq!(authors, vec![default_author]); + let author_id = node.authors.create().await?; let authors: Vec<_> = node.authors.list().await?.try_collect().await?; - assert_eq!(authors.len(), 1); + assert_eq!(authors.len(), 2); let author = node .authors @@ -81,12 +118,16 @@ mod tests { .expect("should have author"); node.authors.delete(author_id).await?; let authors: Vec<_> = node.authors.list().await?.try_collect().await?; - assert!(authors.is_empty()); + assert_eq!(authors.len(), 1); node.authors.import(author).await?; let authors: Vec<_> = node.authors.list().await?.try_collect().await?; - assert_eq!(authors.len(), 1); + assert_eq!(authors.len(), 2); + + assert!(node.authors.default().await? != author_id); + node.authors.set_default(author_id).await?; + assert_eq!(node.authors.default().await?, author_id); Ok(()) } diff --git a/iroh/src/docs_engine.rs b/iroh/src/docs_engine.rs index 018d894f16..b64870fda3 100644 --- a/iroh/src/docs_engine.rs +++ b/iroh/src/docs_engine.rs @@ -2,13 +2,19 @@ //! //! [`iroh_docs::Replica`] is also called documents here. -use std::{io, sync::Arc}; +use std::path::PathBuf; +use std::{ + io, + str::FromStr, + sync::{Arc, RwLock}, +}; -use anyhow::Result; +use anyhow::{bail, Context, Result}; use futures_lite::{Stream, StreamExt}; use iroh_blobs::downloader::Downloader; use iroh_blobs::{store::EntryStatus, Hash}; use iroh_docs::{actor::SyncHandle, ContentStatus, ContentStatusCallback, Entry, NamespaceId}; +use iroh_docs::{Author, AuthorId}; use iroh_gossip::net::Gossip; use iroh_net::util::SharedAbortingJoinHandle; use iroh_net::{key::PublicKey, Endpoint, NodeAddr}; @@ -46,6 +52,7 @@ pub struct Engine { actor_handle: SharedAbortingJoinHandle<()>, #[debug("ContentStatusCallback")] content_status_cb: ContentStatusCallback, + default_author: Arc, } impl Engine { @@ -53,13 +60,14 @@ impl Engine { /// /// This will spawn two tokio tasks for the live sync coordination and gossip actors, and a /// thread for the [`iroh_docs::actor::SyncHandle`]. - pub(crate) fn spawn( + pub(crate) async fn spawn( endpoint: Endpoint, gossip: Gossip, replica_store: iroh_docs::store::Store, bao_store: B, downloader: Downloader, - ) -> Self { + default_author_storage: DefaultAuthorStorage, + ) -> anyhow::Result { let (live_actor_tx, to_live_actor_recv) = mpsc::channel(ACTOR_CHANNEL_CAP); let (to_gossip_actor, to_gossip_actor_recv) = mpsc::channel(ACTOR_CHANNEL_CAP); let me = endpoint.node_id().fmt_short(); @@ -95,13 +103,24 @@ impl Engine { .instrument(error_span!("sync", %me)), ); - Self { + let default_author = match DefaultAuthor::load(default_author_storage, &sync).await { + Ok(author) => author, + Err(err) => { + // If loading the default author failed, make sure to shutdown the sync actor before + // returning. + sync.shutdown().await?; + return Err(err); + } + }; + + Ok(Self { endpoint, sync, to_live_actor: live_actor_tx, actor_handle: actor_handle.into(), content_status_cb, - } + default_author: Arc::new(default_author), + }) } /// Start to sync a document. @@ -273,3 +292,100 @@ impl LiveEvent { }) } } + +/// Where to persist the default author. +/// +/// If set to `Mem`, a new author will be created in the docs store before spawning the sync +/// engine. Changing the default author will not be persisted. +/// +/// If set to `Persistent`, the default author will be loaded from and persisted to the specified +/// path (as base32 encoded string of the author's public key). +#[derive(Debug)] +pub enum DefaultAuthorStorage { + Mem, + Persistent(PathBuf), +} + +impl DefaultAuthorStorage { + pub async fn load(&self, docs_store: &SyncHandle) -> anyhow::Result { + match self { + Self::Mem => { + let author = Author::new(&mut rand::thread_rng()); + let author_id = author.id(); + docs_store.import_author(author).await?; + Ok(author_id) + } + Self::Persistent(ref path) => { + if path.exists() { + let data = tokio::fs::read_to_string(path).await.with_context(|| { + format!( + "Failed to read the default author file at `{}`", + path.to_string_lossy() + ) + })?; + let author_id = AuthorId::from_str(&data).with_context(|| { + format!( + "Failed to parse the default author from `{}`", + path.to_string_lossy() + ) + })?; + if docs_store.export_author(author_id).await?.is_none() { + bail!("The default author is missing from the docs store. To recover, delete the file `{}`. Then iroh will create a new default author.", path.to_string_lossy()) + } + Ok(author_id) + } else { + let author = Author::new(&mut rand::thread_rng()); + let author_id = author.id(); + docs_store.import_author(author).await?; + self.persist(author_id).await?; + Ok(author_id) + } + } + } + } + pub async fn persist(&self, author_id: AuthorId) -> anyhow::Result<()> { + match self { + Self::Mem => { + // persistence is not possible for the mem storage so this is a noop. + } + Self::Persistent(ref path) => { + tokio::fs::write(path, author_id.to_string()) + .await + .with_context(|| { + format!( + "Failed to write the default author to `{}`", + path.to_string_lossy() + ) + })?; + } + } + Ok(()) + } +} + +#[derive(Debug)] +struct DefaultAuthor { + value: RwLock, + storage: DefaultAuthorStorage, +} + +impl DefaultAuthor { + async fn load(storage: DefaultAuthorStorage, docs_store: &SyncHandle) -> Result { + let value = storage.load(docs_store).await?; + Ok(Self { + value: RwLock::new(value), + storage, + }) + } + fn get(&self) -> AuthorId { + *self.value.read().unwrap() + } + async fn set(&self, author_id: AuthorId, docs_store: &SyncHandle) -> Result<()> { + if docs_store.export_author(author_id).await?.is_none() { + bail!("The author does not exist"); + } + self.storage.persist(author_id).await?; + *self.value.write().unwrap() = author_id; + Ok(()) + } +} diff --git a/iroh/src/docs_engine/rpc.rs b/iroh/src/docs_engine/rpc.rs index 51739a92c6..76f2afd761 100644 --- a/iroh/src/docs_engine/rpc.rs +++ b/iroh/src/docs_engine/rpc.rs @@ -9,7 +9,9 @@ use tokio_stream::StreamExt; use crate::client::docs::ShareMode; use crate::rpc_protocol::{ AuthorDeleteRequest, AuthorDeleteResponse, AuthorExportRequest, AuthorExportResponse, - AuthorImportRequest, AuthorImportResponse, DocGetSyncPeersRequest, DocGetSyncPeersResponse, + AuthorGetDefaultRequest, AuthorGetDefaultResponse, AuthorImportRequest, AuthorImportResponse, + AuthorSetDefaultRequest, AuthorSetDefaultResponse, DocGetSyncPeersRequest, + DocGetSyncPeersResponse, }; use crate::{ docs_engine::Engine, @@ -44,6 +46,19 @@ impl Engine { }) } + pub fn author_default(&self, _req: AuthorGetDefaultRequest) -> AuthorGetDefaultResponse { + let author_id = self.default_author.get(); + AuthorGetDefaultResponse { author_id } + } + + pub async fn author_set_default( + &self, + req: AuthorSetDefaultRequest, + ) -> RpcResult { + self.default_author.set(req.author_id, &self.sync).await?; + Ok(AuthorSetDefaultResponse) + } + pub fn author_list( &self, _req: AuthorListRequest, @@ -76,6 +91,9 @@ impl Engine { } pub async fn author_delete(&self, req: AuthorDeleteRequest) -> RpcResult { + if req.author == self.default_author.get() { + return Err(anyhow!("Deleting the default author is not supported").into()); + } self.sync.delete_author(req.author).await?; Ok(AuthorDeleteResponse) } diff --git a/iroh/src/node.rs b/iroh/src/node.rs index 88665a8c72..d202c2753f 100644 --- a/iroh/src/node.rs +++ b/iroh/src/node.rs @@ -387,4 +387,128 @@ mod tests { ); Ok(()) } + + #[tokio::test] + async fn test_default_author_memory() -> Result<()> { + let iroh = Node::memory().spawn().await?; + let author = iroh.authors.default().await?; + assert!(iroh.authors.export(author).await?.is_some()); + assert!(iroh.authors.delete(author).await.is_err()); + Ok(()) + } + + #[cfg(feature = "fs-store")] + #[tokio::test] + async fn test_default_author_persist() -> Result<()> { + use crate::util::path::IrohPaths; + + let _guard = iroh_test::logging::setup(); + + let iroh_root_dir = tempfile::TempDir::new().unwrap(); + let iroh_root = iroh_root_dir.path(); + + // check that the default author exists and cannot be deleted. + let default_author = { + let iroh = Node::persistent(iroh_root) + .await + .unwrap() + .spawn() + .await + .unwrap(); + let author = iroh.authors.default().await.unwrap(); + assert!(iroh.authors.export(author).await.unwrap().is_some()); + assert!(iroh.authors.delete(author).await.is_err()); + iroh.shutdown().await.unwrap(); + author + }; + + // check that the default author is persisted across restarts. + { + let iroh = Node::persistent(iroh_root) + .await + .unwrap() + .spawn() + .await + .unwrap(); + let author = iroh.authors.default().await.unwrap(); + assert_eq!(author, default_author); + assert!(iroh.authors.export(author).await.unwrap().is_some()); + assert!(iroh.authors.delete(author).await.is_err()); + iroh.shutdown().await.unwrap(); + }; + + // check that a new default author is created if the default author file is deleted + // manually. + let default_author = { + tokio::fs::remove_file(IrohPaths::DefaultAuthor.with_root(iroh_root)) + .await + .unwrap(); + let iroh = Node::persistent(iroh_root) + .await + .unwrap() + .spawn() + .await + .unwrap(); + let author = iroh.authors.default().await.unwrap(); + assert!(author != default_author); + assert!(iroh.authors.export(author).await.unwrap().is_some()); + assert!(iroh.authors.delete(author).await.is_err()); + iroh.shutdown().await.unwrap(); + author + }; + + // check that the node fails to start if the default author is missing from the docs store. + { + let mut docs_store = iroh_docs::store::fs::Store::persistent( + IrohPaths::DocsDatabase.with_root(iroh_root), + ) + .unwrap(); + docs_store.delete_author(default_author).unwrap(); + docs_store.flush().unwrap(); + drop(docs_store); + let iroh = Node::persistent(iroh_root).await.unwrap().spawn().await; + dbg!(&iroh); + assert!(iroh.is_err()); + + // somehow the blob store is not shutdown correctly (yet?) on macos. + // so we give it some time until we find a proper fix. + #[cfg(target_os = "macos")] + tokio::time::sleep(Duration::from_secs(1)).await; + + tokio::fs::remove_file(IrohPaths::DefaultAuthor.with_root(iroh_root)) + .await + .unwrap(); + drop(iroh); + let iroh = Node::persistent(iroh_root).await.unwrap().spawn().await; + assert!(iroh.is_ok()); + iroh.unwrap().shutdown().await.unwrap(); + } + + // check that the default author can be set manually and is persisted. + let default_author = { + let iroh = Node::persistent(iroh_root) + .await + .unwrap() + .spawn() + .await + .unwrap(); + let author = iroh.authors.create().await.unwrap(); + iroh.authors.set_default(author).await.unwrap(); + assert_eq!(iroh.authors.default().await.unwrap(), author); + iroh.shutdown().await.unwrap(); + author + }; + { + let iroh = Node::persistent(iroh_root) + .await + .unwrap() + .spawn() + .await + .unwrap(); + assert_eq!(iroh.authors.default().await.unwrap(), default_author); + iroh.shutdown().await.unwrap(); + } + + Ok(()) + } } diff --git a/iroh/src/node/builder.rs b/iroh/src/node/builder.rs index 833071ac05..60e59d868e 100644 --- a/iroh/src/node/builder.rs +++ b/iroh/src/node/builder.rs @@ -32,7 +32,7 @@ use tracing::{debug, error, error_span, info, trace, warn, Instrument}; use crate::{ client::RPC_ALPN, - docs_engine::Engine, + docs_engine::{DefaultAuthorStorage, Engine}, node::NodeInner, rpc_protocol::{Request, Response, RpcService}, util::{fs::load_secret_key, path::IrohPaths}, @@ -366,7 +366,20 @@ where /// This will create the underlying network server and spawn a tokio task accepting /// connections. The returned [`Node`] can be used to control the task as well as /// get information about it. - pub async fn spawn(mut self) -> Result> { + pub async fn spawn(self) -> Result> { + // We clone the blob store to shut it down in case the node fails to spawn. + let blobs_store = self.blobs_store.clone(); + match self.spawn_inner().await { + Ok(node) => Ok(node), + Err(err) => { + debug!("failed to spawn node, shutting down"); + blobs_store.shutdown().await; + Err(err) + } + } + } + + async fn spawn_inner(mut self) -> Result> { trace!("spawning node"); let lp = LocalPoolHandle::new(num_cpus::get()); @@ -430,15 +443,28 @@ where // initialize the gossip protocol let gossip = Gossip::from_endpoint(endpoint.clone(), Default::default(), &addr.info); - // spawn the sync engine + // initialize the downloader let downloader = Downloader::new(self.blobs_store.clone(), endpoint.clone(), lp.clone()); + + // load or create the default author for documents + let default_author_storage = match self.storage { + StorageConfig::Persistent(ref root) => { + let path = IrohPaths::DefaultAuthor.with_root(root); + DefaultAuthorStorage::Persistent(path) + } + StorageConfig::Mem => DefaultAuthorStorage::Mem, + }; + + // spawn the docs engine let sync = Engine::spawn( endpoint.clone(), gossip.clone(), self.docs_store, self.blobs_store.clone(), downloader.clone(), - ); + default_author_storage, + ) + .await?; let sync_db = sync.sync.clone(); let gc_task = if let GcPolicy::Interval(gc_period) = self.gc_policy { diff --git a/iroh/src/node/rpc.rs b/iroh/src/node/rpc.rs index 0c50f7ed33..ba03e10486 100644 --- a/iroh/src/node/rpc.rs +++ b/iroh/src/node/rpc.rs @@ -161,6 +161,18 @@ impl Handler { }) .await } + AuthorGetDefault(msg) => { + chan.rpc(msg, handler, |handler, req| async move { + handler.inner.sync.author_default(req) + }) + .await + } + AuthorSetDefault(msg) => { + chan.rpc(msg, handler, |handler, req| async move { + handler.inner.sync.author_set_default(req).await + }) + .await + } DocOpen(msg) => { chan.rpc(msg, handler, |handler, req| async move { handler.inner.sync.doc_open(req).await diff --git a/iroh/src/rpc_protocol.rs b/iroh/src/rpc_protocol.rs index 90b2637a4c..7bfb5d60b3 100644 --- a/iroh/src/rpc_protocol.rs +++ b/iroh/src/rpc_protocol.rs @@ -438,6 +438,35 @@ pub struct AuthorCreateResponse { pub author_id: AuthorId, } +/// Get the default author. +#[derive(Serialize, Deserialize, Debug)] +pub struct AuthorGetDefaultRequest; + +impl RpcMsg for AuthorGetDefaultRequest { + type Response = AuthorGetDefaultResponse; +} + +/// Response for [`AuthorGetDefaultRequest`] +#[derive(Serialize, Deserialize, Debug)] +pub struct AuthorGetDefaultResponse { + /// The id of the author + pub author_id: AuthorId, +} + +#[derive(Serialize, Deserialize, Debug)] +pub struct AuthorSetDefaultRequest { + /// The id of the author + pub author_id: AuthorId, +} + +impl RpcMsg for AuthorSetDefaultRequest { + type Response = RpcResult; +} + +/// Response for [`AuthorGetDefaultRequest`] +#[derive(Serialize, Deserialize, Debug)] +pub struct AuthorSetDefaultResponse; + /// Delete an author #[derive(Serialize, Deserialize, Debug)] pub struct AuthorDeleteRequest { @@ -1070,6 +1099,8 @@ pub enum Request { AuthorList(AuthorListRequest), AuthorCreate(AuthorCreateRequest), + AuthorGetDefault(AuthorGetDefaultRequest), + AuthorSetDefault(AuthorSetDefaultRequest), AuthorImport(AuthorImportRequest), AuthorExport(AuthorExportRequest), AuthorDelete(AuthorDeleteRequest), @@ -1130,6 +1161,8 @@ pub enum Response { AuthorList(RpcResult), AuthorCreate(RpcResult), + AuthorGetDefault(AuthorGetDefaultResponse), + AuthorSetDefault(RpcResult), AuthorImport(RpcResult), AuthorExport(RpcResult), AuthorDelete(RpcResult), diff --git a/iroh/src/util/path.rs b/iroh/src/util/path.rs index 0240e11de5..f7ee91af40 100644 --- a/iroh/src/util/path.rs +++ b/iroh/src/util/path.rs @@ -24,6 +24,9 @@ pub enum IrohPaths { #[strum(serialize = "rpc.lock")] /// Path to RPC lock file, containing the RPC port if running. RpcLock, + /// Path to the [`iroh_docs::AuthorId`] of the node's default author + #[strum(serialize = "default-author")] + DefaultAuthor, } impl AsRef for IrohPaths {