cli: Implement `rad sync status` command

This command shows the sync status of a repository by consulting the
database via the node.

To make it work, we call into the address store to update the
`repo-sync-status` table when receiving ref announcements that are
relevant to us.
This commit is contained in:
cloudhead 2023-11-15 14:04:43 +01:00
parent ca557909c2
commit d3b0483f42
No known key found for this signature in database
6 changed files with 172 additions and 18 deletions

View File

@ -7,6 +7,18 @@ For instance let's create an issue and sync it with the network:
$ rad issue open --title "Test `rad sync`" --description "Check that the command works" -q --no-announce $ rad issue open --title "Test `rad sync`" --description "Check that the command works" -q --no-announce
``` ```
If we check the sync status, we see that our peers are out of sync:
```
$ rad sync status
╭─────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
│ ● NID Address Status At Timestamp │
├─────────────────────────────────────────────────────────────────────────────────────────────────────────────────┤
│ ● z6Mkux1aUQD2voWWukVb5nNUR7thrHveQG4pDQua8nVhib7Z eve.radicle.xyz:8776 out-of-sync f209c9f [ ... ] │
│ ● z6Mkt67GdsW7715MEfRuP4pSZxJRJh6kj6Y48WRqVv4N1tRk bob.radicle.xyz:8776 out-of-sync f209c9f [ ... ] │
╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯
```
Now let's run `rad sync`. This will announce the issue refs to the network and Now let's run `rad sync`. This will announce the issue refs to the network and
wait for nodes to announce that they have fetched those refs. wait for nodes to announce that they have fetched those refs.
@ -57,3 +69,15 @@ $ rad sync --fetch --replicas 1
✓ Fetching rad:z42hL2jL4XNk6K8oHQaSWfMgCL7ji from z6Mkux1…nVhib7Z.. ✓ Fetching rad:z42hL2jL4XNk6K8oHQaSWfMgCL7ji from z6Mkux1…nVhib7Z..
✓ Fetched repository from 1 seed(s) ✓ Fetched repository from 1 seed(s)
``` ```
We can check the sync status again to make sure everything's in sync:
```
$ rad sync status
╭────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
│ ● NID Address Status At Timestamp │
├────────────────────────────────────────────────────────────────────────────────────────────────────────────┤
│ ● z6Mkux1aUQD2voWWukVb5nNUR7thrHveQG4pDQua8nVhib7Z eve.radicle.xyz:8776 synced 9f615f9 [ ... ] │
│ ● z6Mkt67GdsW7715MEfRuP4pSZxJRJh6kj6Y48WRqVv4N1tRk bob.radicle.xyz:8776 synced 9f615f9 [ ... ] │
╰────────────────────────────────────────────────────────────────────────────────────────────────────────────╯
```

View File

