radicle_cli/commands/node/
control.rs1use 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
18pub const NODE_START_TIMEOUT: time::Duration = time::Duration::from_secs(6);
20pub const NODE_LOG: &str = "node.log";
22pub 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 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 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 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 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}