diff --git a/.github/CHECKPOINT_ISSUE_TEMPLATE.md b/.github/CHECKPOINT_ISSUE_TEMPLATE.md index 83c8aa0fae2e..9ac854d62642 100644 --- a/.github/CHECKPOINT_ISSUE_TEMPLATE.md +++ b/.github/CHECKPOINT_ISSUE_TEMPLATE.md @@ -10,7 +10,7 @@ Checkpoints have to be regularly updated, though, and this issue is automaticall How to compute a new checkpoint for calibnet: 1. Install `forest-cli` -2. Download calibnet snapshot: `forest-cli --chain calibnet snapshot fetch` +2. Download calibnet snapshot: `forest-tool snapshot fetch --chain calibnet` 3. Decompress snapshot: `zstd -d forest_snapshot_calibnet_*.car.zst` 4. Extract checkpoints: `forest-cli archive checkpoints forest_snapshot_calibnet_*.car` 5. Put checkpoints in `build/known_blocks.yaml` diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ebf34230bdff..26765f9177f3 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -37,14 +37,15 @@ jobs: timeout-minutes: 5 continue-on-error: true - name: Cargo Build - run: cargo build --release --bin forest --bin forest-cli + run: cargo build --release --bin forest --bin forest-cli --bin forest-tool - name: Compress Binary run: | mkdir -p forest-${{ github.ref_name }} - cp -v target/release/forest target/release/forest-cli forest-${{ github.ref_name }} + cp -v target/release/forest target/release/forest-cli target/release/forest-tool forest-${{ github.ref_name }} cp -rv CHANGELOG.md LICENSE-APACHE LICENSE-MIT README.md documentation forest-${{ github.ref_name }} sha256sum forest-${{ github.ref_name }}/forest > forest-${{ github.ref_name }}/forest.sha256 sha256sum forest-${{ github.ref_name }}/forest-cli > forest-${{ github.ref_name }}/forest-cli.sha256 + sha256sum forest-${{ github.ref_name }}/forest-tool > forest-${{ github.ref_name }}/forest-tool.sha256 zip -r ${{ matrix.file }} forest-${{ github.ref_name }} - name: Upload Binary uses: svenstaro/upload-release-action@v2 diff --git a/CHANGELOG.md b/CHANGELOG.md index 0aff353fa493..d2b6a12716dc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -31,6 +31,15 @@ internal settings from files to the database. - [#3333](https://github.com/ChainSafe/forest/pull/3333) Changed default rpc port from 1234 to 2345. +- [#3336](https://github.com/ChainSafe/forest/pull/3336) Moved following + `forest-cli` subcommands to `forest-tool` + - `archive info` + - `fetch-params` + - `snapshot fetch` + - `snapshot validate` +- [#3355](https://github.com/ChainSafe/forest/pull/3355) Moved commands + - `forest-cli db stats` to `forest-tool db stats` + - `forest-cli db clean` to `forest-tool db destroy` ### Added diff --git a/Dockerfile b/Dockerfile index b44ed0c1f559..ab8075dfaaf0 100644 --- a/Dockerfile +++ b/Dockerfile @@ -87,6 +87,6 @@ USER ${SERVICE_USER} WORKDIR /home/${SERVICE_USER} # Basic verification of dynamically linked dependencies -RUN forest -V && forest-cli -V +RUN forest -V && forest-cli -V && forest-tool -V ENTRYPOINT ["forest"] diff --git a/Dockerfile-alpine b/Dockerfile-alpine index 8b7f6a797b6d..27237fad1d85 100644 --- a/Dockerfile-alpine +++ b/Dockerfile-alpine @@ -64,6 +64,6 @@ USER ${SERVICE_USER} WORKDIR /home/${SERVICE_USER} # Basic verification of dynamically linked dependencies -RUN forest -V && forest-cli -V +RUN forest -V && forest-cli -V && forest-tool -V ENTRYPOINT ["forest"] diff --git a/README.md b/README.md index fce0549b0842..f8c33cee9e6e 100644 --- a/README.md +++ b/README.md @@ -269,6 +269,17 @@ The admin token can also be set using `--token` flag. forest-cli --token ``` +### Forest executable organization + +The binaries in the Forest repository are organized into the following +categories: + +| Binary | Role | Command example | +| ------------- | -------------------------------------------------------- | -------------------------------------------------- | +| `forest` | Forest daemon, used to connect to the Filecoin network | `forest --chain calibnet --encrypt-keystore false` | +| `forest-cli` | Human-friendly wrappers around the Filecoin JSON-RPC API | `forest-cli info show` | +| `forest-tool` | Handle tasks not involving the Forest daemon | `forest-tool snapshot fetch` | + ### Detaching Forest process You can detach Forest process via the `--detach` flag so that it runs in the diff --git a/scripts/db_params_hyperfine.sh b/scripts/db_params_hyperfine.sh index 536cf643c231..8fa5481ea21e 100755 --- a/scripts/db_params_hyperfine.sh +++ b/scripts/db_params_hyperfine.sh @@ -22,4 +22,4 @@ hyperfine \ ./target/release/forest \ --chain ${CHAIN} --config /tmp/forest.conf --rpc false --no-gc --encrypt-keystore false --halt-after-import \ --import-snapshot ${SNAPSHOT}; \ - ./target/release/forest-cli --chain ${CHAIN} db clean --force" + ./target/release/forest-tool db destroy --chain ${CHAIN} --force" diff --git a/scripts/gen_coverage_report.sh b/scripts/gen_coverage_report.sh index 395f36c98577..787293902fad 100755 --- a/scripts/gen_coverage_report.sh +++ b/scripts/gen_coverage_report.sh @@ -31,17 +31,17 @@ function cov { cargo llvm-cov --workspace clean cargo llvm-cov --workspace --no-report cov forest-cli --chain calibnet db clean --force -cov forest-cli --chain calibnet snapshot fetch --aria2 --provider filecoin -s "$TMP_DIR" +cov forest-tool snapshot fetch --chain calibnet --vendor filops -s "$TMP_DIR" SNAPSHOT_PATH=$(find "$TMP_DIR" -name \*.zst | head -n 1) cov forest --chain calibnet --encrypt-keystore false --import-snapshot "$SNAPSHOT_PATH" --halt-after-import --height=-200 --track-peak-rss cov forest-cli --chain calibnet db clean --force -cov forest-cli --chain calibnet snapshot fetch --aria2 -s "$TMP_DIR" +cov forest-tool snapshot fetch --chain calibnet -s "$TMP_DIR" SNAPSHOT_PATH=$(find "$TMP_DIR" -name \*.car | head -n 1) cov forest --chain calibnet --encrypt-keystore false --import-snapshot "$SNAPSHOT_PATH" --height=-200 --detach --track-peak-rss --save-token "$TOKEN_PATH" cov forest-cli sync wait cov forest-cli sync status cov forest-cli --chain calibnet db gc -cov forest-cli --chain calibnet db stats +cov forest-tool db stats --chain calibnet cov forest-cli snapshot export cov forest-cli snapshot export cov forest-cli attach --exec 'showPeers()' diff --git a/scripts/tests/calibnet_export_check.sh b/scripts/tests/calibnet_export_check.sh index 9bec6055e480..38477385efd5 100755 --- a/scripts/tests/calibnet_export_check.sh +++ b/scripts/tests/calibnet_export_check.sh @@ -25,5 +25,5 @@ echo "Validating CAR files" zstd --decompress ./*.car.zst for f in *.car; do echo "Validating CAR file $f" - $FOREST_CLI_PATH --chain calibnet snapshot validate "$f" + $FOREST_TOOL_PATH snapshot validate "$f" done diff --git a/scripts/tests/forest_cli_check.sh b/scripts/tests/forest_cli_check.sh index ff5a10ac2ecd..e2f8f7cd8dde 100755 --- a/scripts/tests/forest_cli_check.sh +++ b/scripts/tests/forest_cli_check.sh @@ -19,17 +19,17 @@ function num-files-here() { | wc --lines } -"$FOREST_CLI_PATH" fetch-params --keys +"$FOREST_TOOL_PATH" fetch-params --keys -: "cleaning an empty database doesn't fail (see #2811)" -"$FOREST_CLI_PATH" --chain calibnet db clean --force -"$FOREST_CLI_PATH" --chain calibnet db clean --force +: "destroying an empty database doesn't fail (see #2811)" +"$FOREST_TOOL_PATH" db destroy --chain calibnet --force +"$FOREST_TOOL_PATH" db destroy --chain calibnet --force : fetch snapshot pushd "$(mktemp --directory)" - "$FOREST_CLI_PATH" --chain calibnet snapshot fetch --vendor forest - "$FOREST_CLI_PATH" --chain calibnet snapshot fetch --vendor filops + "$FOREST_TOOL_PATH" snapshot fetch --chain calibnet --vendor forest + "$FOREST_TOOL_PATH" snapshot fetch --chain calibnet --vendor filops # this will fail if they happen to have the same height - we should change the format of our filenames test "$(num-files-here)" -eq 2 @@ -59,7 +59,7 @@ pushd "$(mktemp --directory)" #assert_eq "$DIFF_STATE_ROOTS" 1100 : Validate the union of a snapshot and a diff - "$FOREST_CLI_PATH" snapshot validate --check-network calibnet base_snapshot.forest.car.zst diff_snapshot.forest.car.zst + "$FOREST_TOOL_PATH" snapshot validate --check-network calibnet base_snapshot.forest.car.zst diff_snapshot.forest.car.zst rm -- * popd @@ -68,7 +68,7 @@ popd : validate latest calibnet snapshot pushd "$(mktemp --directory)" : : fetch a compressed calibnet snapshot - "$FOREST_CLI_PATH" --chain calibnet snapshot fetch + "$FOREST_TOOL_PATH" snapshot fetch --chain calibnet test "$(num-files-here)" -eq 1 uncompress_me=$(find . -type f | head -1) @@ -77,10 +77,10 @@ pushd "$(mktemp --directory)" validate_me=$(find . -type f | head -1) : : validating under calibnet chain should succeed - "$FOREST_CLI_PATH" snapshot validate --check-network calibnet "$validate_me" + "$FOREST_TOOL_PATH" snapshot validate --check-network calibnet "$validate_me" : : validating under mainnet chain should fail - if "$FOREST_CLI_PATH" snapshot validate --check-network mainnet "$validate_me"; then + if "$FOREST_TOOL_PATH" snapshot validate --check-network mainnet "$validate_me"; then exit 1 fi diff --git a/scripts/tests/harness.sh b/scripts/tests/harness.sh index f4e59814f757..fcdfc5e7c19a 100644 --- a/scripts/tests/harness.sh +++ b/scripts/tests/harness.sh @@ -5,6 +5,7 @@ FOREST_PATH="forest" FOREST_CLI_PATH="forest-cli" +FOREST_TOOL_PATH="forest-tool" TMP_DIR=$(mktemp --directory) LOG_DIRECTORY=$TMP_DIR/logs @@ -19,19 +20,19 @@ function forest_download_and_import_snapshot { function forest_check_db_stats { echo "Checking DB stats" - $FOREST_CLI_PATH --chain calibnet db stats + $FOREST_TOOL_PATH db stats --chain calibnet } function forest_query_epoch { - $FOREST_CLI_PATH archive info "$1" | grep Epoch | awk '{print $2}' + $FOREST_TOOL_PATH archive info "$1" | grep Epoch | awk '{print $2}' } function forest_query_state_roots { - $FOREST_CLI_PATH archive info "$1" | grep State-roots | awk '{print $2}' + $FOREST_TOOL_PATH archive info "$1" | grep State-roots | awk '{print $2}' } function forest_query_format { - $FOREST_CLI_PATH archive info "$1" | grep "CAR format" | awk '{print $3}' + $FOREST_TOOL_PATH archive info "$1" | grep "CAR format" | awk '{print $3}' } function forest_run_node_detached { diff --git a/src/cli/main.rs b/src/cli/main.rs index a92d2745a819..e4afea093ba8 100644 --- a/src/cli/main.rs +++ b/src/cli/main.rs @@ -7,6 +7,7 @@ use std::sync::Arc; use crate::cli_shared::logger; use crate::networks::ChainConfig; use crate::shim::address::{CurrentNetwork, Network}; +use crate::utils::bail_moved_cmd; use crate::utils::io::ProgressBar; use crate::{ cli::subcommands::{cli_error_and_die, Cli}, @@ -50,7 +51,9 @@ where } // Run command match cmd { - Subcommand::Fetch(cmd) => cmd.run(config).await, + Subcommand::Fetch(_cmd) => { + bail_moved_cmd("fetch-params", "forest-tool fetch-params") + } Subcommand::Chain(cmd) => cmd.run(config).await, Subcommand::Auth(cmd) => cmd.run(config).await, Subcommand::Net(cmd) => cmd.run(config).await, diff --git a/src/cli/subcommands/archive_cmd.rs b/src/cli/subcommands/archive_cmd.rs index f89934ad4a83..38520e5c5723 100644 --- a/src/cli/subcommands/archive_cmd.rs +++ b/src/cli/subcommands/archive_cmd.rs @@ -32,17 +32,16 @@ use crate::chain::{ ChainEpochDelta, }; use crate::cli_shared::{snapshot, snapshot::TrustedVendor}; -use crate::db::car::{AnyCar, ManyCar, RandomAccessFileReader}; +use crate::db::car::ManyCar; use crate::ipld::{stream_graph, CidHashSet}; use crate::networks::{calibnet, mainnet, ChainConfig, NetworkChain}; use crate::shim::clock::{ChainEpoch, EPOCHS_IN_DAY, EPOCH_DURATION_SECONDS}; +use crate::utils::bail_moved_cmd; use anyhow::{bail, Context as _}; use chrono::NaiveDateTime; use clap::Subcommand; use futures::TryStreamExt; use fvm_ipld_blockstore::Blockstore; -use indicatif::ProgressIterator; -use itertools::Itertools; use sha2::Sha256; use std::path::PathBuf; use std::sync::Arc; @@ -50,11 +49,9 @@ use tracing::info; #[derive(Debug, Subcommand)] pub enum ArchiveCommands { - /// Show basic information about an archive. - Info { - /// Path to an uncompressed archive (CAR) - snapshot: PathBuf, - }, + // This subcommand is hidden and only here to help users migrating to forest-tool + #[command(hide = true)] + Info { snapshot: PathBuf }, /// Trim a snapshot of the chain and write it to `` Export { /// Snapshot input path. Currently supports only `.car` file format. @@ -90,10 +87,7 @@ pub enum ArchiveCommands { impl ArchiveCommands { pub async fn run(self) -> anyhow::Result<()> { match self { - Self::Info { snapshot } => { - println!("{}", ArchiveInfo::from_store(AnyCar::try_from(snapshot)?)?); - Ok(()) - } + Self::Info { .. } => bail_moved_cmd("archive info", "forest-tool archive info"), Self::Export { snapshot_files, output_path, @@ -233,112 +227,6 @@ async fn do_export( Ok(()) } -#[derive(Debug)] -struct ArchiveInfo { - variant: String, - network: String, - epoch: ChainEpoch, - tipsets: ChainEpoch, - messages: ChainEpoch, -} - -impl std::fmt::Display for ArchiveInfo { - fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { - writeln!(f, "CAR format: {}", self.variant)?; - writeln!(f, "Network: {}", self.network)?; - writeln!(f, "Epoch: {}", self.epoch)?; - writeln!(f, "State-roots: {}", self.epoch - self.tipsets + 1)?; - write!(f, "Messages sets: {}", self.epoch - self.messages + 1)?; - Ok(()) - } -} - -impl ArchiveInfo { - // Scan a CAR archive to identify which network it belongs to and how many - // tipsets/messages are available. Progress is rendered to stdout. - fn from_store(store: AnyCar) -> anyhow::Result { - Self::from_store_with(store, true) - } - - // Scan a CAR archive to identify which network it belongs to and how many - // tipsets/messages are available. Progress is optionally rendered to - // stdout. - fn from_store_with( - store: AnyCar, - progress: bool, - ) -> anyhow::Result { - let root = store.heaviest_tipset()?; - let root_epoch = root.epoch(); - - let tipsets = root.clone().chain(&store); - - let windowed = (std::iter::once(root).chain(tipsets)).tuple_windows(); - - let mut network: String = "unknown".into(); - let mut lowest_stateroot_epoch = root_epoch; - let mut lowest_message_epoch = root_epoch; - - let iter = if progress { - itertools::Either::Left(windowed.progress_count(root_epoch as u64)) - } else { - itertools::Either::Right(windowed) - }; - - for (parent, tipset) in iter { - if tipset.epoch() >= parent.epoch() && parent.epoch() != root_epoch { - bail!("Broken invariant: non-sequential epochs"); - } - - if tipset.epoch() < 0 { - bail!("Broken invariant: tipset with negative epoch"); - } - - // Update the lowest-stateroot-epoch only if our parent also has a - // state-root. The genesis state-root is usually available but we're - // not interested in that. - if lowest_stateroot_epoch == parent.epoch() && store.has(tipset.parent_state())? { - lowest_stateroot_epoch = tipset.epoch(); - } - if lowest_message_epoch == parent.epoch() - && store.has(tipset.min_ticket_block().messages())? - { - lowest_message_epoch = tipset.epoch(); - } - - if tipset.epoch() == 0 { - if tipset.min_ticket_block().cid() == &*calibnet::GENESIS_CID { - network = "calibnet".into(); - } else if tipset.min_ticket_block().cid() == &*mainnet::GENESIS_CID { - network = "mainnet".into(); - } - } - - // If we've already found the lowest-stateroot-epoch and - // lowest-message-epoch then we can skip scanning the rest of the - // archive when we find a checkpoint. - let may_skip = - lowest_stateroot_epoch != tipset.epoch() && lowest_message_epoch != tipset.epoch(); - if may_skip { - let genesis_block = tipset.genesis(&store)?; - if genesis_block.cid() == &*calibnet::GENESIS_CID { - network = "calibnet".into(); - } else if genesis_block.cid() == &*mainnet::GENESIS_CID { - network = "mainnet".into(); - } - break; - } - } - - Ok(ArchiveInfo { - variant: store.variant().to_string(), - network, - epoch: root_epoch, - tipsets: lowest_stateroot_epoch, - messages: lowest_message_epoch, - }) - } -} - // Print a mapping of epochs to block headers in yaml format. This mapping can // be used by Forest to quickly identify tipsets. fn print_checkpoints(snapshot_files: Vec) -> anyhow::Result<()> { @@ -380,34 +268,13 @@ fn list_checkpoints( #[cfg(test)] mod tests { use super::*; + use crate::db::car::AnyCar; use async_compression::tokio::bufread::ZstdDecoder; use fvm_ipld_car::CarReader; use tempfile::TempDir; use tokio::io::BufReader; use tokio_util::compat::TokioAsyncReadCompatExt; - #[test] - fn archive_info_calibnet() { - let info = ArchiveInfo::from_store_with( - AnyCar::try_from(calibnet::DEFAULT_GENESIS).unwrap(), - false, - ) - .unwrap(); - assert_eq!(info.network, "calibnet"); - assert_eq!(info.epoch, 0); - } - - #[test] - fn archive_info_mainnet() { - let info = ArchiveInfo::from_store_with( - AnyCar::try_from(mainnet::DEFAULT_GENESIS).unwrap(), - false, - ) - .unwrap(); - assert_eq!(info.network, "mainnet"); - assert_eq!(info.epoch, 0); - } - fn genesis_timestamp(genesis_car: &'static [u8]) -> u64 { let db = crate::db::car::PlainCar::try_from(genesis_car).unwrap(); let ts = db.heaviest_tipset().unwrap(); diff --git a/src/cli/subcommands/db_cmd.rs b/src/cli/subcommands/db_cmd.rs index bb90991b5f90..55e3c3e37a74 100644 --- a/src/cli/subcommands/db_cmd.rs +++ b/src/cli/subcommands/db_cmd.rs @@ -3,26 +3,25 @@ use std::sync::Arc; -use crate::cli_shared::{chain_path, cli::Config}; -use crate::db::db_engine::db_root; +use crate::cli_shared::cli::Config; use crate::rpc_api::progress_api::GetProgressType; use crate::rpc_client::{db_ops::db_gc, progress_ops::get_progress}; use crate::utils::io::ProgressBar; use chrono::Utc; use clap::Subcommand; -use tracing::error; -use crate::cli::subcommands::{handle_rpc_err, prompt_confirm}; +use crate::cli::subcommands::handle_rpc_err; +use crate::utils::bail_moved_cmd; #[derive(Debug, Subcommand)] pub enum DBCommands { - /// Show DB stats - Stats, /// Run DB garbage collection GC, - /// DB Clean up + // Those subcommands are hidden and only here to help users migrating to forest-tool + #[command(hide = true)] + Stats, + #[command(hide = true)] Clean { - /// Answer yes to all forest-cli yes/no questions without prompting #[arg(long)] force: bool, }, @@ -31,15 +30,7 @@ pub enum DBCommands { impl DBCommands { pub async fn run(&self, config: &Config) -> anyhow::Result<()> { match self { - Self::Stats => { - use human_repr::HumanCount; - - let dir = db_root(&chain_path(config)); - println!("Database path: {}", dir.display()); - let size = fs_extra::dir::get_size(dir).unwrap_or_default(); - println!("Database size: {}", size.human_count_bytes()); - Ok(()) - } + Self::Stats => bail_moved_cmd("db stats", "forest-tool db stats"), Self::GC => { let start = Utc::now(); @@ -81,31 +72,7 @@ impl DBCommands { Ok(()) } - Self::Clean { force } => { - let dir = chain_path(config); - if !dir.is_dir() { - println!( - "Aborted. Database path {} is not a valid directory", - dir.display() - ); - return Ok(()); - } - println!("Deleting {}", dir.display()); - if !force && !prompt_confirm() { - println!("Aborted."); - return Ok(()); - } - match fs_extra::dir::remove(&dir) { - Ok(_) => { - println!("Deleted {}", dir.display()); - Ok(()) - } - Err(err) => { - error!("{err}"); - Ok(()) - } - } - } + Self::Clean { .. } => bail_moved_cmd("db clean", "forest-tool db destroy"), } } } diff --git a/src/cli/subcommands/mod.rs b/src/cli/subcommands/mod.rs index 7c8eb85a0dd0..d7c30c048855 100644 --- a/src/cli/subcommands/mod.rs +++ b/src/cli/subcommands/mod.rs @@ -13,7 +13,6 @@ mod car_cmd; mod chain_cmd; mod config_cmd; mod db_cmd; -mod fetch_params_cmd; mod info_cmd; mod mpool_cmd; mod net_cmd; @@ -40,9 +39,9 @@ use tracing::error; pub(super) use self::{ archive_cmd::ArchiveCommands, attach_cmd::AttachCommand, auth_cmd::AuthCommands, car_cmd::CarCommands, chain_cmd::ChainCommands, config_cmd::ConfigCommands, db_cmd::DBCommands, - fetch_params_cmd::FetchCommands, mpool_cmd::MpoolCommands, net_cmd::NetCommands, - send_cmd::SendCommand, shutdown_cmd::ShutdownCommand, snapshot_cmd::SnapshotCommands, - state_cmd::StateCommands, sync_cmd::SyncCommands, wallet_cmd::WalletCommands, + mpool_cmd::MpoolCommands, net_cmd::NetCommands, send_cmd::SendCommand, + shutdown_cmd::ShutdownCommand, snapshot_cmd::SnapshotCommands, state_cmd::StateCommands, + sync_cmd::SyncCommands, wallet_cmd::WalletCommands, }; use crate::cli::subcommands::info_cmd::InfoCommand; @@ -57,11 +56,23 @@ pub struct Cli { pub cmd: Subcommand, } +// This subcommand is hidden and only here to help users migrating to forest-tool +#[derive(Debug, clap::Args)] +pub struct FetchCommands { + #[arg(short, long)] + all: bool, + #[arg(short, long)] + keys: bool, + #[arg(short, long)] + dry_run: bool, + params_size: Option, +} + /// Forest binary sub-commands available. -#[derive(clap::Subcommand)] +#[derive(clap::Subcommand, Debug)] pub enum Subcommand { - /// Download parameters for generating and verifying proofs for given size - #[command(name = "fetch-params")] + // This subcommand is hidden and only here to help users migrating to forest-tool + #[command(hide = true, name = "fetch-params")] Fetch(FetchCommands), /// Interact with Filecoin blockchain @@ -205,7 +216,7 @@ pub(super) fn print_stdout(out: String) { .unwrap(); } -fn prompt_confirm() -> bool { +pub fn prompt_confirm() -> bool { print!("Do you want to continue? [y/n] "); std::io::stdout().flush().unwrap(); let mut line = String::new(); diff --git a/src/cli/subcommands/snapshot_cmd.rs b/src/cli/subcommands/snapshot_cmd.rs index 3ee2f8340a58..33cf889129b1 100644 --- a/src/cli/subcommands/snapshot_cmd.rs +++ b/src/cli/subcommands/snapshot_cmd.rs @@ -2,29 +2,20 @@ // SPDX-License-Identifier: Apache-2.0, MIT use super::*; -use crate::blocks::Tipset; -use crate::chain::index::ChainIndex; use crate::cli::subcommands::{cli_error_and_die, handle_rpc_err}; use crate::cli_shared::snapshot::{self, TrustedVendor}; -use crate::daemon::bundle::load_actor_bundles; -use crate::db::car::ManyCar; -use crate::ipld::{recurse_links_hash, CidHashSet}; -use crate::networks::{calibnet, mainnet, ChainConfig, NetworkChain}; use crate::rpc_api::chain_api::ChainExportParams; use crate::rpc_client::chain_ops::*; -use crate::shim::machine::MultiEngine; +use crate::utils::bail_moved_cmd; use crate::utils::db::car_stream::CarStream; -use crate::utils::proofs_api::paramfetch::ensure_params_downloaded; use anyhow::{bail, Context, Result}; use chrono::Utc; use clap::Subcommand; use dialoguer::{theme::ColorfulTheme, Confirm}; use futures::TryStreamExt; -use fvm_ipld_blockstore::Blockstore; use human_repr::HumanCount; use indicatif::{ProgressBar, ProgressStyle}; use std::path::{Path, PathBuf}; -use std::sync::Arc; use tempfile::NamedTempFile; use tokio::fs::File; use tokio::io::AsyncWriteExt; @@ -50,31 +41,28 @@ pub enum SnapshotCommands { depth: Option, }, - /// Fetches the most recent snapshot from a trusted, pre-defined location. + // This subcommand is hidden and only here to help users migrating to forest-tool + #[command(hide = true)] Fetch { #[arg(short, long, default_value = ".")] directory: PathBuf, - /// Vendor to fetch the snapshot from #[arg(short, long, value_enum, default_value_t = snapshot::TrustedVendor::default())] vendor: snapshot::TrustedVendor, }, - /// Validates the snapshot. + // This subcommand is hidden and only here to help users migrating to forest-tool + #[command(hide = true)] Validate { - /// Number of recent epochs to scan for broken links #[arg(long, default_value_t = 2000)] check_links: u32, - /// Assert the snapshot belongs to this network. If left blank, the - /// network will be inferred before executing messages. #[arg(long)] check_network: Option, - /// Number of recent epochs to scan for bad messages/transactions #[arg(long, default_value_t = 60)] check_stateroots: u32, - /// Path to a snapshot CAR, which may be zstd compressed #[arg(required = true)] snapshot_files: Vec, }, + /// Make this snapshot suitable for use as a compressed car-backed blockstore. Compress { /// Input CAR file, in `.car`, `.car.zst`, or `.forest.car.zst` format. @@ -190,32 +178,10 @@ impl SnapshotCommands { println!("Export completed."); Ok(()) } - Self::Fetch { directory, vendor } => { - match snapshot::fetch(&directory, &config.chain.network, vendor).await { - Ok(out) => { - println!("{}", out.display()); - Ok(()) - } - Err(e) => cli_error_and_die(format!("Failed fetching the snapshot: {e}"), 1), - } + Self::Fetch { .. } => bail_moved_cmd("snapshot fetch", "forest-tool snapshot fetch"), + Self::Validate { .. } => { + bail_moved_cmd("snapshot validate", "forest-tool snapshot validate") } - Self::Validate { - check_links, - check_network, - check_stateroots, - snapshot_files, - } => { - let store = ManyCar::try_from(snapshot_files)?; - validate_with_blockstore( - store.heaviest_tipset()?, - Arc::new(store), - check_links, - check_network, - check_stateroots, - ) - .await - } - Self::Compress { source, output, @@ -302,197 +268,3 @@ async fn save_checksum(source: &Path, encoded_hash: String) -> Result<()> { checksum_file.flush().await?; Ok(()) } - -// Check the validity of a snapshot by looking at IPLD links, the genesis block, -// and message output. More checks may be added in the future. -// -// If the snapshot is valid, the output should look like this: -// Checking IPLD integrity: ✅ verified! -// Identifying genesis block: ✅ found! -// Verifying network identity: ✅ verified! -// Running tipset transactions: ✅ verified! -// Snapshot is valid -// -// If we receive a mainnet snapshot but expect a calibnet snapshot, the output -// should look like this: -// Checking IPLD integrity: ✅ verified! -// Identifying genesis block: ✅ found! -// Verifying network identity: ❌ wrong! -// Error: Expected mainnet but found calibnet -async fn validate_with_blockstore( - root: Tipset, - store: Arc, - check_links: u32, - check_network: Option, - check_stateroots: u32, -) -> Result<()> -where - BlockstoreT: Blockstore + Send + Sync + 'static, -{ - if check_links != 0 { - validate_ipld_links(root.clone(), &store, check_links).await?; - } - - if let Some(expected_network) = &check_network { - let actual_network = query_network(&root, &store)?; - // Somewhat silly use of a spinner but this makes the checks line up nicely. - let pb = validation_spinner("Verifying network identity:"); - if expected_network != &actual_network { - pb.finish_with_message("❌ wrong!"); - bail!("Expected {} but found {}", expected_network, actual_network); - } else { - pb.finish_with_message("✅ verified!"); - } - } - - if check_stateroots != 0 { - let network = check_network - .map(anyhow::Ok) - .unwrap_or_else(|| query_network(&root, &store))?; - validate_stateroots(root, &store, network, check_stateroots).await?; - } - - println!("Snapshot is valid"); - Ok(()) -} - -// The Filecoin block chain is a DAG of Ipld nodes. The complete graph isn't -// required to sync to the network and snapshot files usually disgard data after -// 2000 epochs. Validity can be verified by ensuring there are no bad IPLD or -// broken links in the N most recent epochs. -async fn validate_ipld_links(ts: Tipset, db: &DB, epochs: u32) -> Result<()> -where - DB: Blockstore + Send + Sync, -{ - let epoch_limit = ts.epoch() - epochs as i64; - let mut seen = CidHashSet::default(); - - let pb = validation_spinner("Checking IPLD integrity:").with_finish( - indicatif::ProgressFinish::AbandonWithMessage("❌ Invalid IPLD data!".into()), - ); - - for tipset in ts - .chain(db) - .take_while(|tipset| tipset.epoch() > epoch_limit) - { - let height = tipset.epoch(); - pb.set_message(format!("{} remaining epochs", height - epoch_limit)); - - let mut assert_cid_exists = |cid: Cid| async move { - let data = db.get(&cid)?; - data.ok_or_else(|| anyhow::anyhow!("Broken IPLD link at epoch: {height}")) - }; - - for h in tipset.blocks() { - recurse_links_hash(&mut seen, *h.state_root(), &mut assert_cid_exists, &|_| ()).await?; - recurse_links_hash(&mut seen, *h.messages(), &mut assert_cid_exists, &|_| ()).await?; - } - } - - pb.finish_with_message("✅ verified!"); - Ok(()) -} - -// The genesis block determines the network identity (e.g., mainnet or -// calibnet). Scanning through the entire blockchain can be time-consuming, so -// Forest keeps a list of known tipsets for each network. Finding a known tipset -// short-circuits the search for the genesis block. If no genesis block can be -// found or if the genesis block is unrecognizable, an error is returned. -fn query_network(ts: &Tipset, db: impl Blockstore) -> Result { - let pb = validation_spinner("Identifying genesis block:").with_finish( - indicatif::ProgressFinish::AbandonWithMessage("✅ found!".into()), - ); - - fn match_genesis_block(block_cid: Cid) -> Result { - if block_cid == *calibnet::GENESIS_CID { - Ok(NetworkChain::Calibnet) - } else if block_cid == *mainnet::GENESIS_CID { - Ok(NetworkChain::Mainnet) - } else { - bail!("Unrecognizable genesis block"); - } - } - - if let Ok(genesis_block) = ts.genesis(db) { - return match_genesis_block(*genesis_block.cid()); - } - - pb.finish_with_message("❌ No valid genesis block!"); - bail!("Snapshot does not contain a genesis block") -} - -// Each tipset in the blockchain contains a set of messages. A message is a -// transaction that manipulates a persistent state-tree. The hashes of these -// state-trees are stored in the tipsets and can be used to verify if the -// messages were correctly executed. -// Note: Messages may access state-trees 900 epochs in the past. So, if a -// snapshot has state-trees for 2000 epochs, one can only validate the messages -// for the last 1100 epochs. -async fn validate_stateroots( - ts: Tipset, - db: &Arc, - network: NetworkChain, - epochs: u32, -) -> Result<()> -where - DB: Blockstore + Send + Sync + 'static, -{ - let chain_config = Arc::new(ChainConfig::from_chain(&network)); - let genesis = ts.genesis(db)?; - - let pb = validation_spinner("Running tipset transactions:").with_finish( - indicatif::ProgressFinish::AbandonWithMessage( - "❌ Transaction result differs from Lotus!".into(), - ), - ); - - let last_epoch = ts.epoch() - epochs as i64; - - // Bundles are required when doing state migrations. - load_actor_bundles(&db).await?; - - // Set proof parameter data dir and make sure the proofs are available - crate::utils::proofs_api::paramfetch::set_proofs_parameter_cache_dir_env( - &Config::default().client.data_dir, - ); - - ensure_params_downloaded().await?; - - let chain_index = Arc::new(ChainIndex::new(Arc::new(db.clone()))); - - // Prepare tipsets for validation - let tipsets = chain_index - .chain(Arc::new(ts)) - .take_while(|tipset| tipset.epoch() >= last_epoch) - .inspect(|tipset| { - pb.set_message(format!("epoch queue: {}", tipset.epoch() - last_epoch)); - }); - - let beacon = Arc::new(chain_config.get_beacon_schedule(genesis.timestamp())); - - // ProgressBar::wrap_iter believes the progress has been abandoned once the - // iterator is consumed. - crate::state_manager::validate_tipsets( - genesis.timestamp(), - chain_index.clone(), - chain_config, - beacon, - &MultiEngine::default(), - tipsets, - )?; - - pb.finish_with_message("✅ verified!"); - drop(pb); - Ok(()) -} - -fn validation_spinner(prefix: &'static str) -> indicatif::ProgressBar { - let pb = indicatif::ProgressBar::new_spinner() - .with_style( - indicatif::ProgressStyle::with_template("{spinner} {prefix:<30} {msg}") - .expect("indicatif template must be valid"), - ) - .with_prefix(prefix); - pb.enable_steady_tick(std::time::Duration::from_secs_f32(0.1)); - pb -} diff --git a/src/cli_shared/cli/mod.rs b/src/cli_shared/cli/mod.rs index 0a50d27ba4c4..c17aaadcf607 100644 --- a/src/cli_shared/cli/mod.rs +++ b/src/cli_shared/cli/mod.rs @@ -148,7 +148,7 @@ pub struct CliOpts { impl CliOpts { pub fn to_config(&self) -> Result<(Config, Option), anyhow::Error> { - let path = find_config_path(self); + let path = find_config_path(&self.config); let mut cfg: Config = match &path { Some(path) => { // Read from config file @@ -247,8 +247,8 @@ impl ConfigPath { } } -fn find_config_path(opts: &CliOpts) -> Option { - if let Some(s) = &opts.config { +pub fn find_config_path(config: &Option) -> Option { + if let Some(s) = config { return Some(ConfigPath::Cli(PathBuf::from(s))); } if let Ok(s) = std::env::var("FOREST_CONFIG_PATH") { diff --git a/src/tool/main.rs b/src/tool/main.rs index 4f734d64f0ea..de5f102a8a7e 100644 --- a/src/tool/main.rs +++ b/src/tool/main.rs @@ -21,7 +21,11 @@ where .block_on(async { // Run command match cmd { - Subcommand::Benchmark(benchmark) => benchmark.run().await, + Subcommand::Benchmark(cmd) => cmd.run().await, + Subcommand::Snapshot(cmd) => cmd.run().await, + Subcommand::Fetch(cmd) => cmd.run().await, + Subcommand::Archive(cmd) => cmd.run().await, + Subcommand::DB(cmd) => cmd.run().await, } }) } diff --git a/src/tool/subcommands/archive_cmd.rs b/src/tool/subcommands/archive_cmd.rs new file mode 100644 index 000000000000..91ddc3a8136e --- /dev/null +++ b/src/tool/subcommands/archive_cmd.rs @@ -0,0 +1,165 @@ +// Copyright 2019-2023 ChainSafe Systems +// SPDX-License-Identifier: Apache-2.0, MIT + +use crate::db::car::{AnyCar, RandomAccessFileReader}; +use crate::networks::{calibnet, mainnet}; +use crate::shim::clock::ChainEpoch; +use anyhow::bail; +use clap::Subcommand; +use fvm_ipld_blockstore::Blockstore; +use indicatif::ProgressIterator; +use itertools::Itertools; +use std::path::PathBuf; + +#[derive(Debug, Subcommand)] +pub enum ArchiveCommands { + /// Show basic information about an archive. + Info { + /// Path to an uncompressed archive (CAR) + snapshot: PathBuf, + }, +} + +impl ArchiveCommands { + pub async fn run(self) -> anyhow::Result<()> { + match self { + Self::Info { snapshot } => { + println!("{}", ArchiveInfo::from_store(AnyCar::try_from(snapshot)?)?); + Ok(()) + } + } + } +} + +#[derive(Debug)] +pub struct ArchiveInfo { + variant: String, + network: String, + epoch: ChainEpoch, + tipsets: ChainEpoch, + messages: ChainEpoch, +} + +impl std::fmt::Display for ArchiveInfo { + fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { + writeln!(f, "CAR format: {}", self.variant)?; + writeln!(f, "Network: {}", self.network)?; + writeln!(f, "Epoch: {}", self.epoch)?; + writeln!(f, "State-roots: {}", self.epoch - self.tipsets + 1)?; + write!(f, "Messages sets: {}", self.epoch - self.messages + 1)?; + Ok(()) + } +} + +impl ArchiveInfo { + // Scan a CAR archive to identify which network it belongs to and how many + // tipsets/messages are available. Progress is rendered to stdout. + fn from_store(store: AnyCar) -> anyhow::Result { + Self::from_store_with(store, true) + } + + // Scan a CAR archive to identify which network it belongs to and how many + // tipsets/messages are available. Progress is optionally rendered to + // stdout. + fn from_store_with( + store: AnyCar, + progress: bool, + ) -> anyhow::Result { + let root = store.heaviest_tipset()?; + let root_epoch = root.epoch(); + + let tipsets = root.clone().chain(&store); + + let windowed = (std::iter::once(root).chain(tipsets)).tuple_windows(); + + let mut network: String = "unknown".into(); + let mut lowest_stateroot_epoch = root_epoch; + let mut lowest_message_epoch = root_epoch; + + let iter = if progress { + itertools::Either::Left(windowed.progress_count(root_epoch as u64)) + } else { + itertools::Either::Right(windowed) + }; + + for (parent, tipset) in iter { + if tipset.epoch() >= parent.epoch() && parent.epoch() != root_epoch { + bail!("Broken invariant: non-sequential epochs"); + } + + if tipset.epoch() < 0 { + bail!("Broken invariant: tipset with negative epoch"); + } + + // Update the lowest-stateroot-epoch only if our parent also has a + // state-root. The genesis state-root is usually available but we're + // not interested in that. + if lowest_stateroot_epoch == parent.epoch() && store.has(tipset.parent_state())? { + lowest_stateroot_epoch = tipset.epoch(); + } + if lowest_message_epoch == parent.epoch() + && store.has(tipset.min_ticket_block().messages())? + { + lowest_message_epoch = tipset.epoch(); + } + + if tipset.epoch() == 0 { + if tipset.min_ticket_block().cid() == &*calibnet::GENESIS_CID { + network = "calibnet".into(); + } else if tipset.min_ticket_block().cid() == &*mainnet::GENESIS_CID { + network = "mainnet".into(); + } + } + + // If we've already found the lowest-stateroot-epoch and + // lowest-message-epoch then we can skip scanning the rest of the + // archive when we find a checkpoint. + let may_skip = + lowest_stateroot_epoch != tipset.epoch() && lowest_message_epoch != tipset.epoch(); + if may_skip { + let genesis_block = tipset.genesis(&store)?; + if genesis_block.cid() == &*calibnet::GENESIS_CID { + network = "calibnet".into(); + } else if genesis_block.cid() == &*mainnet::GENESIS_CID { + network = "mainnet".into(); + } + break; + } + } + + Ok(ArchiveInfo { + variant: store.variant().to_string(), + network, + epoch: root_epoch, + tipsets: lowest_stateroot_epoch, + messages: lowest_message_epoch, + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn archive_info_calibnet() { + let info = ArchiveInfo::from_store_with( + AnyCar::try_from(calibnet::DEFAULT_GENESIS).unwrap(), + false, + ) + .unwrap(); + assert_eq!(info.network, "calibnet"); + assert_eq!(info.epoch, 0); + } + + #[test] + fn archive_info_mainnet() { + let info = ArchiveInfo::from_store_with( + AnyCar::try_from(mainnet::DEFAULT_GENESIS).unwrap(), + false, + ) + .unwrap(); + assert_eq!(info.network, "mainnet"); + assert_eq!(info.epoch, 0); + } +} diff --git a/src/tool/subcommands/db_cmd.rs b/src/tool/subcommands/db_cmd.rs new file mode 100644 index 000000000000..2efecfea2e7e --- /dev/null +++ b/src/tool/subcommands/db_cmd.rs @@ -0,0 +1,84 @@ +// Copyright 2019-2023 ChainSafe Systems +// SPDX-License-Identifier: Apache-2.0, MIT + +use super::read_config; +use crate::cli::subcommands::prompt_confirm; +use crate::cli_shared::chain_path; +use crate::db::db_engine::db_root; +use crate::networks::NetworkChain; +use clap::Subcommand; +use tracing::error; + +#[derive(Debug, Subcommand)] +pub enum DBCommands { + /// Show DB stats + Stats { + /// Optional TOML file containing forest daemon configuration + #[arg(short, long)] + config: Option, + /// Optional chain, will override the chain section of configuration file if used + #[arg(long)] + chain: Option, + }, + /// DB destruction + Destroy { + /// Answer yes to all forest-cli yes/no questions without prompting + #[arg(long)] + force: bool, + /// Optional TOML file containing forest daemon configuration + #[arg(short, long)] + config: Option, + /// Optional chain, will override the chain section of configuration file if used + #[arg(long)] + chain: Option, + }, +} + +impl DBCommands { + pub async fn run(&self) -> anyhow::Result<()> { + match self { + Self::Stats { config, chain } => { + use human_repr::HumanCount; + + let config = read_config(config, chain)?; + + let dir = db_root(&chain_path(&config)); + println!("Database path: {}", dir.display()); + let size = fs_extra::dir::get_size(dir).unwrap_or_default(); + println!("Database size: {}", size.human_count_bytes()); + Ok(()) + } + Self::Destroy { + force, + config, + chain, + } => { + let config = read_config(config, chain)?; + + let dir = chain_path(&config); + if !dir.is_dir() { + println!( + "Aborted. Database path {} is not a valid directory", + dir.display() + ); + return Ok(()); + } + println!("Deleting {}", dir.display()); + if !force && !prompt_confirm() { + println!("Aborted."); + return Ok(()); + } + match fs_extra::dir::remove(&dir) { + Ok(_) => { + println!("Deleted {}", dir.display()); + Ok(()) + } + Err(err) => { + error!("{err}"); + Ok(()) + } + } + } + } + } +} diff --git a/src/cli/subcommands/fetch_params_cmd.rs b/src/tool/subcommands/fetch_params_cmd.rs similarity index 90% rename from src/cli/subcommands/fetch_params_cmd.rs rename to src/tool/subcommands/fetch_params_cmd.rs index 0190d63982ac..aee5c846ed5e 100644 --- a/src/cli/subcommands/fetch_params_cmd.rs +++ b/src/tool/subcommands/fetch_params_cmd.rs @@ -4,8 +4,8 @@ use crate::shim::sector::SectorSize; use crate::utils::proofs_api::paramfetch::{get_params_default, SectorSizeOpt}; -use super::cli_error_and_die; -use crate::cli::subcommands::Config; +use super::read_config; +use crate::cli::subcommands::cli_error_and_die; #[allow(missing_docs)] #[derive(Debug, clap::Args)] @@ -21,10 +21,15 @@ pub struct FetchCommands { dry_run: bool, /// Size in bytes params_size: Option, + /// Optional TOML file containing forest daemon configuration + #[arg(short, long)] + pub config: Option, } impl FetchCommands { - pub async fn run(&self, config: Config) -> anyhow::Result<()> { + pub async fn run(&self) -> anyhow::Result<()> { + let config = read_config(&self.config, &None)?; + let sizes = if self.all { SectorSizeOpt::All } else if let Some(size) = &self.params_size { diff --git a/src/tool/subcommands/mod.rs b/src/tool/subcommands/mod.rs index c088fad27066..8ff837157bff 100644 --- a/src/tool/subcommands/mod.rs +++ b/src/tool/subcommands/mod.rs @@ -1,11 +1,19 @@ // Copyright 2019-2023 ChainSafe Systems // SPDX-License-Identifier: Apache-2.0, MIT +pub mod archive_cmd; pub mod benchmark_cmd; +pub mod db_cmd; +pub mod fetch_params_cmd; +pub mod snapshot_cmd; use crate::cli_shared::cli::HELP_MESSAGE; +use crate::cli_shared::cli::*; +use crate::networks::{ChainConfig, NetworkChain}; use crate::utils::version::FOREST_VERSION_STRING; +use crate::utils::{io::read_file_to_string, io::read_toml}; use clap::Parser; +use std::sync::Arc; /// Command-line options for the `forest-tool` binary #[derive(Parser)] @@ -22,4 +30,43 @@ pub enum Subcommand { /// Benchmark various Forest subsystems #[command(subcommand)] Benchmark(benchmark_cmd::BenchmarkCommands), + + /// Manage snapshots + #[command(subcommand)] + Snapshot(snapshot_cmd::SnapshotCommands), + + /// Download parameters for generating and verifying proofs for given size + #[command(name = "fetch-params")] + Fetch(fetch_params_cmd::FetchCommands), + + /// Manage archives + #[command(subcommand)] + Archive(archive_cmd::ArchiveCommands), + + /// Database management + #[command(subcommand)] + DB(db_cmd::DBCommands), +} + +fn read_config(config: &Option, chain: &Option) -> anyhow::Result { + let path = find_config_path(config); + let mut cfg: Config = match &path { + Some(path) => { + // Read from config file + let toml = read_file_to_string(path.to_path_buf())?; + // Parse and return the configuration file + read_toml(&toml)? + } + None => Config::default(), + }; + + // Override config with chain if some + match chain { + Some(NetworkChain::Mainnet) => cfg.chain = Arc::new(ChainConfig::mainnet()), + Some(NetworkChain::Calibnet) => cfg.chain = Arc::new(ChainConfig::calibnet()), + Some(NetworkChain::Devnet(_)) => cfg.chain = Arc::new(ChainConfig::devnet()), + None => (), + } + + Ok(cfg) } diff --git a/src/tool/subcommands/snapshot_cmd.rs b/src/tool/subcommands/snapshot_cmd.rs new file mode 100644 index 000000000000..5732bc4fad1f --- /dev/null +++ b/src/tool/subcommands/snapshot_cmd.rs @@ -0,0 +1,279 @@ +// Copyright 2019-2023 ChainSafe Systems +// SPDX-License-Identifier: Apache-2.0, MIT + +use super::*; +use crate::blocks::Tipset; +use crate::chain::index::ChainIndex; +use crate::cli_shared::snapshot; +use crate::daemon::bundle::load_actor_bundles; +use crate::db::car::ManyCar; +use crate::ipld::{recurse_links_hash, CidHashSet}; +use crate::networks::{calibnet, mainnet, ChainConfig, NetworkChain}; +use crate::shim::machine::MultiEngine; +use crate::utils::proofs_api::paramfetch::ensure_params_downloaded; +use anyhow::{bail, Result}; +use cid::Cid; +use clap::Subcommand; +use fvm_ipld_blockstore::Blockstore; +use std::path::PathBuf; +use std::sync::Arc; + +#[derive(Debug, Subcommand)] +pub enum SnapshotCommands { + /// Fetches the most recent snapshot from a trusted, pre-defined location. + Fetch { + #[arg(short, long, default_value = ".")] + directory: PathBuf, + /// Network chain the snapshot will belong to + #[arg(long, default_value_t = NetworkChain::Mainnet)] + chain: NetworkChain, + /// Vendor to fetch the snapshot from + #[arg(short, long, value_enum, default_value_t = snapshot::TrustedVendor::default())] + vendor: snapshot::TrustedVendor, + }, + + /// Validates the snapshot. + Validate { + /// Number of recent epochs to scan for broken links + #[arg(long, default_value_t = 2000)] + check_links: u32, + /// Assert the snapshot belongs to this network. If left blank, the + /// network will be inferred before executing messages. + #[arg(long)] + check_network: Option, + /// Number of recent epochs to scan for bad messages/transactions + #[arg(long, default_value_t = 60)] + check_stateroots: u32, + /// Path to a snapshot CAR, which may be zstd compressed + #[arg(required = true)] + snapshot_files: Vec, + }, +} + +impl SnapshotCommands { + pub async fn run(self) -> Result<()> { + match self { + Self::Fetch { + directory, + chain, + vendor, + } => match snapshot::fetch(&directory, &chain, vendor).await { + Ok(out) => { + println!("{}", out.display()); + Ok(()) + } + Err(e) => cli_error_and_die(format!("Failed fetching the snapshot: {e}"), 1), + }, + Self::Validate { + check_links, + check_network, + check_stateroots, + snapshot_files, + } => { + let store = ManyCar::try_from(snapshot_files)?; + validate_with_blockstore( + store.heaviest_tipset()?, + Arc::new(store), + check_links, + check_network, + check_stateroots, + ) + .await + } + } + } +} + +// Check the validity of a snapshot by looking at IPLD links, the genesis block, +// and message output. More checks may be added in the future. +// +// If the snapshot is valid, the output should look like this: +// Checking IPLD integrity: ✅ verified! +// Identifying genesis block: ✅ found! +// Verifying network identity: ✅ verified! +// Running tipset transactions: ✅ verified! +// Snapshot is valid +// +// If we receive a mainnet snapshot but expect a calibnet snapshot, the output +// should look like this: +// Checking IPLD integrity: ✅ verified! +// Identifying genesis block: ✅ found! +// Verifying network identity: ❌ wrong! +// Error: Expected mainnet but found calibnet +async fn validate_with_blockstore( + root: Tipset, + store: Arc, + check_links: u32, + check_network: Option, + check_stateroots: u32, +) -> Result<()> +where + BlockstoreT: Blockstore + Send + Sync + 'static, +{ + if check_links != 0 { + validate_ipld_links(root.clone(), &store, check_links).await?; + } + + if let Some(expected_network) = &check_network { + let actual_network = query_network(&root, &store)?; + // Somewhat silly use of a spinner but this makes the checks line up nicely. + let pb = validation_spinner("Verifying network identity:"); + if expected_network != &actual_network { + pb.finish_with_message("❌ wrong!"); + bail!("Expected {} but found {}", expected_network, actual_network); + } else { + pb.finish_with_message("✅ verified!"); + } + } + + if check_stateroots != 0 { + let network = check_network + .map(anyhow::Ok) + .unwrap_or_else(|| query_network(&root, &store))?; + validate_stateroots(root, &store, network, check_stateroots).await?; + } + + println!("Snapshot is valid"); + Ok(()) +} + +// The Filecoin block chain is a DAG of Ipld nodes. The complete graph isn't +// required to sync to the network and snapshot files usually disgard data after +// 2000 epochs. Validity can be verified by ensuring there are no bad IPLD or +// broken links in the N most recent epochs. +async fn validate_ipld_links(ts: Tipset, db: &DB, epochs: u32) -> Result<()> +where + DB: Blockstore + Send + Sync, +{ + let epoch_limit = ts.epoch() - epochs as i64; + let mut seen = CidHashSet::default(); + + let pb = validation_spinner("Checking IPLD integrity:").with_finish( + indicatif::ProgressFinish::AbandonWithMessage("❌ Invalid IPLD data!".into()), + ); + + for tipset in ts + .chain(db) + .take_while(|tipset| tipset.epoch() > epoch_limit) + { + let height = tipset.epoch(); + pb.set_message(format!("{} remaining epochs", height - epoch_limit)); + + let mut assert_cid_exists = |cid: Cid| async move { + let data = db.get(&cid)?; + data.ok_or_else(|| anyhow::anyhow!("Broken IPLD link at epoch: {height}")) + }; + + for h in tipset.blocks() { + recurse_links_hash(&mut seen, *h.state_root(), &mut assert_cid_exists, &|_| ()).await?; + recurse_links_hash(&mut seen, *h.messages(), &mut assert_cid_exists, &|_| ()).await?; + } + } + + pb.finish_with_message("✅ verified!"); + Ok(()) +} + +// The genesis block determines the network identity (e.g., mainnet or +// calibnet). Scanning through the entire blockchain can be time-consuming, so +// Forest keeps a list of known tipsets for each network. Finding a known tipset +// short-circuits the search for the genesis block. If no genesis block can be +// found or if the genesis block is unrecognizable, an error is returned. +fn query_network(ts: &Tipset, db: impl Blockstore) -> Result { + let pb = validation_spinner("Identifying genesis block:").with_finish( + indicatif::ProgressFinish::AbandonWithMessage("✅ found!".into()), + ); + + fn match_genesis_block(block_cid: Cid) -> Result { + if block_cid == *calibnet::GENESIS_CID { + Ok(NetworkChain::Calibnet) + } else if block_cid == *mainnet::GENESIS_CID { + Ok(NetworkChain::Mainnet) + } else { + bail!("Unrecognizable genesis block"); + } + } + + if let Ok(genesis_block) = ts.genesis(db) { + return match_genesis_block(*genesis_block.cid()); + } + + pb.finish_with_message("❌ No valid genesis block!"); + bail!("Snapshot does not contain a genesis block") +} + +// Each tipset in the blockchain contains a set of messages. A message is a +// transaction that manipulates a persistent state-tree. The hashes of these +// state-trees are stored in the tipsets and can be used to verify if the +// messages were correctly executed. +// Note: Messages may access state-trees 900 epochs in the past. So, if a +// snapshot has state-trees for 2000 epochs, one can only validate the messages +// for the last 1100 epochs. +async fn validate_stateroots( + ts: Tipset, + db: &Arc, + network: NetworkChain, + epochs: u32, +) -> Result<()> +where + DB: Blockstore + Send + Sync + 'static, +{ + let chain_config = Arc::new(ChainConfig::from_chain(&network)); + let genesis = ts.genesis(db)?; + + let pb = validation_spinner("Running tipset transactions:").with_finish( + indicatif::ProgressFinish::AbandonWithMessage( + "❌ Transaction result differs from Lotus!".into(), + ), + ); + + let last_epoch = ts.epoch() - epochs as i64; + + // Bundles are required when doing state migrations. + load_actor_bundles(&db).await?; + + // Set proof parameter data dir and make sure the proofs are available + crate::utils::proofs_api::paramfetch::set_proofs_parameter_cache_dir_env( + &Config::default().client.data_dir, + ); + + ensure_params_downloaded().await?; + + let chain_index = Arc::new(ChainIndex::new(Arc::new(db.clone()))); + + // Prepare tipsets for validation + let tipsets = chain_index + .chain(Arc::new(ts)) + .take_while(|tipset| tipset.epoch() >= last_epoch) + .inspect(|tipset| { + pb.set_message(format!("epoch queue: {}", tipset.epoch() - last_epoch)); + }); + + let beacon = Arc::new(chain_config.get_beacon_schedule(genesis.timestamp())); + + // ProgressBar::wrap_iter believes the progress has been abandoned once the + // iterator is consumed. + crate::state_manager::validate_tipsets( + genesis.timestamp(), + chain_index.clone(), + chain_config, + beacon, + &MultiEngine::default(), + tipsets, + )?; + + pb.finish_with_message("✅ verified!"); + drop(pb); + Ok(()) +} + +fn validation_spinner(prefix: &'static str) -> indicatif::ProgressBar { + let pb = indicatif::ProgressBar::new_spinner() + .with_style( + indicatif::ProgressStyle::with_template("{spinner} {prefix:<30} {msg}") + .expect("indicatif template must be valid"), + ) + .with_prefix(prefix); + pb.enable_steady_tick(std::time::Duration::from_secs_f32(0.1)); + pb +} diff --git a/src/utils/mod.rs b/src/utils/mod.rs index e4053fe559c6..4e2896b9d438 100644 --- a/src/utils/mod.rs +++ b/src/utils/mod.rs @@ -21,6 +21,17 @@ use std::{pin::Pin, time::Duration}; use tokio::time::sleep; use tracing::error; +// FIXME: Remove this function and hidden commands +// Tracking issue https://github.com/ChainSafe/forest/issues/3363 +/// Function used to bail on usage of migrated commands +pub fn bail_moved_cmd(subcommand: &str, command: &str) -> anyhow::Result<()> { + anyhow::bail!( + "Invalid subcommand: forest-cli {}. It has been moved to {}.", + subcommand, + command + ) +} + /// Keep running the future created by `make_fut` until the timeout or retry /// limit in `args` is reached. /// `F` _must_ be cancel safe. diff --git a/tests/config.rs b/tests/config.rs index 7805607dfda1..292fa1e70b1e 100644 --- a/tests/config.rs +++ b/tests/config.rs @@ -131,7 +131,7 @@ fn test_config_env_var() -> Result<()> { fn test_download_location_of_proof_parameter_files_env() { let tmp_dir = TempDir::new().unwrap(); - Command::cargo_bin("forest-cli") + Command::cargo_bin("forest-tool") .unwrap() .env("FIL_PROOFS_PARAMETER_CACHE", tmp_dir.path()) .arg("fetch-params") @@ -159,7 +159,7 @@ fn test_download_location_of_proof_parameter_files_default() { .write_all(toml::to_string(&config).unwrap().as_bytes()) .expect("Failed writing configuration!"); - Command::cargo_bin("forest-cli") + Command::cargo_bin("forest-tool") .unwrap() .env("FOREST_CONFIG_PATH", config_file.path()) .arg("fetch-params")