diff --git a/radicle-cli/examples/rad-init-sync-not-connected.md b/radicle-cli/examples/rad-init-sync-not-connected.md new file mode 100644 index 00000000..e5da18b1 --- /dev/null +++ b/radicle-cli/examples/rad-init-sync-not-connected.md @@ -0,0 +1,19 @@ +When initializing a project without any peer connections, we get this output: + +``` +$ rad init --name heartwood --description "Radicle Heartwood Protocol & Stack" --no-confirm --public --scope followed + +Initializing public radicle 👾 project in . + +✓ Project heartwood created. + +Your project's Repository ID (RID) is rad:z42hL2jL4XNk6K8oHQaSWfMgCL7ji. +You can show it any time by running `rad .` from this directory. + +✗ Announcing.. + +You are not connected to any peers. Your project will be announced as soon as your node establishes a connection with the network. +Check for peer connections with `rad node status`. + +To push changes, run `git push`. +``` diff --git a/radicle-cli/examples/rad-init-sync-preferred.md b/radicle-cli/examples/rad-init-sync-preferred.md new file mode 100644 index 00000000..29b81803 --- /dev/null +++ b/radicle-cli/examples/rad-init-sync-preferred.md @@ -0,0 +1,21 @@ +Let's try initializing a new project with a preferred seed configured. + +``` +$ rad init --name heartwood --description "Radicle Heartwood Protocol & Stack" --no-confirm --public --scope followed + +Initializing public radicle 👾 project in . + +✓ Project heartwood created. + +Your project's Repository ID (RID) is rad:z3Rry7rpdWuGpfjPYGzdJKQADsoNW. +You can show it any time by running `rad .` from this directory. + +✓ Project successfully synced to 1 node(s). + +Your project has been synced to the network and is now discoverable by peers. +View it in your browser at: + + https://app.radicle.xyz/nodes/[...]/rad:z3Rry7rpdWuGpfjPYGzdJKQADsoNW + +To push changes, run `git push`. +``` diff --git a/radicle-cli/examples/rad-init-sync-timeout.md b/radicle-cli/examples/rad-init-sync-timeout.md new file mode 100644 index 00000000..836e1293 --- /dev/null +++ b/radicle-cli/examples/rad-init-sync-timeout.md @@ -0,0 +1,20 @@ +Sometimes, `init` will fail to sync with the network. This is not a big deal, +as the node will keep attempting to sync in the background. + +``` +$ rad init --name heartwood --description "Radicle Heartwood Protocol & Stack" --no-confirm --public --scope followed + +Initializing public radicle 👾 project in . + +✓ Project heartwood created. + +Your project's Repository ID (RID) is rad:z3Rry7rpdWuGpfjPYGzdJKQADsoNW. +You can show it any time by running `rad .` from this directory. + +✓ Project successfully announced to the network. + +Your project has been announced to the network and is now discoverable by peers. +You can check for any nodes that have replicated your project by running `rad sync status`. + +To push changes, run `git push`. +``` diff --git a/radicle-cli/examples/rad-init-sync.md b/radicle-cli/examples/rad-init-sync.md index 8324fba2..3a45fa67 100644 --- a/radicle-cli/examples/rad-init-sync.md +++ b/radicle-cli/examples/rad-init-sync.md @@ -12,8 +12,10 @@ Initializing public radicle 👾 project in . Your project's Repository ID (RID) is rad:z42hL2jL4XNk6K8oHQaSWfMgCL7ji. You can show it any time by running `rad .` from this directory. -✓ Project successfully announced. +✓ Project successfully announced to the network. Your project has been announced to the network and is now discoverable by peers. +You can check for any nodes that have replicated your project by running `rad sync status`. + To push changes, run `git push`. ``` diff --git a/radicle-cli/examples/rad-patch-pull-update.md b/radicle-cli/examples/rad-patch-pull-update.md index 1673f276..d433cbe1 100644 --- a/radicle-cli/examples/rad-patch-pull-update.md +++ b/radicle-cli/examples/rad-patch-pull-update.md @@ -12,9 +12,11 @@ Initializing public radicle 👾 project in . Your project's Repository ID (RID) is rad:zhbMU4DUXrzB8xT6qAJh6yZ7bFMK. You can show it any time by running `rad .` from this directory. -✓ Project successfully announced. +✓ Project successfully announced to the network. Your project has been announced to the network and is now discoverable by peers. +You can check for any nodes that have replicated your project by running `rad sync status`. + To push changes, run `git push`. ``` diff --git a/radicle-cli/src/commands/init.rs b/radicle-cli/src/commands/init.rs index 2a551eb6..a66e677d 100644 --- a/radicle-cli/src/commands/init.rs +++ b/radicle-cli/src/commands/init.rs @@ -1,23 +1,25 @@ #![allow(clippy::or_fun_call)] #![allow(clippy::collapsible_else_if)] +use std::collections::HashSet; use std::convert::TryFrom; -use std::env; use std::ffi::OsString; use std::path::PathBuf; use std::str::FromStr; +use std::{env, io, time}; use anyhow::{anyhow, bail, Context as _}; use serde_json as json; use radicle::crypto::{ssh, Verified}; use radicle::git::RefString; -use radicle::identity::Visibility; +use radicle::identity::{Id, Visibility}; use radicle::node::policy::Scope; -use radicle::node::{Handle, NodeId}; +use radicle::node::{Event, Handle, NodeId}; use radicle::prelude::Doc; use radicle::{profile, Node}; use crate as cli; +use crate::commands; use crate::git; use crate::terminal as term; use crate::terminal::args::{Args, Error, Help}; @@ -274,7 +276,7 @@ pub fn init(options: Options, profile: &profile::Profile) -> anyhow::Result<()> &signer, &profile.storage, ) { - Ok((id, doc, _)) => { + Ok((rid, doc, _)) => { let proj = doc.project()?; spinner.message(format!( @@ -290,7 +292,7 @@ pub fn init(options: Options, profile: &profile::Profile) -> anyhow::Result<()> // It's important to seed our own repositories to make sure that our node signals // interest for them. This ensures that messages relating to them are relayed to us. if options.seed { - cli::project::seed(id, options.scope, &mut node, profile)?; + cli::project::seed(rid, options.scope, &mut node, profile)?; } if options.set_upstream || git::branch_remote(&repo, proj.default_branch()).is_err() { @@ -314,7 +316,7 @@ pub fn init(options: Options, profile: &profile::Profile) -> anyhow::Result<()> term::info!( "Your project's Repository ID {} is {}.", term::format::dim("(RID)"), - term::format::highlight(id.urn()) + term::format::highlight(rid.urn()) ); term::info!( "You can show it any time by running {} from this directory.", @@ -323,7 +325,7 @@ pub fn init(options: Options, profile: &profile::Profile) -> anyhow::Result<()> term::blank(); // Announce inventory to network. - if let Err(e) = announce(doc, &mut node) { + if let Err(e) = announce(rid, doc, &mut node, &profile.config) { term::blank(); term::warning(format!( "There was an error announcing your project to the network: {e}" @@ -342,28 +344,168 @@ pub fn init(options: Options, profile: &profile::Profile) -> anyhow::Result<()> Ok(()) } -pub fn announce(doc: Doc, node: &mut Node) -> anyhow::Result<()> { +#[derive(Debug)] +enum SyncResult { + NodeStopped, + NoPeersConnected, + NotSynced, + Synced { result: T }, +} + +fn sync( + rid: Id, + node: &mut Node, + config: &profile::Config, +) -> Result>, radicle::node::Error> { + if !node.is_running() { + return Ok(SyncResult::NodeStopped); + } + let mut spinner = term::spinner("Updating inventory.."); + let events = node.subscribe(time::Duration::from_secs(3))?; + let sessions = node.sessions()?; + + node.sync_inventory()?; + spinner.message("Announcing.."); + + if !sessions.iter().any(|s| s.is_connected()) { + return Ok(SyncResult::NoPeersConnected); + } + + // Connect to preferred seeds in case we aren't connected. + for seed in &config.preferred_seeds { + if !sessions.iter().any(|s| s.nid == seed.id) { + commands::rad_node::control::connect( + node, + seed.id, + seed.addr.clone(), + radicle::node::DEFAULT_TIMEOUT, + ) + .ok(); + } + } + // Announce our new inventory to connected nodes. + node.announce_inventory()?; + + spinner.message("Syncing.."); + + let mut replicas = HashSet::new(); + for e in events { + match e { + Ok(Event::RefsSynced { + remote, rid: rid_, .. + }) if rid == rid_ => { + replicas.insert(remote); + // If we manage to replicate to one of our preferred seeds, we can stop waiting. + if config.preferred_seeds.iter().any(|s| s.id == remote) { + break; + } + } + Ok(_) => { + // Some other irrelevant event received. + } + Err(e) if e.kind() == io::ErrorKind::TimedOut => { + break; + } + Err(e) => { + spinner.error(&e); + return Err(e.into()); + } + } + } + + if !replicas.is_empty() { + spinner.message(format!( + "Project successfully synced to {} node(s).", + replicas.len() + )); + spinner.finish(); + + for seed in &config.preferred_seeds { + if replicas.contains(&seed.id) { + return Ok(SyncResult::Synced { + result: Some( + config + .public_explorer + .url(seed.addr.host.to_string().as_str(), &rid), + ), + }); + } + } + Ok(SyncResult::Synced { result: None }) + } else { + spinner.message("Project successfully announced to the network."); + spinner.finish(); + + Ok(SyncResult::NotSynced) + } +} + +pub fn announce( + rid: Id, + doc: Doc, + node: &mut Node, + config: &profile::Config, +) -> anyhow::Result<()> { if doc.visibility.is_public() { - if node.is_running() { - let mut spinner = term::spinner("Updating inventory.."); - - node.sync_inventory()?; - spinner.message("Announcing.."); - node.announce_inventory()?; - spinner.message("Project successfully announced."); - spinner.finish(); - - term::blank(); - term::info!( - "Your project has been announced to the network and is \ - now discoverable by peers.", - ); - } else { - term::info!("Your project will be announced to the network when you start your node."); - term::info!( - "You can start your node with {}.", - term::format::command("rad node start") - ); + match sync(rid, node, config) { + Ok(SyncResult::Synced { + result: Some(url), .. + }) => { + term::blank(); + term::info!( + "Your project has been synced to the network and is \ + now discoverable by peers.", + ); + term::info!("View it in your browser at:"); + term::blank(); + term::indented(term::format::tertiary(url)); + term::blank(); + } + Ok(SyncResult::Synced { result: None, .. }) => { + term::blank(); + term::info!( + "Your project has been synced to the network and is \ + now discoverable by peers.", + ); + if !config.preferred_seeds.is_empty() { + term::info!( + "Unfortunately, you were unable to replicate your project to \ + your preferred seeds." + ); + } + } + Ok(SyncResult::NotSynced) => { + term::blank(); + term::info!( + "Your project has been announced to the network and is \ + now discoverable by peers.", + ); + term::info!( + "You can check for any nodes that have replicated your project by running \ + `rad sync status`." + ); + term::blank(); + } + Ok(SyncResult::NoPeersConnected) => { + term::blank(); + term::info!( + "You are not connected to any peers. Your project will be announced as soon as \ + your node establishes a connection with the network."); + term::info!("Check for peer connections with `rad node status`."); + term::blank(); + } + Ok(SyncResult::NodeStopped) => { + term::info!( + "Your project will be announced to the network when you start your node." + ); + term::info!( + "You can start your node with {}.", + term::format::command("rad node start") + ); + } + Err(e) => { + return Err(e.into()); + } } } else { term::info!( diff --git a/radicle-cli/src/commands/node.rs b/radicle-cli/src/commands/node.rs index c2cd35e6..2fbec1a2 100644 --- a/radicle-cli/src/commands/node.rs +++ b/radicle-cli/src/commands/node.rs @@ -11,13 +11,13 @@ use crate::terminal::args::{Args, Error, Help}; use crate::terminal::Element as _; #[path = "node/control.rs"] -mod control; +pub mod control; #[path = "node/events.rs"] -mod events; +pub mod events; #[path = "node/policies.rs"] -mod policies; +pub mod policies; #[path = "node/routing.rs"] -mod routing; +pub mod routing; pub const HELP: Help = Help { name: "node", diff --git a/radicle-cli/tests/commands.rs b/radicle-cli/tests/commands.rs index 1e6c0888..93f86a0a 100644 --- a/radicle-cli/tests/commands.rs +++ b/radicle-cli/tests/commands.rs @@ -9,6 +9,7 @@ use radicle::node::routing::Store as _; use radicle::node::Handle as _; use radicle::node::{Alias, DEFAULT_TIMEOUT}; use radicle::prelude::Id; +use radicle::profile; use radicle::profile::Home; use radicle::storage::{ReadStorage, RemoteRepository}; use radicle::test::fixtures; @@ -16,7 +17,7 @@ use radicle::test::fixtures; use radicle_cli_test::TestFormula; use radicle_node::service::policy::{Policy, Scope}; use radicle_node::service::Event; -use radicle_node::test::environment::{Config, Environment}; +use radicle_node::test::environment::{Config, Environment, Node}; #[allow(unused_imports)] use radicle_node::test::logger; @@ -432,7 +433,7 @@ fn rad_node() { fixtures::repository(working.path().join("alice")); test( - "examples/rad-init-sync.md", + "examples/rad-init-sync-not-connected.md", &working.path().join("alice"), Some(&alice.home), [], @@ -963,6 +964,93 @@ fn rad_clone_unknown() { .unwrap(); } +#[test] +fn rad_init_sync_not_connected() { + let mut environment = Environment::new(); + let alice = environment.node(Config::test(Alias::new("alice"))); + let working = tempfile::tempdir().unwrap(); + let alice = alice.spawn(); + + fixtures::repository(working.path().join("alice")); + + test( + "examples/rad-init-sync-not-connected.md", + &working.path().join("alice"), + Some(&alice.home), + [], + ) + .unwrap(); +} + +#[test] +fn rad_init_sync_preferred() { + let mut environment = Environment::new(); + let mut alice = environment + .node(Config { + policy: Policy::Allow, + ..Config::test(Alias::new("alice")) + }) + .spawn(); + + let bob = environment.profile(profile::Config { + preferred_seeds: vec![alice.address()], + ..config::profile("bob") + }); + let mut bob = Node::new(bob).spawn(); + let working = environment.tmp().join("working"); + + bob.connect(&alice); + alice.handle.follow(bob.id, None).unwrap(); + + fixtures::repository(working.join("bob")); + + // Necessary for now, if we don't want the new inventry announcement to be considered stale + // for Alice. + // TODO: Find a way to advance internal clocks instead. + thread::sleep(time::Duration::from_millis(3)); + + // Bob initializes a repo after her node has started, and after bob has connected to it. + test( + "examples/rad-init-sync-preferred.md", + &working.join("bob"), + Some(&bob.home), + [], + ) + .unwrap(); +} + +#[test] +fn rad_init_sync_timeout() { + let mut environment = Environment::new(); + let mut alice = environment + .node(Config { + policy: Policy::Block, + ..Config::test(Alias::new("alice")) + }) + .spawn(); + + let bob = environment.profile(profile::Config { + preferred_seeds: vec![alice.address()], + ..config::profile("bob") + }); + let mut bob = Node::new(bob).spawn(); + let working = environment.tmp().join("working"); + + bob.connect(&alice); + alice.handle.follow(bob.id, None).unwrap(); + + fixtures::repository(working.join("bob")); + + // Bob initializes a repo after her node has started, and after bob has connected to it. + test( + "examples/rad-init-sync-timeout.md", + &working.join("bob"), + Some(&bob.home), + [], + ) + .unwrap(); +} + #[test] fn rad_init_sync_and_clone() { let mut environment = Environment::new(); diff --git a/radicle-node/src/test/environment.rs b/radicle-node/src/test/environment.rs index acc09aeb..c5d236a3 100644 --- a/radicle-node/src/test/environment.rs +++ b/radicle-node/src/test/environment.rs @@ -17,6 +17,7 @@ use radicle::crypto::{KeyPair, Seed, Signer}; use radicle::git; use radicle::git::refname; use radicle::identity::{Id, Visibility}; +use radicle::node::config::ConnectAddress; use radicle::node::policy::store as policy; use radicle::node::routing::Store; use radicle::node::Database; @@ -152,7 +153,7 @@ pub struct Node { } impl Node { - fn new(profile: Profile) -> Self { + pub fn new(profile: Profile) -> Self { let signer = MemorySigner::load(&profile.keystore, None).unwrap(); let id = *profile.id(); let policies_db = profile.home.node().join(POLICIES_DB_FILE); @@ -227,6 +228,11 @@ impl NodeHandle { self } + /// Get the full address of this node. + pub fn address(&self) -> ConnectAddress { + (self.id, node::Address::from(self.addr)).into() + } + /// Get routing table entries. pub fn routing(&self) -> impl Iterator { Database::reader(self.home.node().join(node::NODE_DB_FILE)) diff --git a/radicle-term/src/spinner.rs b/radicle-term/src/spinner.rs index 05928d82..731b77bb 100644 --- a/radicle-term/src/spinner.rs +++ b/radicle-term/src/spinner.rs @@ -1,4 +1,4 @@ -use std::io::Write; +use std::io::{IsTerminal, Write}; use std::mem::ManuallyDrop; use std::sync::{Arc, Mutex}; use std::{fmt, io, thread, time}; @@ -105,7 +105,13 @@ impl Spinner { /// Create a new spinner with the given message. Sends animation output to `stderr` and success or /// failure messages to `stdout`. pub fn spinner(message: impl ToString) -> Spinner { - spinner_to(message, io::stdout(), io::stderr()) + let (stdout, stderr) = (io::stdout(), io::stderr()); + + if stderr.is_terminal() { + spinner_to(message, stdout, stderr) + } else { + spinner_to(message, stdout, io::sink()) + } } /// Create a new spinner with the given message, and send output to the given writers. diff --git a/radicle/src/node.rs b/radicle/src/node.rs index 8437f253..d53a25f5 100644 --- a/radicle/src/node.rs +++ b/radicle/src/node.rs @@ -275,7 +275,7 @@ impl FromStr for Alias { } /// Options passed to the "connect" node command. -#[derive(Debug, Default, Clone, serde::Serialize, serde::Deserialize)] +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] pub struct ConnectOptions { /// Establish a persistent connection. pub persistent: bool, @@ -283,6 +283,15 @@ pub struct ConnectOptions { pub timeout: time::Duration, } +impl Default for ConnectOptions { + fn default() -> Self { + Self { + persistent: false, + timeout: DEFAULT_TIMEOUT, + } + } +} + /// Result of a command, on the node control socket. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(untagged)]