@ -4,12 +4,14 @@ use std::time;
use anyhow::{anyhow, Context as _}; use anyhow::{anyhow, Context as _};
use radicle::node; use radicle::node;
use radicle::node::{FetchResult, FetchResults, Handle as _, Node}; use radicle::node::{FetchResult, FetchResults, Handle as _, Node, SyncStatus};
use radicle::prelude::{Id, NodeId, Profile}; use radicle::prelude::{Id, NodeId, Profile};
use radicle::storage::{ReadRepository, ReadStorage}; use radicle::storage::{ReadRepository, ReadStorage};
use radicle_term::Element;
use crate::terminal as term; use crate::terminal as term;
use crate::terminal::args::{Args, Error, Help}; use crate::terminal::args::{Args, Error, Help};
use crate::terminal::{Table, TableOptions};
pub const HELP: Help = Help { pub const HELP: Help = Help {
name: "sync", name: "sync",
@ -20,6 +22,7 @@ Usage
rad sync [--fetch | --announce] [<rid>] [<option>...] rad sync [--fetch | --announce] [<rid>] [<option>...]
rad sync --inventory [<option>...] rad sync --inventory [<option>...]
rad sync status [<rid>] [<option>...]
By default, the current repository is synchronized both ways. By default, the current repository is synchronized both ways.
If an <rid> is specified, that repository is synced instead. If an <rid> is specified, that repository is synced instead.
@ -40,6 +43,10 @@ Usage
If `--inventory` is specified, the node's inventory is announced to If `--inventory` is specified, the node's inventory is announced to
the network. This mode does not take an `<rid>`. the network. This mode does not take an `<rid>`.
Commands
status Display the sync status of a repository
Options Options
--fetch, -f Turn on fetching (default: true) --fetch, -f Turn on fetching (default: true)
@ -53,6 +60,13 @@ Options
"#, "#,
}; };
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub enum Operation {
Synchronize(SyncMode),
#[default]
Status,
}
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
pub enum SyncMode { pub enum SyncMode {
Repo { Repo {
@ -99,7 +113,7 @@ pub struct Options {
pub rid: Option<Id>, pub rid: Option<Id>,
pub verbose: bool, pub verbose: bool,
pub timeout: time::Duration, pub timeout: time::Duration,
pub sync: SyncMode, pub op: Operation,
} }
impl Args for Options { impl Args for Options {
@ -115,6 +129,7 @@ impl Args for Options {
let mut inventory = false; let mut inventory = false;
let mut replicas = None; let mut replicas = None;
let mut seeds = Vec::new(); let mut seeds = Vec::new();
let mut op: Option<Operation> = None;
while let Some(arg) = parser.next()? { while let Some(arg) = parser.next()? {
match arg { match arg {
@ -154,9 +169,14 @@ impl Args for Options {
Long("help") | Short('h') => { Long("help") | Short('h') => {
return Err(Error::Help.into()); return Err(Error::Help.into());
} }
Value(val) if rid.is_none() => { Value(val) if rid.is_none() => match val.to_string_lossy().as_ref() {
"s" | "status" => {
op = Some(Operation::Status);
}
_ => {
rid = Some(term::args::rid(&val)?); rid = Some(term::args::rid(&val)?);
} }
},
arg => { arg => {
return Err(anyhow!(arg.unexpected())); return Err(anyhow!(arg.unexpected()));
} }
@ -195,7 +215,7 @@ impl Args for Options {
rid, rid,
verbose, verbose,
timeout, timeout,
sync, op: op.unwrap_or(Operation::Synchronize(sync)),
}, },
vec![], vec![],
)) ))
@ -220,8 +240,11 @@ pub fn run(options: Options, ctx: impl term::Context) -> anyhow::Result<()> {
); );
} }
match options.sync { match options.op {
SyncMode::Repo { mode, direction } => { Operation::Status => {
sync_status(rid, &mut node)?;
}
Operation::Synchronize(SyncMode::Repo { mode, direction }) => {
if [SyncDirection::Fetch, SyncDirection::Both].contains(&direction) { if [SyncDirection::Fetch, SyncDirection::Both].contains(&direction) {
if !profile.tracking()?.is_repo_tracked(&rid)? { if !profile.tracking()?.is_repo_tracked(&rid)? {
anyhow::bail!("repository {rid} is not tracked"); anyhow::bail!("repository {rid} is not tracked");
@ -240,13 +263,69 @@ pub fn run(options: Options, ctx: impl term::Context) -> anyhow::Result<()> {
announce_refs(rid, mode, options.timeout, node, &profile)?; announce_refs(rid, mode, options.timeout, node, &profile)?;
} }
} }
SyncMode::Inventory => { Operation::Synchronize(SyncMode::Inventory) => {
announce_inventory(node)?; announce_inventory(node)?;
} }
} }
Ok(()) Ok(())
} }
fn sync_status(rid: Id, node: &mut Node) -> anyhow::Result<()> {
let mut table = Table::<6, term::Label>::new(TableOptions::bordered());
let seeds: Vec<_> = node.seeds(rid)?.into();
table.push([
term::format::dim(String::from("")).into(),
term::format::bold(String::from("NID")).into(),
term::format::bold(String::from("Address")).into(),
term::format::bold(String::from("Status")).into(),
term::format::bold(String::from("At")).into(),
term::format::bold(String::from("Timestamp")).into(),
]);
table.divider();
for seed in seeds {
let (icon, status, refs, time) = match seed.sync {
Some(SyncStatus::Synced { at }) => (
term::format::positive(""),
term::format::positive("synced"),
term::label(term::format::oid(at.oid)),
term::format::timestamp(at.timestamp).into(),
),
Some(SyncStatus::OutOfSync { remote, .. }) => (
term::format::negative(""),
term::format::negative("out-of-sync"),
term::label(term::format::oid(remote.oid)),
term::format::timestamp(remote.timestamp).into(),
),
None => (
term::format::yellow(""),
term::format::yellow("?"),
term::label("?"),
term::label("?"),
),
};
let addr = seed
.addrs
.first()
.map(|a| a.addr.to_string())
.unwrap_or_default()
.into();
table.push([
icon.into(),
seed.nid.to_human().into(),
addr,
status.into(),
refs,
time,
]);
}
table.print();
Ok(())
}
fn announce_refs( fn announce_refs(
rid: Id, rid: Id,
_mode: RepoSync, _mode: RepoSync,

View File

@ -45,6 +45,16 @@ fn test<'a>(
Ok(()) Ok(())
} }
/// Test config.
fn config(alias: &'static str) -> Config {
Config {
external_addresses: vec![
node::Address::from_str(&format!("{alias}.radicle.xyz:8776")).unwrap(),
],
..Config::test(Alias::new(alias))
}
}
fn formula(root: &Path, test: impl AsRef<Path>) -> Result<TestFormula, Box<dyn std::error::Error>> { fn formula(root: &Path, test: impl AsRef<Path>) -> Result<TestFormula, Box<dyn std::error::Error>> {
let mut formula = TestFormula::new(root.to_path_buf()); let mut formula = TestFormula::new(root.to_path_buf());
let base = Path::new(env!("CARGO_MANIFEST_DIR")); let base = Path::new(env!("CARGO_MANIFEST_DIR"));
@ -1018,9 +1028,9 @@ fn test_cob_deletion() {
fn rad_sync() { fn rad_sync() {
let mut environment = Environment::new(); let mut environment = Environment::new();
let working = environment.tmp().join("working"); let working = environment.tmp().join("working");
let alice = environment.node(Config::test(Alias::new("alice"))); let alice = environment.node(config("alice"));
let bob = environment.node(Config::test(Alias::new("bob"))); let bob = environment.node(config("bob"));
let eve = environment.node(Config::test(Alias::new("eve"))); let eve = environment.node(config("eve"));
let acme = Id::from_str("z42hL2jL4XNk6K8oHQaSWfMgCL7ji").unwrap(); let acme = Id::from_str("z42hL2jL4XNk6K8oHQaSWfMgCL7ji").unwrap();
fixtures::repository(working.join("acme")); fixtures::repository(working.join("acme"));

View File

@ -32,7 +32,9 @@ use crate::crypto::{Signer, Verified};
use crate::identity::{Doc, Id}; use crate::identity::{Doc, Id};
use crate::node::routing; use crate::node::routing;
use crate::node::routing::InsertResult; use crate::node::routing::InsertResult;
use crate::node::{Address, Alias, Features, FetchResult, HostName, Seed, Seeds, SyncStatus}; use crate::node::{
Address, Alias, Features, FetchResult, HostName, Seed, Seeds, SyncStatus, SyncedAt,
};
use crate::prelude::*; use crate::prelude::*;
use crate::runtime::Emitter; use crate::runtime::Emitter;
use crate::service::message::{Announcement, AnnouncementMessage, Info, Ping}; use crate::service::message::{Announcement, AnnouncementMessage, Info, Ping};
@ -116,6 +118,8 @@ pub enum Error {
#[error(transparent)] #[error(transparent)]
Git(#[from] radicle::git::raw::Error), Git(#[from] radicle::git::raw::Error),
#[error(transparent)] #[error(transparent)]
GitExt(#[from] radicle::git::ext::Error),
#[error(transparent)]
Storage(#[from] storage::Error), Storage(#[from] storage::Error),
#[error(transparent)] #[error(transparent)]
Refs(#[from] storage::refs::Error), Refs(#[from] storage::refs::Error),
@ -1137,6 +1141,27 @@ where
} }
} }
// Update sync status for this repo.
if let Some(refs) = message.refs.iter().find(|r| &r.remote == self.nid()) {
match self
.addresses
.synced(&message.rid, announcer, refs.at, message.timestamp)
{
Ok(updated) => {
if updated {
debug!(
target: "service",
"Updating sync status of {announcer} for {} to {}",
message.rid, refs.at
);
}
}
Err(e) => {
error!(target: "service", "Error updating sync status for {}: {e}", message.rid);
}
}
}
// TODO: Buffer/throttle fetches. // TODO: Buffer/throttle fetches.
let repo_entry = self.tracking.repo_policy(&message.rid).expect( let repo_entry = self.tracking.repo_policy(&message.rid).expect(
"Service::handle_announcement: error accessing repo tracking configuration", "Service::handle_announcement: error accessing repo tracking configuration",
@ -1531,7 +1556,9 @@ where
let mut refs = BoundedVec::<_, REF_REMOTE_LIMIT>::new(); let mut refs = BoundedVec::<_, REF_REMOTE_LIMIT>::new();
for remote_id in remotes.into_iter() { for remote_id in remotes.into_iter() {
if refs.push(RefsAt::new(&repo, remote_id)?).is_err() { let refs_at = RefsAt::new(&repo, remote_id)?;
if refs.push(refs_at).is_err() {
warn!( warn!(
target: "service", target: "service",
"refs announcement limit ({}) exceeded, peers will see only some of your repository references", "refs announcement limit ({}) exceeded, peers will see only some of your repository references",
@ -1634,9 +1661,11 @@ where
let synced = if local.at == seed.synced_at.oid { let synced = if local.at == seed.synced_at.oid {
SyncStatus::Synced { at: seed.synced_at } SyncStatus::Synced { at: seed.synced_at }
} else { } else {
let local = SyncedAt::new(local.at, &repo)?;
SyncStatus::OutOfSync { SyncStatus::OutOfSync {
local: local.at, local,
remote: seed.synced_at.oid, remote: seed.synced_at,
} }
}; };
seeds.insert(Seed::new(seed.nid, seed.addresses, state, Some(synced))); seeds.insert(Seed::new(seed.nid, seed.addresses, state, Some(synced)));

View File

@ -141,9 +141,9 @@ pub enum SyncStatus {
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
OutOfSync { OutOfSync {
/// Local head of our `rad/sigrefs`. /// Local head of our `rad/sigrefs`.
local: git_ext::Oid, local: SyncedAt,
/// Remote head of our `rad/sigrefs`. /// Remote head of our `rad/sigrefs`.
remote: git_ext::Oid, remote: SyncedAt,
}, },
} }

View File

@ -6,8 +6,10 @@ use localtime::LocalTime;
use nonempty::NonEmpty; use nonempty::NonEmpty;
use crate::collections::RandomMap; use crate::collections::RandomMap;
use crate::git;
use crate::node::{Address, Alias}; use crate::node::{Address, Alias};
use crate::prelude::{NodeId, Timestamp}; use crate::prelude::{NodeId, Timestamp};
use crate::storage::ReadRepository;
use crate::{node, profile}; use crate::{node, profile};
/// A map with the ability to randomly select values. /// A map with the ability to randomly select values.
@ -193,6 +195,16 @@ pub struct SyncedAt {
pub timestamp: LocalTime, pub timestamp: LocalTime,
} }
impl SyncedAt {
/// Create a new [`SyncedAt`] given an OID, by looking up the timestamp in the repo.
pub fn new<S: ReadRepository>(oid: git::ext::Oid, repo: &S) -> Result<Self, git::ext::Error> {
let timestamp = repo.commit(oid)?.time();
let timestamp = LocalTime::from_secs(timestamp.seconds() as u64);
Ok(Self { oid, timestamp })
}
}
/// Seed of a specific repository. /// Seed of a specific repository.
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] #[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]