radicle_cli/commands/node/
control.rs

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