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