Skip to main content

devimint/
cli.rs

1use std::fmt::Write;
2use std::ops::ControlFlow;
3use std::path::{Path, PathBuf};
4use std::time::Duration;
5use std::{env, ffi};
6
7use anyhow::{Context, Result, anyhow, ensure};
8use clap::builder::BoolishValueParser;
9use clap::{Parser, Subcommand};
10use fedimint_core::task::TaskGroup;
11use fedimint_core::util::{FmtCompact as _, FmtCompactAnyhow as _, write_overwrite_async};
12use fedimint_logging::LOG_DEVIMINT;
13use rand::Rng as _;
14use rand::distributions::Alphanumeric;
15use tokio::fs;
16use tokio::time::Instant;
17use tracing::{debug, error, info, trace, warn};
18
19use crate::devfed::DevJitFed;
20use crate::envs::{
21    FM_DEVIMINT_STATIC_DATA_DIR_ENV, FM_FED_SIZE_ENV, FM_FEDERATIONS_BASE_PORT_ENV,
22    FM_GATEWAY_BASE_PORT_ENV, FM_INVITE_CODE_ENV, FM_LINK_TEST_DIR_ENV, FM_NUM_FEDS_ENV,
23    FM_OFFLINE_NODES_ENV, FM_PRE_DKG_ENV, FM_TEST_DIR_ENV,
24};
25use crate::util::{ProcessManager, poll};
26use crate::vars::mkdir;
27use crate::{external_daemons, vars};
28
29fn random_test_dir_suffix() -> String {
30    rand::thread_rng()
31        .sample_iter(&Alphanumeric)
32        .filter(u8::is_ascii_digit)
33        .take(3)
34        .map(char::from)
35        .collect::<String>()
36}
37
38#[derive(Parser, Clone, Default)]
39pub struct CommonArgs {
40    #[clap(short = 'd', long, env = FM_TEST_DIR_ENV)]
41    pub test_dir: Option<PathBuf>,
42
43    /// Don't set up new Federation, start from the state in existing
44    /// devimint data dir
45    #[arg(long, env = "FM_SKIP_SETUP", value_parser = BoolishValueParser::new())]
46    skip_setup: bool,
47
48    /// Do not set up federation and stop at a pre-dkg stage
49    #[arg(long, env = FM_PRE_DKG_ENV, value_parser = BoolishValueParser::new())]
50    pre_dkg: bool,
51
52    /// Number of peers to allocate in every federation
53    #[clap(short = 'n', long, env = FM_FED_SIZE_ENV, default_value = "4")]
54    pub fed_size: usize,
55
56    /// Number of federations to allocate for the test/run
57    #[clap(long, env = FM_NUM_FEDS_ENV, default_value = "1")]
58    pub num_feds: usize,
59
60    #[clap(long, env = FM_LINK_TEST_DIR_ENV)]
61    /// Create a link to the test dir under this path
62    pub link_test_dir: Option<PathBuf>,
63
64    #[clap(long, default_value_t = random_test_dir_suffix())]
65    pub link_test_dir_suffix: String,
66
67    /// Run degraded federation with FM_OFFLINE_NODES shutdown
68    #[clap(long, env = FM_OFFLINE_NODES_ENV, default_value = "0")]
69    pub offline_nodes: usize,
70
71    /// Force a base federations port, e.g. for convenience during dev tasks
72    #[clap(long, env = FM_FEDERATIONS_BASE_PORT_ENV)]
73    pub federations_base_port: Option<u16>,
74
75    /// Force a base gateway port, e.g. for convenience during dev tasks
76    #[clap(long, env = FM_GATEWAY_BASE_PORT_ENV)]
77    pub gateway_base_port: Option<u16>,
78}
79
80impl CommonArgs {
81    pub fn mk_test_dir(&self) -> Result<PathBuf> {
82        if self.skip_setup {
83            ensure!(
84                self.test_dir.is_some(),
85                "When using `--skip-setup`, `--test-dir` must be set"
86            );
87        }
88        let path = self.test_dir();
89
90        std::fs::create_dir_all(&path)
91            .with_context(|| format!("Creating tmp directory {}", path.display()))?;
92
93        Ok(path)
94    }
95
96    pub fn test_dir(&self) -> PathBuf {
97        self.test_dir.clone().unwrap_or_else(|| {
98            std::env::temp_dir().join(format!(
99                "devimint-{}-{}",
100                std::process::id(),
101                self.link_test_dir_suffix
102            ))
103        })
104    }
105}
106
107#[derive(Subcommand)]
108pub enum Cmd {
109    /// Spins up bitcoind and esplora.
110    ExternalDaemons {
111        #[arg(long, trailing_var_arg = true, allow_hyphen_values = true, num_args=1..)]
112        exec: Option<Vec<ffi::OsString>>,
113    },
114    /// Spins up bitcoind, LDK Gateway, lnd w/ gateway, a faucet,
115    /// esplora, and a federation sized from FM_FED_SIZE it opens LN channel
116    /// between the two nodes. it connects the gateways to the federation.
117    /// it finally switches to use the LND gateway for LNv1
118    DevFed {
119        #[arg(long, trailing_var_arg = true, allow_hyphen_values = true, num_args=1..)]
120        exec: Option<Vec<ffi::OsString>>,
121    },
122    /// Spins up a dev federation, backs up fedimint-0, wipes it, and restarts
123    /// it in setup mode for manual guardian restore UI testing.
124    DevFedPreRestore {
125        #[arg(long, trailing_var_arg = true, allow_hyphen_values = true, num_args=1..)]
126        exec: Option<Vec<ffi::OsString>>,
127    },
128    /// Rpc commands to the long running devimint instance. Could be entry point
129    /// for devimint as a cli
130    #[clap(flatten)]
131    Rpc(RpcCmd),
132}
133
134#[derive(Subcommand)]
135pub enum RpcCmd {
136    Wait,
137    Env,
138}
139
140pub async fn setup(arg: CommonArgs) -> Result<(ProcessManager, TaskGroup)> {
141    let test_dir = &arg.mk_test_dir()?;
142    mkdir(test_dir.clone()).await?;
143    let logs_dir: PathBuf = test_dir.join("logs");
144    mkdir(logs_dir.clone()).await?;
145
146    let log_file = fs::OpenOptions::new()
147        .write(true)
148        .create(true)
149        .append(true)
150        .open(logs_dir.join("devimint.log"))
151        .await?
152        .into_std()
153        .await;
154
155    fedimint_logging::TracingSetup::default()
156        .with_file(Some(log_file))
157        // jsonrpsee is expected to fail during startup
158        .with_directive("jsonrpsee-client=off")
159        .init()?;
160
161    let globals = vars::Global::new(
162        test_dir,
163        arg.num_feds,
164        arg.fed_size,
165        arg.offline_nodes,
166        arg.federations_base_port,
167        arg.gateway_base_port,
168    )
169    .await?;
170
171    if let Some(link_test_dir) = arg.link_test_dir.as_ref() {
172        update_test_dir_link(link_test_dir, &arg.test_dir()).await?;
173    }
174    info!(target: LOG_DEVIMINT, path = %globals.FM_DATA_DIR.display(), "Devimint data dir");
175
176    let mut env_string = String::new();
177    for (var, value) in globals.vars() {
178        debug!(var, value, "Env variable set");
179        writeln!(env_string, r#"export {var}="{value}""#)?; // hope that value doesn't contain a "
180        // TODO: Audit that the environment access only happens in single-threaded code.
181        unsafe { std::env::set_var(var, value) };
182    }
183    write_overwrite_async(globals.FM_TEST_DIR.join("env"), env_string).await?;
184    let process_mgr = ProcessManager::new(globals);
185    let task_group = TaskGroup::new();
186    task_group.install_kill_handler();
187    Ok((process_mgr, task_group))
188}
189
190pub async fn update_test_dir_link(
191    link_test_dir: &Path,
192    test_dir: &Path,
193) -> Result<(), anyhow::Error> {
194    let make_link = match fs::read_link(link_test_dir).await {
195        Ok(existing) => {
196            if existing == test_dir {
197                false
198            } else {
199                debug!(
200                    old = %existing.display(),
201                    new = %test_dir.display(),
202                    link = %link_test_dir.display(),
203                    "Updating exinst test dir link"
204                );
205
206                fs::remove_file(link_test_dir).await?;
207                true
208            }
209        }
210        _ => true,
211    };
212    if make_link {
213        debug!(src = %test_dir.display(), dst = %link_test_dir.display(), "Linking test dir");
214        fs::symlink(&test_dir, link_test_dir).await?;
215    }
216    Ok(())
217}
218
219/// Runs `main_process` until it finishes or the task group is told to shut
220/// down, then shuts the task group down and joins it on every exit path, so
221/// that tasks still holding daemons finish inside the runtime instead of
222/// being dropped by its shutdown. Callers tear their daemons down explicitly
223/// afterwards.
224pub async fn cleanup_on_exit(
225    main_process: impl futures::Future<Output = Result<()>>,
226    task_group: TaskGroup,
227) -> Result<()> {
228    let result = match task_group
229        .make_handle()
230        .cancel_on_shutdown(main_process)
231        .await
232    {
233        Err(_) => {
234            info!(
235                target: LOG_DEVIMINT,
236                "Received shutdown signal before finishing main process, exiting early"
237            );
238            Ok(())
239        }
240        Ok(Ok(())) => {
241            debug!(
242                target: LOG_DEVIMINT,
243                "Main process finished successfully, shutting down task group"
244            );
245            Ok(())
246        }
247        Ok(Err(err)) => {
248            warn!(
249                target: LOG_DEVIMINT,
250                err = %err.fmt_compact_anyhow(),
251                "Main process failed, will shutdown"
252            );
253            Err(err)
254        }
255    };
256
257    let joined = task_group.shutdown_join_all(Duration::from_secs(30)).await;
258
259    match (result, joined) {
260        (Ok(()), joined) => joined.map_err(anyhow::Error::from),
261        (Err(err), Ok(())) => Err(err),
262        (Err(err), Err(join_err)) => {
263            // The main process error is the one worth reporting.
264            warn!(
265                target: LOG_DEVIMINT,
266                err = %join_err.fmt_compact(),
267                "Task group did not shut down cleanly"
268            );
269            Err(err)
270        }
271    }
272}
273
274/// Runs the user command if there is one, otherwise waits for the task group
275/// to be told to shut down, e.g. by a signal.
276pub async fn exec_or_wait_for_shutdown(
277    exec: Option<Vec<ffi::OsString>>,
278    task_group: &TaskGroup,
279) -> Result<()> {
280    if let Some(exec) = exec {
281        debug!(target: LOG_DEVIMINT, "Starting exec command");
282        exec_user_command(exec).await
283    } else {
284        debug!(target: LOG_DEVIMINT, "Waiting for group task shutdown");
285        task_group.make_handle().make_shutdown_rx().await;
286        Ok(())
287    }
288}
289
290pub async fn write_ready_file<T>(global: &vars::Global, result: Result<T>) -> Result<T> {
291    let ready_file = &global.FM_READY_FILE;
292    match result {
293        Ok(_) => write_overwrite_async(ready_file, "READY").await?,
294        Err(_) => write_overwrite_async(ready_file, "ERROR").await?,
295    }
296    result
297}
298
299pub async fn handle_command(cmd: Cmd, common_args: CommonArgs) -> Result<()> {
300    match cmd {
301        Cmd::ExternalDaemons { exec } => {
302            let (process_mgr, task_group) = setup(common_args).await?;
303            let _daemons =
304                write_ready_file(&process_mgr.globals, external_daemons(&process_mgr).await)
305                    .await?;
306            if let Some(exec) = exec {
307                exec_user_command(exec).await?;
308                task_group.shutdown();
309            }
310            task_group.make_handle().make_shutdown_rx().await;
311        }
312        Cmd::DevFed { exec } => handle_dev_fed_command(common_args, exec, false).await?,
313        Cmd::DevFedPreRestore { exec } => handle_dev_fed_command(common_args, exec, true).await?,
314        Cmd::Rpc(rpc_cmd) => rpc_command(rpc_cmd, common_args).await?,
315    }
316    Ok(())
317}
318
319async fn handle_dev_fed_command(
320    common_args: CommonArgs,
321    exec: Option<Vec<ffi::OsString>>,
322    pre_restore: bool,
323) -> Result<()> {
324    trace!(target: LOG_DEVIMINT, "Starting dev fed");
325    let start_time = Instant::now();
326    let skip_setup = common_args.skip_setup;
327    let pre_dkg = common_args.pre_dkg;
328    ensure!(
329        !pre_restore || (!skip_setup && !pre_dkg),
330        "dev-fed-pre-restore cannot be combined with skip-setup or pre-dkg"
331    );
332    let (process_mgr, task_group) = setup(common_args).await?;
333    let dev_fed = DevJitFed::new_with_pre_restore(&process_mgr, skip_setup, pre_dkg, pre_restore)?;
334    let main = {
335        let task_group = task_group.clone();
336        let dev_fed = dev_fed.clone();
337        async move {
338            let pegin_start_time = Instant::now();
339            debug!(target: LOG_DEVIMINT, "Peging in client and gateways");
340
341            if !skip_setup && !pre_dkg && !pre_restore {
342                const GW_PEGIN_AMOUNT: u64 = 1_000_000;
343                const CLIENT_PEGIN_AMOUNT: u64 = 1_000_000;
344
345                let (handle, (), ()) = tokio::try_join!(
346                    async {
347                        let (address, handle) =
348                            dev_fed.internal_client().await?.get_deposit_addr().await?;
349                        debug!(
350                            target: LOG_DEVIMINT,
351                            %address,
352                            %handle,
353                            "Sending funds to client deposit addr"
354                        );
355                        dev_fed
356                            .bitcoind()
357                            .await?
358                            .send_to(address, CLIENT_PEGIN_AMOUNT)
359                            .await?;
360                        Ok(handle)
361                    },
362                    async {
363                        let address = dev_fed
364                            .gw_lnd_registered()
365                            .await?
366                            .client()
367                            .get_pegin_addr(&dev_fed.fed().await?.calculate_federation_id())
368                            .await?;
369                        debug!(
370                            target: LOG_DEVIMINT,
371                            %address,
372                            "Sending funds to LND deposit addr"
373                        );
374                        dev_fed
375                            .bitcoind()
376                            .await?
377                            .send_to(address, GW_PEGIN_AMOUNT)
378                            .await
379                            .map(|_| ())
380                    },
381                    async {
382                        if crate::util::supports_lnv2() {
383                            let gw_ldk = dev_fed.gw_ldk_connected().await?;
384                            let address = gw_ldk
385                                .client()
386                                .get_pegin_addr(&dev_fed.fed().await?.calculate_federation_id())
387                                .await?;
388                            debug!(
389                                target: LOG_DEVIMINT,
390                                %address,
391                                "Sending funds to LDK deposit addr"
392                            );
393                            dev_fed
394                                .bitcoind()
395                                .await?
396                                .send_to(address, GW_PEGIN_AMOUNT)
397                                .await
398                                .map(|_| ())
399                        } else {
400                            Ok(())
401                        }
402                    },
403                )?;
404
405                dev_fed.bitcoind().await?.mine_blocks_no_wait(11).await?;
406                if crate::util::supports_wallet_v2() {
407                    if crate::util::FedimintCli::version_or_default().await
408                        >= *crate::version_constants::VERSION_0_12_0_ALPHA
409                    {
410                        // `handle` is the event log position to wait from.
411                        dev_fed
412                            .internal_client()
413                            .await?
414                            .await_receive(&handle)
415                            .await?;
416                    } else {
417                        // Legacy walletv2 (<= 0.11) auto-claims deposits;
418                        // wait for the balance to reflect the pegin.
419                        dev_fed
420                            .internal_client()
421                            .await?
422                            .await_balance(CLIENT_PEGIN_AMOUNT * 1000 * 9 / 10)
423                            .await?;
424                    }
425                } else {
426                    dev_fed
427                        .internal_client()
428                        .await?
429                        .await_deposit(&handle)
430                        .await?;
431                }
432
433                info!(
434                    target: LOG_DEVIMINT,
435                    elapsed_ms = %pegin_start_time.elapsed().as_millis(),
436                    "Pegins completed"
437                );
438            }
439
440            if !pre_dkg && !pre_restore {
441                // TODO: Audit that the environment access only happens in single-threaded
442                // code.
443                unsafe {
444                    std::env::set_var(FM_INVITE_CODE_ENV, dev_fed.fed().await?.invite_code()?);
445                };
446            }
447
448            if pre_restore {
449                let _ = dev_fed.fed().await?;
450            } else {
451                dev_fed.finalize(&process_mgr).await?;
452            }
453
454            write_ready_file(&process_mgr.globals, Ok(())).await?;
455
456            info!(
457                target: LOG_DEVIMINT,
458                elapsed_ms = %start_time.elapsed().as_millis(),
459                path = %process_mgr.globals.FM_DATA_DIR.display(),
460                "Devfed ready"
461            );
462            exec_or_wait_for_shutdown(exec, &task_group).await
463        }
464    };
465    let result = cleanup_on_exit(main, task_group).await;
466    // Explicit teardown in async context; `cleanup_on_exit` has joined the
467    // task group by now.
468    dev_fed.fast_terminate().await;
469    result
470}
471
472pub async fn exec_user_command(path: Vec<ffi::OsString>) -> Result<(), anyhow::Error> {
473    let cmd_str = path
474        .join(ffi::OsStr::new(" "))
475        .to_string_lossy()
476        .to_string();
477
478    let path_with_aliases = if let Some(existing_path) = env::var_os("PATH") {
479        let mut path = devimint_static_data_dir();
480        path.push("/aliases:");
481        path.push(existing_path);
482        path
483    } else {
484        let mut path = devimint_static_data_dir();
485        path.push("/aliases");
486        path
487    };
488    debug!(target: LOG_DEVIMINT, cmd = %cmd_str, "Executing user command");
489    if !tokio::process::Command::new(&path[0])
490        .args(&path[1..])
491        .env("PATH", path_with_aliases)
492        .kill_on_drop(true)
493        .status()
494        .await
495        .with_context(|| format!("Executing user command failed: {cmd_str}"))?
496        .success()
497    {
498        error!(cmd = %cmd_str, "User command failed");
499        return Err(anyhow!("User command failed: {cmd_str}"));
500    }
501    Ok(())
502}
503
504fn devimint_static_data_dir() -> ffi::OsString {
505    // If set, use the runtime, otherwise the compile time value
506    env::var_os(FM_DEVIMINT_STATIC_DATA_DIR_ENV).unwrap_or(
507        env!(
508            // Note: constant expression, not allowed, so we can't use the constant :/
509            "FM_DEVIMINT_STATIC_DATA_DIR"
510        )
511        .into(),
512    )
513}
514
515pub async fn rpc_command(rpc: RpcCmd, common: CommonArgs) -> Result<()> {
516    fedimint_logging::TracingSetup::default().init()?;
517    match rpc {
518        RpcCmd::Env => {
519            let env_file = common.test_dir().join("env");
520            poll("env file", || async {
521                if fs::try_exists(&env_file)
522                    .await
523                    .context("env file")
524                    .map_err(ControlFlow::Continue)?
525                {
526                    Ok(())
527                } else {
528                    Err(ControlFlow::Continue(anyhow!("env file not found")))
529                }
530            })
531            .await?;
532            let env = fs::read_to_string(&env_file).await?;
533            print!("{env}");
534            Ok(())
535        }
536        RpcCmd::Wait => {
537            let ready_file = common.test_dir().join("ready");
538            poll("ready file", || async {
539                if fs::try_exists(&ready_file)
540                    .await
541                    .context("ready file")
542                    .map_err(ControlFlow::Continue)?
543                {
544                    Ok(())
545                } else {
546                    Err(ControlFlow::Continue(anyhow!("ready file not found")))
547                }
548            })
549            .await?;
550            let env = fs::read_to_string(&ready_file).await?;
551            print!("{env}");
552
553            // Append invite code to devimint env
554            let test_dir = &common.test_dir();
555            let env_file = test_dir.join("env");
556            let invite_file = test_dir.join("cfg/invite-code");
557            if fs::try_exists(&env_file).await.ok().unwrap_or(false)
558                && fs::try_exists(&invite_file).await.ok().unwrap_or(false)
559            {
560                let invite = fs::read_to_string(&invite_file).await?;
561                let mut env_string = fs::read_to_string(&env_file).await?;
562                writeln!(env_string, r#"export FM_INVITE_CODE="{invite}""#)?;
563                // TODO: Audit that the environment access only happens in single-threaded code.
564                unsafe { std::env::set_var(FM_INVITE_CODE_ENV, invite) };
565                write_overwrite_async(env_file, env_string).await?;
566            }
567
568            Ok(())
569        }
570    }
571}