radicle_cli/commands/node/
control.rs

1use std::ffi::OsString;
2use std::fs::{File, OpenOptions};
3use std::io::{BufRead, BufReader, Read, Seek, SeekFrom};
4use std::{fs, io, path::Path, process, thread, time};
5
6use anyhow::{anyhow, Context};
7use localtime::LocalTime;
8
9use radicle::node;
10use radicle::node::{Address, ConnectResult, Handle as _, NodeId};
11use radicle::Node;
12use radicle::{profile, Profile};
13
14use crate::terminal as term;
15use crate::terminal::Element as _;
16
17/// How long to wait for the node to start before returning an error.
18pub const NODE_START_TIMEOUT: time::Duration = time::Duration::from_secs(6);
19/// Node log file name.
20pub const NODE_LOG: &str = "node.log";
21/// Node log old file name, after rotation.
22pub const NODE_LOG_OLD: &str = "node.log.old";
23
24pub fn start(
25    node: Node,
26    daemon: bool,
27    verbose: bool,
28    mut options: Vec<OsString>,
29    cmd: &Path,
30    profile: &Profile,
31) -> anyhow::Result<()> {
32    if node.is_running() {
33        term::success!("Node is already running.");
34        return Ok(());
35    }
36    let envs = if profile.keystore.is_encrypted()? {
37        // Ask passphrase here, otherwise it'll be a fatal error when running the daemon
38        // without `RAD_PASSPHRASE`.
39        let validator = term::io::PassphraseValidator::new(profile.keystore.clone());
40        let passphrase = if let Some(phrase) = profile::env::passphrase() {
41            phrase
42        } else if let Ok(phrase) = term::io::passphrase(validator) {
43            phrase
44        } else {
45            anyhow::bail!("your radicle passphrase is required to start your node");
46        };
47        Some((profile::env::RAD_PASSPHRASE, passphrase))
48    } else {
49        None
50    };
51
52    // Since we checked that the node is not running, it's safe to use `--force`
53    // here.
54    if !options.contains(&OsString::from("--force")) {
55        options.push(OsString::from("--force"));
56    }
57    if daemon {
58        let log = log_rotate(profile)?;
59        let child = process::Command::new(cmd)
60            .args(options)
61            .envs(envs)
62            .stdin(process::Stdio::null())
63            .stdout(process::Stdio::from(log.try_clone()?))
64            .stderr(process::Stdio::from(log))
65            .spawn()
66            .map_err(|e| anyhow!("failed to start node process {cmd:?}: {e}"))?;
67        let pid = term::format::parens(term::format::dim(child.id()));
68
69        if verbose {
70            logs(0, Some(time::Duration::from_secs(1)), profile)?;
71        } else {
72            let started = time::Instant::now();
73            let mut spinner = term::spinner(format!("Node starting.. {pid}"));
74
75            loop {
76                if node.is_running() {
77                    spinner.message(format!("Node started {pid}"));
78                    spinner.finish();
79
80                    term::print(term::format::dim(
81                        "To stay in sync with the network, leave the node running in the background.",
82                    ));
83                    term::info!(
84                        "{} {}{}",
85                        term::format::dim("To learn more, run"),
86                        term::format::command("rad node --help"),
87                        term::format::dim("."),
88                    );
89                    break;
90                } else if started.elapsed() >= NODE_START_TIMEOUT {
91                    anyhow::bail!(
92                        "node failed to start. Try running it with `rad node start --foreground`, \
93                        or check the logs with `rad node logs`"
94                    );
95                }
96                thread::sleep(time::Duration::from_millis(60));
97            }
98        }
99    } else {
100        let mut child = process::Command::new(cmd)
101            .args(options)
102            .envs(envs)
103            .spawn()
104            .map_err(|e| anyhow!("failed to start node process {cmd:?}: {e}"))?;
105
106        child.wait()?;
107    }
108
109    Ok(())
110}
111
112pub fn stop(node: Node) -> anyhow::Result<()> {
113    let mut spinner = term::spinner("Stopping node...");
114    if node.shutdown().is_err() {
115        spinner.error("node is not running");
116    } else {
117        spinner.message("Node stopped");
118        spinner.finish();
119    }
120    Ok(())
121}
122
123pub fn debug(node: &mut Node) -> anyhow::Result<()> {
124    let json = node.debug()?;
125    term::json::to_pretty(&json, Path::new("debug.json"))?.print();
126
127    Ok(())
128}
129
130pub fn logs(lines: usize, follow: Option<time::Duration>, profile: &Profile) -> anyhow::Result<()> {
131    let logs_path = profile.home.node().join("node.log");
132    let mut file = File::open(logs_path.clone())
133        .map(BufReader::new)
134        .with_context(|| {
135            format!(
136                "Failed to read log file at '{}'. Did you start the node with `rad node start`? \
137                If the node was started through a process manager, check its logs instead.",
138                logs_path.display()
139            )
140        })?;
141
142    file.seek(SeekFrom::End(0))?;
143
144    let mut tail = Vec::new();
145    let mut nlines = 0;
146
147    for i in (1..=file.stream_position()?).rev() {
148        let mut buf = [0; 1];
149        file.seek(SeekFrom::Start(i - 1))?;
150        file.read_exact(&mut buf)?;
151
152        if buf[0] == b'\n' {
153            nlines += 1;
154        }
155        if nlines > lines {
156            break;
157        }
158        tail.push(buf[0]);
159    }
160    tail.reverse();
161
162    print!("{}", term::format::dim(String::from_utf8_lossy(&tail)));
163
164    if let Some(timeout) = follow {
165        file.seek(SeekFrom::End(0))?;
166
167        let start = time::Instant::now();
168
169        while start.elapsed() < timeout {
170            let mut line = String::new();
171            let len = file.read_line(&mut line)?;
172
173            if len == 0 {
174                thread::sleep(time::Duration::from_millis(250));
175            } else {
176                print!("{}", term::format::dim(line));
177            }
178        }
179    }
180    Ok(())
181}
182
183pub fn connect(
184    node: &mut Node,
185    nid: NodeId,
186    addr: Address,
187    timeout: time::Duration,
188) -> anyhow::Result<()> {
189    let spinner = term::spinner(format!(
190        "Connecting to {}@{addr}...",
191        term::format::node(&nid)
192    ));
193    match node.connect(
194        nid,
195        addr,
196        node::ConnectOptions {
197            persistent: true,
198            timeout,
199        },
200    ) {
201        Ok(ConnectResult::Connected) => spinner.finish(),
202        Ok(ConnectResult::Disconnected { reason }) => spinner.error(reason),
203        Err(err) => return Err(err.into()),
204    }
205    Ok(())
206}
207
208pub fn status(node: &Node, profile: &Profile) -> anyhow::Result<()> {
209    if node.is_running() {
210        let listen = node
211            .listen_addrs()?
212            .into_iter()
213            .map(|addr| addr.to_string())
214            .collect::<Vec<_>>();
215
216        if listen.is_empty() {
217            term::success!("Node is {}.", term::format::positive("running"));
218        } else {
219            term::success!(
220                "Node is {} and listening on {}.",
221                term::format::positive("running"),
222                listen.join(", ")
223            );
224        }
225    } else {
226        term::info!("Node is {}.", term::format::negative("stopped"));
227        term::info!(
228            "To start it, run {}.",
229            term::format::command("rad node start")
230        );
231        return Ok(());
232    }
233
234    let sessions = sessions(node)?;
235    if let Some(table) = sessions {
236        term::blank();
237        table.print();
238    }
239
240    if profile.hints() {
241        const COLUMN_WIDTH: usize = 12;
242        let status = format!(
243            "\n{:>4} … {}\n       {}   {}\n       {}   {}",
244            state_label().fg(radicle_term::Color::White),
245            term::format::dim("Status:"),
246            format_args!(
247                "{} {:width$}",
248                state_connected(),
249                term::format::dim("… connected"),
250                width = COLUMN_WIDTH,
251            ),
252            format_args!(
253                "{} {}",
254                state_disconnected(),
255                term::format::dim("… disconnected")
256            ),
257            format_args!(
258                "{} {:width$}",
259                state_attempted(),
260                term::format::dim("… attempted"),
261                width = COLUMN_WIDTH,
262            ),
263            format_args!("{} {}", state_initial(), term::format::dim("… initial")),
264        );
265        let link_direction = format!(
266            "\n{:>4} … {}\n       {}   {}",
267            link_direction_label(),
268            term::format::dim("Link Direction:"),
269            format_args!(
270                "{} {:width$}",
271                link_direction_inbound(),
272                term::format::dim("… inbound"),
273                width = COLUMN_WIDTH,
274            ),
275            format_args!(
276                "{} {}",
277                link_direction_outbound(),
278                term::format::dim("… outbound")
279            ),
280        );
281        term::hint(status + &link_direction);
282    }
283
284    if profile.home.node().join("node.log").exists() {
285        term::blank();
286        // If we're running the node via `systemd` for example, there won't be a log file
287        // and this will fail.
288        logs(10, None, profile)?;
289    }
290    Ok(())
291}
292
293pub fn sessions(node: &Node) -> Result<Option<term::Table<5, term::Label>>, node::Error> {
294    let sessions = node.sessions()?;
295    if sessions.is_empty() {
296        return Ok(None);
297    }
298    let mut table = term::Table::new(term::table::TableOptions::bordered());
299    let now = LocalTime::now();
300
301    table.header([
302        term::format::bold("Node ID").into(),
303        term::format::bold("Address").into(),
304        state_label().into(),
305        link_direction_label().bold().into(),
306        term::format::bold("Since").into(),
307    ]);
308    table.divider();
309
310    for sess in sessions {
311        let nid = term::format::tertiary(term::format::node(&sess.nid)).into();
312        let (addr, state, time) = match sess.state {
313            node::State::Initial => (
314                term::Label::blank(),
315                term::Label::from(state_initial()),
316                term::Label::blank(),
317            ),
318            node::State::Attempted => (
319                sess.addr.to_string().into(),
320                term::Label::from(state_attempted()),
321                term::Label::blank(),
322            ),
323            node::State::Connected { since, .. } => (
324                sess.addr.to_string().into(),
325                term::Label::from(state_connected()),
326                term::format::dim(now - since).into(),
327            ),
328            node::State::Disconnected { since, .. } => (
329                sess.addr.to_string().into(),
330                term::Label::from(state_disconnected()),
331                term::format::dim(now - since).into(),
332            ),
333        };
334        let direction = match sess.link {
335            node::Link::Inbound => term::Label::from(link_direction_inbound()),
336            node::Link::Outbound => term::Label::from(link_direction_outbound()),
337        };
338        table.push([nid, addr, state, direction, time]);
339    }
340    Ok(Some(table))
341}
342
343pub fn config(node: &Node) -> anyhow::Result<()> {
344    let cfg = node.config()?;
345    let cfg = serde_json::to_string_pretty(&cfg)?;
346
347    println!("{cfg}");
348
349    Ok(())
350}
351
352fn log_rotate(profile: &Profile) -> io::Result<File> {
353    let base = profile.home.node();
354    if base.join(NODE_LOG).exists() {
355        // Let this fail, eg. if the file doesn't exist.
356        fs::remove_file(base.join(NODE_LOG_OLD)).ok();
357        fs::rename(base.join(NODE_LOG), base.join(NODE_LOG_OLD))?;
358    }
359
360    let log = OpenOptions::new()
361        .write(true)
362        .create_new(true)
363        .open(base.join(NODE_LOG))?;
364
365    Ok(log)
366}
367
368fn state_label() -> term::Paint<String> {
369    term::Paint::from("?".to_string())
370}
371
372fn state_initial() -> term::Paint<String> {
373    term::format::dim("•".to_string())
374}
375
376fn state_attempted() -> term::Paint<String> {
377    term::format::yellow("!".to_string())
378}
379
380fn state_connected() -> term::Paint<String> {
381    term::format::positive("✓".to_string())
382}
383
384fn state_disconnected() -> term::Paint<String> {
385    term::format::negative("✗".to_string())
386}
387
388fn link_direction_label() -> term::Paint<String> {
389    term::Paint::from("⤭".to_string())
390}
391
392fn link_direction_inbound() -> term::Paint<String> {
393    term::format::yellow("🡦".to_string())
394}
395
396fn link_direction_outbound() -> term::Paint<String> {
397    term::format::dim("🡥".to_string())
398}