diff --git a/.github-proposal/README.md b/.github-proposal/README.md new file mode 100644 index 00000000..8c440c45 --- /dev/null +++ b/.github-proposal/README.md @@ -0,0 +1,87 @@ +# GitHub Actions Workflows for OSVM CLI + +This directory contains GitHub Actions workflow proposals for the OSVM CLI project. These workflows automate testing, benchmarking, and deployment processes. + +## Workflows + +### CI (Continuous Integration) + +The CI workflow (`workflows/ci.yml`) runs on every push to the main branch and on pull requests. It performs: + +1. **Sanity Checks**: Runs `rustfmt` and `clippy` to ensure code quality and style. +2. **Unit Tests**: Runs unit tests for the library and binary components. +3. **End-to-End Tests**: Runs the end-to-end tests that verify CLI functionality. +4. **Code Coverage**: Generates a code coverage report using `cargo-tarpaulin` and uploads it to Codecov. + +### Cross-Platform Tests + +The Cross-Platform Tests workflow (`workflows/cross-platform.yml`) runs on every push to the main branch and on pull requests. It: + +1. Builds and tests the application on multiple operating systems: + - Ubuntu Linux + - macOS + - Windows +2. Ensures the CLI works consistently across different platforms. + +### Benchmarks + +The Benchmarks workflow (`workflows/benchmarks.yml`) runs on every push to the main branch, on pull requests, and weekly on Sundays. It: + +1. Runs performance benchmarks using `cargo-criterion`. +2. Uploads benchmark results as artifacts. +3. Generates a benchmark report. +4. For pull requests, compares benchmarks with the main branch to detect performance regressions. + +### Security Scan + +The Security Scan workflow (`workflows/security.yml`) runs on every push to the main branch, on pull requests, and weekly on Mondays. It: + +1. **Security Audit**: Runs `cargo-audit` to check for known vulnerabilities in dependencies. +2. **Dependency Review**: Reviews dependencies for security issues in pull requests. +3. **Code Scanning**: Uses GitHub's CodeQL to scan for security vulnerabilities in the code. + +### Release + +The Release workflow (`workflows/release.yml`) runs when a tag starting with 'v' is pushed. It: + +1. **Builds** the release binary. +2. **Creates a GitHub Release** with the binary attached. +3. **Deploys to APT Repository**: Creates a Debian package and deploys it to an APT repository. +4. **Deploys to Homebrew**: Creates a Homebrew formula and submits it to Homebrew. +5. **Deploys Documentation**: Generates documentation using `cargo doc` and deploys it to GitHub Pages. + +## Usage + +To use these workflows: + +1. Move the `.github-proposal` directory to `.github` in your repository. +2. Customize the workflows as needed for your specific requirements. +3. For the Release workflow, you'll need to set up: + - An APT repository for Debian package deployment + - A Homebrew tap for formula submission + - GitHub Pages for documentation hosting + +## Requirements + +These workflows require: + +- GitHub Actions enabled on your repository +- Appropriate permissions for the GitHub token +- For the Release workflow, additional secrets may be needed for deployment + +## Customization + +You can customize these workflows by: + +- Adjusting the triggers (e.g., which branches to run on) +- Adding or removing steps +- Changing the deployment targets +- Modifying the build parameters + +## Troubleshooting + +If you encounter issues with these workflows: + +- Check the GitHub Actions logs for detailed error messages +- Ensure all required secrets are properly set +- Verify that the repository has the necessary permissions \ No newline at end of file diff --git a/.github-proposal/workflows/benchmarks.yml b/.github-proposal/workflows/benchmarks.yml new file mode 100644 index 00000000..70462e15 --- /dev/null +++ b/.github-proposal/workflows/benchmarks.yml @@ -0,0 +1,78 @@ +name: Benchmarks + +on: + push: + branches: [ main ] + pull_request: + branches: [ main ] + schedule: + - cron: '0 0 * * 0' # Run weekly on Sundays + +env: + CARGO_TERM_COLOR: always + RUST_BACKTRACE: 1 + +jobs: + benchmarks: + name: Run Benchmarks + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + + - name: Install Rust + uses: actions-rs/toolchain@v1 + with: + profile: minimal + toolchain: stable + override: true + + - name: Cache dependencies + uses: actions/cache@v3 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + target + key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }} + + - name: Install cargo-criterion + uses: actions-rs/install@v0.1 + with: + crate: cargo-criterion + version: latest + use-tool-cache: true + + - name: Run benchmarks + uses: actions-rs/cargo@v1 + with: + command: criterion + + - name: Upload benchmark results + uses: actions/upload-artifact@v3 + with: + name: benchmark-results + path: target/criterion + + - name: Generate benchmark report + run: | + mkdir -p benchmark-report + cp -r target/criterion/* benchmark-report/ + echo "# Benchmark Results" > benchmark-report/README.md + echo "Generated on $(date)" >> benchmark-report/README.md + echo "## Summary" >> benchmark-report/README.md + find target/criterion -name "*/new/estimates.json" -exec cat {} \; | jq -r '.mean | { command: .point_estimate, lower_bound: .confidence_interval.lower_bound, upper_bound: .confidence_interval.upper_bound }' >> benchmark-report/README.md + + - name: Upload benchmark report + uses: actions/upload-artifact@v3 + with: + name: benchmark-report + path: benchmark-report + + - name: Compare with previous benchmarks + if: github.event_name == 'pull_request' + run: | + git fetch origin ${{ github.base_ref }} + git checkout FETCH_HEAD + cargo criterion --baseline main + git checkout ${{ github.sha }} + cargo criterion --baseline main \ No newline at end of file diff --git a/.github-proposal/workflows/ci.yml b/.github-proposal/workflows/ci.yml new file mode 100644 index 00000000..b2e4e310 --- /dev/null +++ b/.github-proposal/workflows/ci.yml @@ -0,0 +1,144 @@ +name: CI + +on: + push: + branches: [ main ] + pull_request: + branches: [ main ] + +env: + CARGO_TERM_COLOR: always + RUST_BACKTRACE: 1 + +jobs: + sanity-check: + name: Sanity Checks + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + + - name: Install Rust + uses: actions-rs/toolchain@v1 + with: + profile: minimal + toolchain: stable + override: true + components: rustfmt, clippy + + - name: Cache dependencies + uses: actions/cache@v3 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + target + key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }} + + - name: Check formatting + uses: actions-rs/cargo@v1 + with: + command: fmt + args: --all -- --check + + - name: Run clippy + uses: actions-rs/cargo@v1 + with: + command: clippy + args: -- -D warnings + + unit-tests: + name: Unit Tests + needs: sanity-check + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + + - name: Install Rust + uses: actions-rs/toolchain@v1 + with: + profile: minimal + toolchain: stable + override: true + + - name: Cache dependencies + uses: actions/cache@v3 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + target + key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }} + + - name: Run unit tests + uses: actions-rs/cargo@v1 + with: + command: test + args: --lib --bins + + e2e-tests: + name: End-to-End Tests + needs: unit-tests + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + + - name: Install Rust + uses: actions-rs/toolchain@v1 + with: + profile: minimal + toolchain: stable + override: true + + - name: Cache dependencies + uses: actions/cache@v3 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + target + key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }} + + - name: Build binary + uses: actions-rs/cargo@v1 + with: + command: build + args: --release + + - name: Run e2e tests + uses: actions-rs/cargo@v1 + with: + command: test + args: --test main + + code-coverage: + name: Code Coverage + needs: [unit-tests, e2e-tests] + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + + - name: Install Rust + uses: actions-rs/toolchain@v1 + with: + profile: minimal + toolchain: stable + override: true + + - name: Install cargo-tarpaulin + uses: actions-rs/install@v0.1 + with: + crate: cargo-tarpaulin + version: latest + use-tool-cache: true + + - name: Generate coverage report + uses: actions-rs/cargo@v1 + with: + command: tarpaulin + args: --out Xml --output-dir coverage + + - name: Upload coverage to Codecov + uses: codecov/codecov-action@v3 + with: + directory: ./coverage/ + fail_ci_if_error: true \ No newline at end of file diff --git a/.github-proposal/workflows/cross-platform.yml b/.github-proposal/workflows/cross-platform.yml new file mode 100644 index 00000000..3b01bd1d --- /dev/null +++ b/.github-proposal/workflows/cross-platform.yml @@ -0,0 +1,54 @@ +name: Cross-Platform Tests + +on: + push: + branches: [ main ] + pull_request: + branches: [ main ] + +env: + CARGO_TERM_COLOR: always + RUST_BACKTRACE: 1 + +jobs: + build-and-test: + name: Build and Test + strategy: + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v3 + + - name: Install Rust + uses: actions-rs/toolchain@v1 + with: + profile: minimal + toolchain: stable + override: true + + - name: Cache dependencies + uses: actions/cache@v3 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + target + key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }} + + - name: Build + uses: actions-rs/cargo@v1 + with: + command: build + + - name: Run unit tests + uses: actions-rs/cargo@v1 + with: + command: test + args: --lib --bins + + - name: Run e2e tests + uses: actions-rs/cargo@v1 + with: + command: test + args: --test main \ No newline at end of file diff --git a/.github-proposal/workflows/release.yml b/.github-proposal/workflows/release.yml new file mode 100644 index 00000000..42808083 --- /dev/null +++ b/.github-proposal/workflows/release.yml @@ -0,0 +1,188 @@ +name: Release + +on: + push: + tags: + - 'v*' + +env: + CARGO_TERM_COLOR: always + RUST_BACKTRACE: 1 + +jobs: + build: + name: Build Release Binaries + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + + - name: Install Rust + uses: actions-rs/toolchain@v1 + with: + profile: minimal + toolchain: stable + override: true + + - name: Build release binary + uses: actions-rs/cargo@v1 + with: + command: build + args: --release + + - name: Upload binary + uses: actions/upload-artifact@v3 + with: + name: osvm-binary + path: target/release/osvm + + create-github-release: + name: Create GitHub Release + needs: build + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + + - name: Download binary + uses: actions/download-artifact@v3 + with: + name: osvm-binary + path: ./ + + - name: Make binary executable + run: chmod +x ./osvm + + - name: Create release + id: create_release + uses: softprops/action-gh-release@v1 + with: + files: ./osvm + draft: false + prerelease: false + generate_release_notes: true + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + deploy-apt: + name: Deploy to APT Repository + needs: create-github-release + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + + - name: Download binary + uses: actions/download-artifact@v3 + with: + name: osvm-binary + path: ./ + + - name: Make binary executable + run: chmod +x ./osvm + + - name: Set up Debian packaging environment + run: | + sudo apt-get update + sudo apt-get install -y debhelper dh-make devscripts + + - name: Create Debian package + run: | + VERSION=$(echo ${{ github.ref_name }} | sed 's/^v//') + mkdir -p osvm-$VERSION + cp ./osvm osvm-$VERSION/ + cd osvm-$VERSION + dh_make -y -s -c mit -e maintainer@example.com -f ../osvm + cd .. + dpkg-buildpackage -us -uc + + - name: Upload Debian package + uses: actions/upload-artifact@v3 + with: + name: osvm-deb-package + path: ../osvm_*.deb + + - name: Deploy to APT repository + run: | + # This is a placeholder for the actual APT repository deployment + # In a real scenario, you would use a service like Launchpad or a custom APT repository + echo "Deploying to APT repository..." + # Example: scp ../osvm_*.deb user@apt-repo:/path/to/repo/ + # Then update the repository index + + deploy-homebrew: + name: Deploy to Homebrew + needs: create-github-release + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + + - name: Set up Homebrew + uses: Homebrew/actions/setup-homebrew@master + + - name: Create Homebrew formula + run: | + VERSION=$(echo ${{ github.ref_name }} | sed 's/^v//') + SHA=$(curl -sL https://github.com/${{ github.repository }}/archive/${{ github.ref_name }}.tar.gz | shasum -a 256 | cut -d ' ' -f 1) + + cat > osvm.rb << EOF + class Osvm < Formula + desc "OpenSVM CLI tool for managing SVM nodes" + homepage "https://github.com/${{ github.repository }}" + url "https://github.com/${{ github.repository }}/archive/${{ github.ref_name }}.tar.gz" + sha256 "$SHA" + version "$VERSION" + + depends_on "rust" => :build + + def install + system "cargo", "build", "--release" + bin.install "target/release/osvm" + end + + test do + system "#{bin}/osvm", "--version" + end + end + EOF + + - name: Upload Homebrew formula + uses: actions/upload-artifact@v3 + with: + name: osvm-homebrew-formula + path: ./osvm.rb + + - name: Submit to Homebrew + run: | + # This is a placeholder for the actual Homebrew submission + # In a real scenario, you would create a PR to homebrew-core or a custom tap + echo "Submitting to Homebrew..." + # Example: Create a PR to homebrew-core with the formula + + deploy-cargo-doc: + name: Deploy to cargo doc + needs: create-github-release + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + + - name: Install Rust + uses: actions-rs/toolchain@v1 + with: + profile: minimal + toolchain: stable + override: true + + - name: Generate documentation + uses: actions-rs/cargo@v1 + with: + command: doc + args: --no-deps --document-private-items + + - name: Create index.html + run: | + echo '' > target/doc/index.html + + - name: Deploy to GitHub Pages + uses: peaceiris/actions-gh-pages@v3 + with: + github_token: ${{ secrets.GITHUB_TOKEN }} + publish_dir: ./target/doc + force_orphan: true \ No newline at end of file diff --git a/.github-proposal/workflows/security.yml b/.github-proposal/workflows/security.yml new file mode 100644 index 00000000..8c0ad0ae --- /dev/null +++ b/.github-proposal/workflows/security.yml @@ -0,0 +1,77 @@ +name: Security Scan + +on: + push: + branches: [ main ] + pull_request: + branches: [ main ] + schedule: + - cron: '0 0 * * 1' # Run weekly on Mondays + +jobs: + security-audit: + name: Security Audit + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + + - name: Install Rust + uses: actions-rs/toolchain@v1 + with: + profile: minimal + toolchain: stable + override: true + + - name: Install cargo-audit + uses: actions-rs/install@v0.1 + with: + crate: cargo-audit + version: latest + use-tool-cache: true + + - name: Run cargo-audit + uses: actions-rs/cargo@v1 + with: + command: audit + + - name: Run cargo-deny + uses: EmbarkStudios/cargo-deny-action@v1 + with: + command: check advisories + + dependency-review: + name: Dependency Review + runs-on: ubuntu-latest + if: github.event_name == 'pull_request' + steps: + - name: Checkout Repository + uses: actions/checkout@v3 + + - name: Dependency Review + uses: actions/dependency-review-action@v3 + with: + fail-on-severity: high + + code-scanning: + name: Code Scanning + runs-on: ubuntu-latest + permissions: + security-events: write + actions: read + contents: read + steps: + - name: Checkout repository + uses: actions/checkout@v3 + + - name: Initialize CodeQL + uses: github/codeql-action/init@v2 + with: + languages: rust + + - name: Build + uses: actions-rs/cargo@v1 + with: + command: build + + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@v2 \ No newline at end of file diff --git a/Cargo.toml b/Cargo.toml index fa6c3942..d9f3bced 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,7 +6,6 @@ license = "WTFPL" publish = false [dependencies] -borsh = "0.9.3" clap = "2.33.3" lazy_static = "1.4.0" serde = { version = "1.0.125", features = ["derive"] } @@ -35,6 +34,8 @@ serde_json = "1.0" dirs = "5.0" [dev-dependencies] -lazy_static = "1.4.0" -solana-test-validator = "1.14.*" -solana-streamer = "1.14.*" \ No newline at end of file +assert_cmd = "2.0" +predicates = "3.0" +tempfile = "3.8" +serial_test = "2.0" +mockito = "1.2" \ No newline at end of file diff --git a/src/clparse.rs b/src/clparse.rs index 43c61956..56566153 100644 --- a/src/clparse.rs +++ b/src/clparse.rs @@ -14,6 +14,7 @@ pub fn parse_command_line() -> ArgMatches<'static> { .version(crate_version!()) .setting(AppSettings::SubcommandRequiredElseHelp) .setting(AppSettings::AllowExternalSubcommands) + // Global arguments .arg({ let arg = Arg::with_name("config_file") .short("C") @@ -87,106 +88,7 @@ pub fn parse_command_line() -> ArgMatches<'static> { .possible_values(&["mainnet", "testnet", "devnet"]) .default_value("mainnet") .help("Network to deploy on"), - ) - .subcommand( - SubCommand::with_name("balance").about("Get balance").arg( - Arg::with_name("address") - .validator(is_valid_pubkey) - .value_name("ADDRESS") - .takes_value(true) - .index(1) - .help("Address to get the balance of"), - ), - ) - .subcommand( - SubCommand::with_name("mint") - .about("Mint a new key/value pair to an account") - .arg( - Arg::with_name("to-owner") - .display_order(1) - .long("to-owner") - .short("t") - .required(true) - .takes_value(true) - .help("Owner of accounts") - .possible_values(&["User1", "User2"]), - ) - .arg( - Arg::with_name("key") - .display_order(2) - .long("key") - .short("k") - .required(true) - .takes_value(true) - .help("The key of key/value pair"), - ) - .arg( - Arg::with_name("value") - .display_order(3) - .long("value") - .required(true) - .min_values(1) - .help("The value string of key/value pair"), - ), - ) - .subcommand( - SubCommand::with_name("transfer") - .about("Transfer a key/value pair from one account to another") - .arg( - Arg::with_name("from-owner") - .display_order(1) - .long("from-owner") - .short("f") - .required(true) - .takes_value(true) - .help("Owner to transfer from") - .possible_values(&["User1", "User2"]), - ) - .arg( - Arg::with_name("to-owner") - .display_order(2) - .long("to-owner") - .short("t") - .required(true) - .takes_value(true) - .help("Owner to transfer to") - .possible_values(&["User1", "User2"]), - ) - .arg( - Arg::with_name("key") - .display_order(3) - .long("key") - .short("k") - .required(true) - .takes_value(true) - .help("The key of key/value pair to transfer"), - ), - ) - .subcommand( - SubCommand::with_name("burn") - .about("Burn (delete) a key/value pair from an account") - .arg( - Arg::with_name("from-owner") - .display_order(1) - .long("from-owner") - .short("f") - .required(true) - .takes_value(true) - .help("Owner to burn key/value from") - .possible_values(&["User1", "User2"]), - ) - .arg( - Arg::with_name("key") - .display_order(2) - .long("key") - .short("k") - .required(true) - .takes_value(true) - .help("The key of key/value pair to burn"), - ), - ) - .subcommand(SubCommand::with_name("ping").about("Send a ping transaction")) - .subcommand( + ) .subcommand( SubCommand::with_name("examples") .about("Show usage examples for OSVM CLI commands") .arg( @@ -246,6 +148,7 @@ pub fn parse_command_line() -> ArgMatches<'static> { ) ) ) + // Node management commands .subcommand( SubCommand::with_name("nodes") .about("Manage validator and RPC nodes") @@ -413,8 +316,16 @@ pub fn parse_command_line() -> ArgMatches<'static> { Arg::with_name("host") .long("host") .value_name("HOST") + .required(true) + .takes_value(true) + .help("Remote host to deploy on (format: user@host[:port])") + ) + .arg( + Arg::with_name("name") + .long("name") + .value_name("NAME") .takes_value(true) - .help("Remote host to deploy on (format: user@host)") + .help("Custom name for the node (default: auto-generated)") ) ) ) diff --git a/src/lib.rs b/src/lib.rs index 59ff22e3..fd90ad6d 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,10 +1,23 @@ -pub mod utils; +//! OSVM CLI Library +//! +//! This library provides the core functionality for the OSVM CLI. +//! It includes utilities for managing SVMs, nodes, and SSH deployments. +//! +//! # Features +//! +//! - SVM Management: List, get details, and install SVMs +//! - Node Management: Deploy, monitor, and control validator and RPC nodes +//! - SSH Deployment: Deploy SVMs and nodes to remote hosts +//! - Interactive Dashboards: Monitor SVMs and nodes in real-time +//! +//! # Architecture +//! +//! The OSVM CLI is organized into several modules: +//! +//! - `utils`: Core utilities for SVM and node management +//! - `clparse`: Command-line parsing and argument definitions +//! - `main`: Main entry point and command handlers -/// Exports key capabilities -pub mod prelude { - pub use crate::utils::{ - account_state::*, - keys_db::{KEYS_DB, PROG_KEY}, - txn_utils::*, - }; -} +pub mod utils; +pub mod clparse; +pub mod prelude; \ No newline at end of file diff --git a/src/main.rs b/src/main.rs index d121bee2..2b0eeeb7 100644 --- a/src/main.rs +++ b/src/main.rs @@ -560,14 +560,11 @@ async fn main() -> Result<(), Box> { additional_params: std::collections::HashMap::new(), }; - if let Err(e) = ssh_deploy::deploy_svm_node(connection, deploy_config, None).await { - eprintln!("Deployment error: {}", e); - exit(1); - } + ssh_deploy::deploy_svm_node(connection, deploy_config, None).await + .context("Deployment failed")?; } (cmd, _) => { - eprintln!("Unknown command: {}", cmd); - exit(1); + return Err(anyhow::anyhow!("Unknown command: {}", cmd)); } }; diff --git a/src/prelude.rs b/src/prelude.rs index 294e935e..24415876 100644 --- a/src/prelude.rs +++ b/src/prelude.rs @@ -1,10 +1,19 @@ //! Exports key capabilities in a single module for convenient use +//! +//! This module re-exports the key modules from the utils directory +//! to make them easily accessible throughout the codebase. pub use crate::utils::{ - account_state::unpack_account_data, - keys_db::{KEYS_DB, PROG_KEY}, - txn_utils::{ - burn_instruction, load_account, load_wallet, mint_transaction, ping_instruction, - transfer_instruction, - }, + /// SVM information and management utilities + svm_info, + /// SSH deployment utilities + ssh_deploy, + /// Node management utilities + nodes, + /// Dashboard utilities + dashboard, + /// Example command utilities + examples, + /// Color formatting utilities + color, }; \ No newline at end of file diff --git a/src/utils/account_state.rs b/src/utils/account_state.rs deleted file mode 100644 index d2451a54..00000000 --- a/src/utils/account_state.rs +++ /dev/null @@ -1,26 +0,0 @@ -//! @brief Account state access - -use { - crate::utils::txn_utils::get_account_for, - solana_client::rpc_client::RpcClient, - solana_sdk::{commitment_config::CommitmentConfig, signature::Keypair, signer::Signer}, - std::{collections::BTreeMap, error::Error}, -}; - -/// Unpacks token state for the accumulator -pub fn unpack_account_data( - rpc_client: &RpcClient, - account: &Keypair, - commitment_config: CommitmentConfig, -) -> Result<(bool, BTreeMap), Box> { - match get_account_for(rpc_client, &account.pubkey(), commitment_config) { - Some(_account) => { - // Implement custom unpacking logic here instead of using sol_template_shared - Ok((false, BTreeMap::new())) - }, - None => Err(Box::::from(format!( - "account not found for \"{:?}\". ", - account - ))), - } -} diff --git a/src/utils/examples.rs b/src/utils/examples.rs index 43505764..b4148b00 100644 --- a/src/utils/examples.rs +++ b/src/utils/examples.rs @@ -62,18 +62,6 @@ pub struct Example { pub fn get_all_examples() -> Vec { vec![ // Basic Commands - Example { - title: "Check your balance", - command: "osvm balance", - explanation: "Displays the SOL balance of your default keypair", - category: ExampleCategory::Basic, - }, - Example { - title: "Check a specific address balance", - command: "osvm balance 5vXNUCLCvfBxLrZcgJnSury7KxZ6niwg3gdMrys4a6Uh", - explanation: "Displays the SOL balance of the specified wallet address", - category: ExampleCategory::Basic, - }, Example { title: "Run with increased verbosity", command: "osvm -v svm list", @@ -82,10 +70,16 @@ pub fn get_all_examples() -> Vec { }, Example { title: "Specify a different RPC URL", - command: "osvm --url https://api.mainnet-beta.solana.com balance", + command: "osvm --url https://api.mainnet-beta.solana.com svm list", explanation: "Run a command using a specific Solana RPC endpoint", category: ExampleCategory::Basic, }, + Example { + title: "Disable colored output", + command: "osvm --no-color svm list", + explanation: "Run a command with colored output disabled", + category: ExampleCategory::Basic, + }, // SVM Management Example { diff --git a/src/utils/keys_db.rs b/src/utils/keys_db.rs deleted file mode 100644 index 69e31299..00000000 --- a/src/utils/keys_db.rs +++ /dev/null @@ -1,241 +0,0 @@ -//! keys encapsulates key management -//! -//! Processes the keys in the `keys/accounts` folder by -//! 1. Read the keys_db.yml file -//! 2. Faults in keys from file system as needed - -use { - super::load_keys_config_file, - lazy_static::lazy_static, - serde::{Deserialize, Serialize}, - solana_sdk::signature::{read_keypair_file, Keypair}, - std::{ - collections::HashMap, - error, fs, - path::{Path, PathBuf}, - }, -}; - -/// The base folder for the keys DB -const KEYS_DB_CONFIG_PATH: &str = "keys"; -/// The configuration file name for the keys in the DB -const KEYS_DB_CONFIG_FILE_NAME: &str = "keys_db.yml"; -/// Standardized wallet key name -const WALLET: &str = "wallet"; -/// Standardized account key name -const ACCOUNT: &str = "account"; -/// The base folder for the program -const KEY_PROGRAM_PATH: &str = "program"; -/// Our fee receiving account owner -const SERVICE_OWNER: &str = "Service"; - -// Initialize the pathbuf for the path/filename of keys db configuration -lazy_static! { - static ref KEYS_CONFIG_PATH: PathBuf = { - let path = Path::new(KEYS_DB_CONFIG_PATH); - path.join(KEYS_DB_CONFIG_FILE_NAME) - }; -} - -// Initialize the yaml keys hashmap -lazy_static! { - static ref KEYS_YAML_DB: KeysYamlDB = KeysYamlDB::load(); -} - -// Initialize the pathbuf for the path/subpath for the program key -lazy_static! { - static ref PROGRAM_KEY_PATH: PathBuf = { - let path = Path::new(KEYS_DB_CONFIG_PATH); - path.join(KEY_PROGRAM_PATH) - }; -} - -// Initialize the Program Key -lazy_static! { - pub static ref PROG_KEY: Keypair = load_program_key(); -} - -/// Load the programs key for deployment or usage in transactions -fn load_program_key() -> Keypair { - if PROGRAM_KEY_PATH.is_dir() { - let mut fpath = fs::read_dir(PROGRAM_KEY_PATH.as_path()).unwrap(); - let entry = fpath.next().unwrap().unwrap(); - match read_keypair_file(entry.path()) { - Ok(f) => f, - Err(_) => panic!(), - } - } else { - panic!() - } -} - -#[derive(Debug, PartialEq, Eq, Serialize, Deserialize)] -/// Encapsulates the users and keypair keypaths, internal only -struct KeysYamlDB { - version: String, - registry: HashMap>, // Maps from friendly "User" down to keypair paths -} - -impl KeysYamlDB { - fn load() -> Self { - match load_keys_config_file(KEYS_CONFIG_PATH.as_path()) { - Ok(t) => t, - Err(_) => { - eprintln!( - "{:?} not found during keys_db configuration load", - *KEYS_CONFIG_PATH - ); - panic!() - } - } - } - - /// Returns the configuration file map - pub fn registry(&self) -> &HashMap> { - &self.registry - } - - /// Returns the configuration file version - #[allow(dead_code)] - pub fn version(&self) -> &String { - &self.version - } -} - -// Initialize the keypairs hashmap -lazy_static! { - pub static ref KEYS_DB: KeysDB = KeysDB::load(); -} - -#[derive(Debug)] -/// Encapsulates the users and keypairs -pub struct KeysDB { - keys_registry: HashMap>, -} - -impl KeysDB { - /// Load a account file into a Keypair - #[allow(dead_code)] - fn load_keypair(path: &Path) -> Result> { - match read_keypair_file(&path) { - Err(e) => Err(std::io::Error::new( - std::io::ErrorKind::Other, - format!("could not read keypair file \"{}\". Run \"solana-keygen new\" to create a keypair file: {}", - path.display(), e), - ) - .into()), - Ok(kp) => Ok(kp), - } - } - - /// Loads the KEYS_DB keypairs from the configuration - #[allow(dead_code)] - fn load() -> Self { - let mut keys_reg = HashMap::>::new(); - for accounts in KEYS_YAML_DB.registry().iter() { - let mut keys_hm = HashMap::::new(); - keys_hm.insert( - WALLET.to_string().to_string(), - Self::load_keypair(accounts.1.get(WALLET).unwrap()).unwrap(), - ); - keys_hm.insert( - ACCOUNT.to_string().to_string(), - Self::load_keypair(accounts.1.get(ACCOUNT).unwrap()).unwrap(), - ); - keys_reg.insert(accounts.0.clone(), keys_hm); - } - KeysDB { - keys_registry: keys_reg, - } - } - /// Fetch a reference to the registry of keypairs - pub fn keys_registry(&self) -> &HashMap> { - &self.keys_registry - } - /// Returns a vector of key owners - pub fn key_owners(&self) -> Vec { - let mut result = Vec::::new(); - for x in self.keys_registry.keys() { - result.push(x.to_string()); - } - result - } - /// Returns non service account owner names - pub fn non_service_key_owners(&self) -> Vec { - let mut all_owners = self.key_owners(); - all_owners.remove( - all_owners - .iter() - .position(|x| *x == SERVICE_OWNER) - .expect("needle not found"), - ); - all_owners - } - /// Get a wallet and account keypair for name - pub fn wallet_and_account( - &self, - name: String, - ) -> Result<(&Keypair, &Keypair), Box> { - match self.keys_registry.contains_key(&name) { - true => { - let owner = self.keys_registry.get(&name).unwrap(); - Ok((owner.get(WALLET).unwrap(), owner.get(ACCOUNT).unwrap())) - } - false => Err(Box::::from(format!( - "could not find owner \"{}\". key in DB", - name - ))), - } - } -} - -#[cfg(test)] -mod tests { - use solana_sdk::signer::Signer; - - use super::*; - - #[test] - fn test_program_key() { - println!("{}", PROG_KEY.pubkey()); - } - #[test] - fn test_keys_config_db_load() { - assert_eq!("1.5.0", KEYS_YAML_DB.version()); - } - - #[test] - fn test_keys_keypair_load() { - assert!(KEYS_DB.keys_registry().contains_key(SERVICE_OWNER)); - assert!(KEYS_DB.keys_registry().contains_key("User1")); - assert!(KEYS_DB.keys_registry().contains_key("User2")); - if let Some(user) = KEYS_DB.keys_registry().get(SERVICE_OWNER) { - assert!(user.contains_key(WALLET)); - assert!(user.contains_key(ACCOUNT)); - } - if let Some(user) = KEYS_DB.keys_registry().get("User1") { - assert!(user.contains_key(WALLET)); - assert!(user.contains_key(ACCOUNT)); - } - if let Some(user) = KEYS_DB.keys_registry().get("User2") { - assert!(user.contains_key(WALLET)); - assert!(user.contains_key(ACCOUNT)); - } - } - - #[test] - fn test_list_key_holders() { - let key_owners = KEYS_DB.key_owners(); - assert!(key_owners.contains(&"Service".to_string())); - assert!(key_owners.contains(&"User1".to_string())); - assert!(key_owners.contains(&"User2".to_string())); - } - - #[test] - fn test_non_service_key_holders() { - let key_owners = KEYS_DB.non_service_key_owners(); - assert!(!key_owners.contains(&"Service".to_string())); - assert!(key_owners.contains(&"User1".to_string())); - assert!(key_owners.contains(&"User2".to_string())); - } -} diff --git a/src/utils/mod.rs b/src/utils/mod.rs index f5f85e3f..32031a1d 100644 --- a/src/utils/mod.rs +++ b/src/utils/mod.rs @@ -1,22 +1,55 @@ -//! Module exports for utility modules +//! Utility modules for the OSVM CLI +//! +//! This directory contains various utility modules that provide the core functionality +//! for the OSVM CLI, including SVM and node management, SSH deployment, and UI components. use std::{fs::File, io, path::Path}; +use serde::de::DeserializeOwned; -pub mod account_state; +// UI and display utilities +/// Color formatting utilities for terminal output pub mod color; -pub mod keys_db; +/// Example command utilities for displaying usage examples pub mod examples; +/// Dashboard utilities for interactive SVM monitoring +pub mod dashboard; +/// Node dashboard utilities for interactive node monitoring +pub mod nodes_dashboard; + +// Core functionality +/// SVM information and management utilities pub mod svm_info; +/// SSH deployment utilities for remote node deployment pub mod ssh_deploy; -pub mod txn_utils; -pub mod dashboard; +/// Node management utilities for monitoring and controlling nodes pub mod nodes; -pub mod nodes_dashboard; -/// Loads a yaml file +/// Loads a YAML configuration file and deserializes it into the specified type +/// +/// # Arguments +/// +/// * `config_file` - Path to the YAML configuration file +/// +/// # Returns +/// +/// * `Result` - The deserialized configuration or an error +/// +/// # Examples +/// +/// ``` +/// use osvm::utils::load_keys_config_file; +/// use serde::Deserialize; +/// +/// #[derive(Deserialize)] +/// struct Config { +/// key: String, +/// } +/// +/// let config: Config = load_keys_config_file("config.yml").unwrap(); +/// ``` pub fn load_keys_config_file(config_file: P) -> Result where - T: serde::de::DeserializeOwned, + T: DeserializeOwned, P: AsRef, { let file = File::open(config_file)?; diff --git a/src/utils/nodes.rs b/src/utils/nodes.rs index aea8c556..4696f545 100644 --- a/src/utils/nodes.rs +++ b/src/utils/nodes.rs @@ -968,7 +968,6 @@ pub fn get_node_info( /// Get node logs /// /// # Arguments -/// * `client` - RPC client /// * `node_id` - Node ID /// * `lines` - Number of lines to show /// * `follow` - Whether to follow the logs @@ -980,8 +979,8 @@ pub fn get_node_logs( lines: usize, follow: bool ) -> Result<(), Box> { - let db = NodeDatabase::load()?; - let node = db.get_node(node_id)?; + let db = NodeDatabase::load().map_err(|e| anyhow::anyhow!("Failed to load node database: {}", e))?; + let node = db.get_node(node_id).map_err(|e| anyhow::anyhow!("Failed to get node {}: {}", node_id, e))?; // Create SSH client let server_config = ServerConfig { @@ -998,12 +997,15 @@ pub fn get_node_logs( let service_name = format!("{}-{}-{}", node.svm_type, node.node_type, node.network); // Create runtime for executing SSH client - let rt = Runtime::new()?; + let rt = Runtime::new().map_err(|e| anyhow::anyhow!("Failed to create async runtime: {}", e))?; rt.block_on(async { use crate::utils::ssh_deploy::SshClient; - let mut client = SshClient::new(server_config.clone())?; - client.connect()?; + let mut client = SshClient::new(server_config.clone()) + .map_err(|e| anyhow::anyhow!("Failed to create SSH client: {}", e))?; + + client.connect() + .map_err(|e| anyhow::anyhow!("Failed to connect to {}: {}", node.host, e))?; let command = if follow { println!("Note: Log streaming is not fully supported. Displaying current logs..."); @@ -1017,14 +1019,16 @@ pub fn get_node_logs( client.stream_command(&command, |line| { println!("{}", line); true // Continue streaming - })?; + }) + .map_err(|e| anyhow::anyhow!("Failed to stream logs: {}", e))?; } else { // For non-follow mode, just execute and print - let logs = client.execute_command(&command)?; + let logs = client.execute_command(&command) + .map_err(|e| anyhow::anyhow!("Failed to execute log command: {}", e))?; println!("{}", logs); } - Ok::<_, Box>(()) + Ok::<_, anyhow::Error>(()) })?; Ok(()) @@ -1114,13 +1118,22 @@ trait SshClientExt { } impl SshClientExt for crate::utils::ssh_deploy::SshClient { - fn stream_command(&mut self, command: &str, callback: F) -> Result<(), Box> + fn stream_command(&mut self, command: &str, mut callback: F) -> Result<(), Box> where F: FnMut(&str) -> bool, { - // Execute the command and process all output at once as a fallback + // First try to execute the command let output = self.execute_command(command)?; - output.lines().all(callback); + + // Process each line of the output + let mut continue_processing = true; + for line in output.lines() { + continue_processing = callback(line); + if !continue_processing { + break; + } + } + Ok(()) } } \ No newline at end of file diff --git a/src/utils/ssh_deploy.rs b/src/utils/ssh_deploy.rs index 6a437798..79c9e865 100644 --- a/src/utils/ssh_deploy.rs +++ b/src/utils/ssh_deploy.rs @@ -648,46 +648,52 @@ fn validate_system_requirements( // Check CPU cores let cpu_cores = system_info.get("cpu_cores") - .and_then(|s| s.parse::().ok()) - .unwrap_or(0); - + .ok_or_else(|| DeploymentError::ValidationError("CPU cores information not available".to_string()))? + .parse::() + .map_err(|e| DeploymentError::ValidationError(format!("Invalid CPU cores value: {}", e)))?; + if cpu_cores < required_cpu { return Err(DeploymentError::ValidationError(format!( "Insufficient CPU cores: {} (required: {})", cpu_cores, required_cpu ))); } - + // Check memory let memory_gb = system_info.get("memory_gb") - .and_then(|s| s.parse::().ok()) - .unwrap_or(0); - + .ok_or_else(|| DeploymentError::ValidationError("Memory information not available".to_string()))? + .parse::() + .map_err(|e| DeploymentError::ValidationError(format!("Invalid memory value: {}", e)))?; + if memory_gb < required_memory { return Err(DeploymentError::ValidationError(format!( "Insufficient memory: {} GB (required: {} GB)", memory_gb, required_memory ))); } - + // Check available disk space let available_disk = system_info.get("disk_available") - .and_then(|s| { - // Parse disk space - handle different units (G, T) - if s.ends_with('G') { - s[..s.len()-1].parse::().ok().map(|v| v as u16) - } else if s.ends_with('T') { - s[..s.len()-1].parse::().ok().map(|v| (v * 1024.0) as u16) - } else { - None - } - }) - .unwrap_or(0); - - if available_disk < required_disk { + .ok_or_else(|| DeploymentError::ValidationError("Disk space information not available".to_string()))?; + + // Parse disk space - handle different units (G, T) + let available_disk_gb = if available_disk.ends_with('G') { + available_disk[..available_disk.len()-1].parse::() + .map_err(|e| DeploymentError::ValidationError(format!("Invalid disk space value: {}", e)))? + } else if available_disk.ends_with('T') { + available_disk[..available_disk.len()-1].parse::() + .map_err(|e| DeploymentError::ValidationError(format!("Invalid disk space value: {}", e)))? + * 1024.0 + } else { + return Err(DeploymentError::ValidationError(format!( + "Unrecognized disk space unit: {}", available_disk + ))); + }; + + if available_disk_gb as u16 < required_disk { return Err(DeploymentError::ValidationError(format!( "Insufficient disk space: {} GB (required: {} GB)", - available_disk, required_disk + available_disk_gb, required_disk ))); } diff --git a/src/utils/txn_utils.rs b/src/utils/txn_utils.rs deleted file mode 100644 index 98f723ad..00000000 --- a/src/utils/txn_utils.rs +++ /dev/null @@ -1,252 +0,0 @@ -//! @brief Transaction utilities - -use { - crate::utils::keys_db::PROG_KEY, - solana_client::rpc_client::RpcClient, - solana_sdk::{ - account::Account, - commitment_config::CommitmentConfig, - instruction::{AccountMeta, Instruction}, - message::Message, - pubkey::Pubkey, - signature::{Keypair, Signature}, - signer::Signer, - system_instruction, - transaction::Transaction, - }, -}; - -/// Checks for existence of account -fn account_for_key( - rpc_client: &RpcClient, - key: &Pubkey, - commitment_config: CommitmentConfig, -) -> Option { - rpc_client - .get_account_with_commitment(key, commitment_config) - .unwrap() - .value -} - -/// Gets the account from the ledger -pub fn get_account_for( - rpc_client: &RpcClient, - account: &Pubkey, - commitment_config: CommitmentConfig, -) -> Option { - account_for_key(rpc_client, account, commitment_config) -} - -/// Fund a wallet by transferring rent-free amount from core account -fn fund_wallet( - rpc_client: &RpcClient, - wallet_signer: &dyn Signer, - signer: &dyn Signer, - commitment_config: CommitmentConfig, -) -> Result<(), Box> { - let recent_blockhash = rpc_client - .get_latest_blockhash() - .map_err(|err| format!("error: unable to get recent blockhash: {}", err)) - .unwrap(); - - let mut transaction = Transaction::new_unsigned(Message::new( - &[system_instruction::transfer( - &signer.pubkey(), - &wallet_signer.pubkey(), - 50_000_000, - )], - Some(&signer.pubkey()), - )); - - transaction - .try_sign(&vec![signer], recent_blockhash) - .map_err(|err| format!("error: failed to sign transaction: {}", err)) - .unwrap(); - let _signature = rpc_client - .send_and_confirm_transaction_with_spinner_and_commitment(&transaction, commitment_config) - .map_err(|err| format!("error: send transaction: {}", err)) - .unwrap(); - let _account = rpc_client - .get_account_with_commitment(&wallet_signer.pubkey(), commitment_config) - .unwrap() - .value - .unwrap(); - Ok(()) -} - -/// Load wallet and, if needed, fund it -pub fn load_wallet( - rpc_client: &RpcClient, - wallet_keypair: &Keypair, - signer: &dyn Signer, - commitment_config: CommitmentConfig, -) -> Result<(), Box> { - if account_for_key(rpc_client, &wallet_keypair.pubkey(), commitment_config).is_some() { - } else { - fund_wallet(rpc_client, wallet_keypair, signer, commitment_config)?; - }; - Ok(()) -} - -/// Create a new program account with account state data allocation -fn new_account( - rpc_client: &RpcClient, - wallet_signer: &dyn Signer, - account_pair: &dyn Signer, - program_owner: &Pubkey, - state_space: u64, - commitment_config: CommitmentConfig, -) -> Result<(), Box> { - let account_lamports = rpc_client - .get_minimum_balance_for_rent_exemption(state_space as usize) - .unwrap(); - - let mut transaction = Transaction::new_with_payer( - &[ - system_instruction::create_account( - &wallet_signer.pubkey(), - &account_pair.pubkey(), - account_lamports, - state_space, - program_owner, - ), - Instruction::new_with_borsh( - *program_owner, - &"initialize_account", // Replace ProgramInstruction with string command - vec![ - AccountMeta::new(account_pair.pubkey(), false), - AccountMeta::new(wallet_signer.pubkey(), false), - ], - ), - ], - Some(&wallet_signer.pubkey()), - ); - - let recent_blockhash = rpc_client - .get_latest_blockhash() - .map_err(|err| format!("error: unable to get recent blockhash: {}", err)) - .unwrap(); - transaction - .try_sign(&vec![wallet_signer, account_pair], recent_blockhash) - .map_err(|err| format!("error: failed to sign transaction: {}", err)) - .unwrap(); - let _signature = rpc_client - .send_and_confirm_transaction_with_spinner_and_commitment(&transaction, commitment_config) - .map_err(|err| format!("error: send transaction: {}", err)) - .unwrap(); - let _account = rpc_client - .get_account_with_commitment(&account_pair.pubkey(), commitment_config) - .map_err(|err| format!("error: getting account after initialization: {}", err)) - .unwrap(); - - Ok(()) -} - -/// Load account with size -pub fn load_account( - rpc_client: &RpcClient, - account_pair: &Keypair, - wallet_signer: &dyn Signer, - program_owner: &Pubkey, - space: u64, - commitment_config: CommitmentConfig, -) -> Result<(), Box> { - match get_account_for(rpc_client, &account_pair.pubkey(), commitment_config) { - Some(_) => {} - None => new_account( - rpc_client, - wallet_signer, - account_pair, - program_owner, - space, - commitment_config, - ) - .unwrap(), - }; - Ok(()) -} - -/// Submits the program instruction as per the -/// instruction definition -pub fn submit_transaction( - rpc_client: &RpcClient, - wallet_signer: &dyn Signer, - instruction: Instruction, - commitment_config: CommitmentConfig, -) -> Result> { - let mut transaction = - Transaction::new_unsigned(Message::new(&[instruction], Some(&wallet_signer.pubkey()))); - let recent_blockhash = rpc_client - .get_latest_blockhash() - .map_err(|err| format!("error: unable to get recent blockhash: {}", err))?; - transaction - .try_sign(&vec![wallet_signer], recent_blockhash) - .map_err(|err| format!("error: failed to sign transaction: {}", err))?; - let signature = rpc_client - .send_and_confirm_transaction_with_spinner_and_commitment(&transaction, commitment_config) - .map_err(|err| format!("error: send transaction: {}", err))?; - Ok(signature) -} - -/// Perform a mint transaction consisting of a key/value pair -pub fn mint_transaction( - rpc_client: &RpcClient, - accounts: &[AccountMeta], - wallet_signer: &dyn Signer, - mint_key: &str, - mint_value: &str, - commitment_config: CommitmentConfig, -) -> Result> { - let instruction = Instruction::new_with_borsh( - PROG_KEY.pubkey(), - &format!("mint_to_account:{}:{}", mint_key, mint_value), // Replace ProgramInstruction with string command - accounts.to_vec(), - ); - submit_transaction(rpc_client, wallet_signer, instruction, commitment_config) -} - -/// Transfer a minted key/value from one account to another account -pub fn transfer_instruction( - rpc_client: &RpcClient, - accounts: &[AccountMeta], - wallet_signer: &dyn Signer, - transfer_key: &str, - commitment_config: CommitmentConfig, -) -> Result> { - let instruction = Instruction::new_with_borsh( - PROG_KEY.pubkey(), - &format!("transfer_between_accounts:{}", transfer_key), // Replace ProgramInstruction with string command - accounts.to_vec(), - ); - submit_transaction(rpc_client, wallet_signer, instruction, commitment_config) -} - -/// Burn, delete, the key/value from the owning account -pub fn burn_instruction( - rpc_client: &RpcClient, - accounts: &[AccountMeta], - wallet_signer: &dyn Signer, - burn_key: &str, - commitment_config: CommitmentConfig, -) -> Result> { - let instruction = Instruction::new_with_borsh( - PROG_KEY.pubkey(), - &format!("burn_from_account:{}", burn_key), // Replace ProgramInstruction with string command - accounts.to_vec(), - ); - submit_transaction(rpc_client, wallet_signer, instruction, commitment_config) -} - -pub fn ping_instruction( - rpc_client: &RpcClient, - signer: &dyn Signer, - commitment_config: CommitmentConfig, -) -> Result> { - let amount = 0; - submit_transaction( - rpc_client, - signer, - system_instruction::transfer(&signer.pubkey(), &signer.pubkey(), amount), - commitment_config, - ) -} diff --git a/tests/README.md b/tests/README.md new file mode 100644 index 00000000..5869c37f --- /dev/null +++ b/tests/README.md @@ -0,0 +1,67 @@ +# OSVM CLI Tests + +This directory contains tests for the OSVM CLI application. + +## End-to-End (E2E) Tests + +The `e2e` directory contains end-to-end tests that verify the CLI functionality by running commands and checking their output. These tests simulate real user interactions with the CLI. + +### Test Structure + +- `common.rs`: Common utilities for e2e tests, including functions to run commands and check output +- `svm_tests.rs`: Tests for SVM-related commands +- `node_tests.rs`: Tests for node-related commands + +### Running the Tests + +To run all tests: + +```bash +cargo test +``` + +To run only e2e tests: + +```bash +cargo test --test main +``` + +To run a specific test: + +```bash +cargo test --test main test_svm_list +``` + +### Test Coverage + +The e2e tests cover the following functionality: + +#### SVM Commands +- `svm list`: List all available SVMs +- `svm get`: Get detailed information about a specific SVM +- `svm dashboard`: Launch the SVM dashboard + +#### Node Commands +- `nodes list`: List all managed nodes +- `nodes list` with filters: Test filtering by network, type, and status +- `nodes dashboard`: Launch the node dashboard +- `nodes get`: Get detailed information about a specific node + +#### General CLI Features +- Custom configuration file +- Custom RPC URL +- Verbose output +- No-color mode +- Help command + +### Mock Server + +The tests use a mock server to simulate API responses for testing without requiring a real network connection. This allows the tests to run in any environment without external dependencies. + +### Test Dependencies + +- `assert_cmd`: For running commands and making assertions about their output +- `predicates`: For making assertions about command output +- `tempfile`: For creating temporary directories and files +- `serial_test`: For running tests serially to avoid conflicts +- `mockito`: For creating a mock server to simulate API responses \ No newline at end of file diff --git a/tests/e2e/README.md b/tests/e2e/README.md new file mode 100644 index 00000000..f9dee268 --- /dev/null +++ b/tests/e2e/README.md @@ -0,0 +1,156 @@ +# End-to-End (E2E) Tests for OSVM CLI + +This directory contains end-to-end tests for the OSVM CLI application. These tests verify the CLI functionality by running commands and checking their output, simulating real user interactions. + +## Test Structure + +- `mod.rs`: Main module file that includes all test modules +- `common.rs`: Common utilities for e2e tests, including functions to run commands and check output +- `svm_tests.rs`: Tests for SVM-related commands +- `node_tests.rs`: Tests for node-related commands +- `examples.rs`: Example tests demonstrating how to write e2e tests + +## Test Utilities + +The `common.rs` file provides several utilities for writing e2e tests: + +- `run_osvm_command()`: Returns a Command object for running OSVM commands +- `run_osvm_command_string()`: Runs an OSVM command and returns the output as a string +- `output_contains()`: Checks if the output contains the expected text +- `create_temp_dir()`: Creates a temporary directory for test files +- `create_mock_config()`: Creates a mock config file in the given directory +- `MockServer`: A mock server for testing SSH deployment and API responses + +## Writing Tests + +### Basic Test Structure + +```rust +#[test] +#[serial] // Run tests serially to avoid conflicts +fn test_name() { + // Run a command and get the output + let output = run_osvm_command_string(&["command", "arg1", "arg2"]); + + // Check if the output contains expected text + assert!(output_contains(&output, "Expected text")); +} +``` + +### Using assert_cmd for More Complex Assertions + +```rust +#[test] +#[serial] +fn test_with_assert_cmd() { + // Use assert_cmd to run a command and make assertions about the output + let assert = run_osvm_command() + .args(&["command", "arg1", "arg2"]) + .assert(); + + // Make assertions about the command output + assert.success() + .stdout(predicate::str::contains("Expected text")) + .stderr(predicate::str::contains("Error").not()); +} +``` + +### Using a Mock Server + +```rust +#[test] +#[serial] +fn test_with_mock_server() { + // Create a mock server + let mock_server = MockServer::new(); + + // Set up a mock endpoint + let _mock = mock_server.mock_svm_list(); + + // Run a command that uses the mock server + let output = run_osvm_command_string(&["--url", &format!("http://{}", mock_server.server.host_with_port()), "svm", "list"]); + + // Check if the output contains expected text + assert!(output_contains(&output, "Expected text")); +} +``` + +### Using a Custom Config File + +```rust +#[test] +#[serial] +fn test_with_custom_config() { + // Create a temporary directory and config file + let temp_dir = create_temp_dir(); + let config_path = create_mock_config(&temp_dir); + + // Run a command with the custom config file + let output = run_osvm_command_string(&["-C", config_path.to_str().unwrap(), "svm", "list"]); + + // Check if the output contains expected text + assert!(output_contains(&output, "Expected text")); +} +``` + +## Running Tests + +To run all e2e tests: + +```bash +cargo test --test main +``` + +To run a specific test: + +```bash +cargo test --test main test_svm_list +``` + +To run tests with verbose output: + +```bash +cargo test --test main -- --nocapture +``` + +## Test Coverage + +The e2e tests cover the following functionality: + +### SVM Commands +- `svm list`: List all available SVMs +- `svm get`: Get detailed information about a specific SVM +- `svm dashboard`: Launch the SVM dashboard + +### Node Commands +- `nodes list`: List all managed nodes +- `nodes list` with filters: Test filtering by network, type, and status +- `nodes dashboard`: Launch the node dashboard +- `nodes get`: Get detailed information about a specific node + +### General CLI Features +- Custom configuration file +- Custom RPC URL +- Verbose output +- No-color mode +- Help command + +## Adding New Tests + +To add a new test: + +1. Decide which module the test belongs to (svm_tests, node_tests, or create a new one) +2. Add a new test function with the `#[test]` and `#[serial]` attributes +3. Use the utilities from `common.rs` to run commands and check output +4. Add assertions to verify the expected behavior + +Example: + +```rust +#[test] +#[serial] +fn test_new_command() { + let output = run_osvm_command_string(&["new", "command"]); + assert!(output_contains(&output, "Expected output")); +} +``` \ No newline at end of file diff --git a/tests/e2e/common.rs b/tests/e2e/common.rs new file mode 100644 index 00000000..c98ad57b --- /dev/null +++ b/tests/e2e/common.rs @@ -0,0 +1,98 @@ +//! Common utilities for e2e tests + +use std::process::Command; +use std::path::PathBuf; +use std::env; +use assert_cmd::prelude::*; +use predicates::prelude::*; +use tempfile::TempDir; +use mockito::Server; + +/// Path to the osvm binary +pub fn osvm_bin_path() -> PathBuf { + // In a real environment, this would be the path to the installed binary + // For testing, we'll use the debug build in the target directory + let mut path = env::current_dir().unwrap(); + path.push("target"); + path.push("debug"); + path.push("osvm"); + path +} + +/// Run an OSVM command and return the command for further assertions +pub fn run_osvm_command() -> Command { + Command::cargo_bin("osvm").expect("Failed to find osvm binary") +} + +/// Run an OSVM command with arguments and return the output as a string +pub fn run_osvm_command_string(args: &[&str]) -> String { + let output = run_osvm_command() + .args(args) + .output() + .expect("Failed to execute osvm command"); + + String::from_utf8_lossy(&output.stdout).to_string() +} + +/// Check if the output contains the expected text +pub fn output_contains(output: &str, expected: &str) -> bool { + output.contains(expected) +} + +/// Create a temporary directory for test files +pub fn create_temp_dir() -> TempDir { + TempDir::new().expect("Failed to create temporary directory") +} + +/// Create a mock config file in the given directory +pub fn create_mock_config(dir: &TempDir) -> PathBuf { + let config_path = dir.path().join("config.yml"); + std::fs::write(&config_path, "json_rpc_url: http://localhost:8899\nkeypair_path: ~/.config/osvm/id.json\n").expect("Failed to write config file"); + config_path +} + +/// Mock server for testing SSH deployment +pub struct MockServer { + pub server: Server, +} + +impl MockServer { + /// Create a new mock server + pub fn new() -> Self { + MockServer { + server: mockito::Server::new(), + } + } + + /// Get the connection string for the mock server + pub fn connection_string(&self) -> String { + format!("test@{}", self.server.host_with_port()) + } + + /// Mock an SVM list endpoint + pub fn mock_svm_list(&self) -> mockito::Mock { + self.server.mock("GET", "/api/svms") + .with_status(200) + .with_header("content-type", "application/json") + .with_body(r#"{"solana":{"name":"solana","display_name":"Solana"},"sonic":{"name":"sonic","display_name":"Sonic"}}"#) + .create() + } + + /// Mock an SVM get endpoint + pub fn mock_svm_get(&self, svm_name: &str) -> mockito::Mock { + self.server.mock("GET", &format!("/api/svms/{}", svm_name)) + .with_status(200) + .with_header("content-type", "application/json") + .with_body(format!(r#"{{"name":"{}","display_name":"{}","token_symbol":"TEST","token_price_usd":1.0}}"#, svm_name, svm_name.to_uppercase())) + .create() + } + + /// Mock an SVM get endpoint with 404 response + pub fn mock_svm_get_not_found(&self, svm_name: &str) -> mockito::Mock { + self.server.mock("GET", &format!("/api/svms/{}", svm_name)) + .with_status(404) + .with_header("content-type", "application/json") + .with_body(format!(r#"{{"error":"SVM not found: {}"}}"#, svm_name)) + .create() + } +} \ No newline at end of file diff --git a/tests/e2e/examples.rs b/tests/e2e/examples.rs new file mode 100644 index 00000000..3ef02516 --- /dev/null +++ b/tests/e2e/examples.rs @@ -0,0 +1,67 @@ +//! Example tests to demonstrate how to write e2e tests + +use crate::tests::e2e::common::{run_osvm_command, run_osvm_command_string, output_contains, create_temp_dir, create_mock_config, MockServer}; +use predicates::prelude::*; +use serial_test::serial; + +/// Example test that demonstrates how to test a simple command +#[test] +#[serial] +fn example_test_simple_command() { + // Run a command and get the output as a string + let output = run_osvm_command_string(&["--help"]); + + // Check if the output contains expected text + assert!(output_contains(&output, "USAGE:")); + assert!(output_contains(&output, "FLAGS:")); + assert!(output_contains(&output, "SUBCOMMANDS:")); +} + +/// Example test that demonstrates how to use assert_cmd for more complex assertions +#[test] +#[serial] +fn example_test_with_assert_cmd() { + // Use assert_cmd to run a command and make assertions about the output + let assert = run_osvm_command() + .arg("--help") + .assert(); + + // Make assertions about the command output + assert.success() + .stdout(predicate::str::contains("USAGE:")) + .stdout(predicate::str::contains("FLAGS:")) + .stdout(predicate::str::contains("SUBCOMMANDS:")); +} + +/// Example test that demonstrates how to use a mock server +#[test] +#[serial] +fn example_test_with_mock_server() { + // Create a mock server + let mock_server = MockServer::new(); + + // Set up a mock endpoint + let _mock = mock_server.mock_svm_list(); + + // Run a command that uses the mock server + let output = run_osvm_command_string(&["--url", &format!("http://{}", mock_server.server.host_with_port()), "svm", "list"]); + + // Check if the output contains expected text + assert!(output_contains(&output, "Available SVMs in the chain:") || + output_contains(&output, "NAME")); +} + +/// Example test that demonstrates how to use a custom config file +#[test] +#[serial] +fn example_test_with_custom_config() { + // Create a temporary directory and config file + let temp_dir = create_temp_dir(); + let config_path = create_mock_config(&temp_dir); + + // Run a command with the custom config file + let output = run_osvm_command_string(&["-C", config_path.to_str().unwrap(), "svm", "list"]); + + // Check if the output contains expected text + assert!(output_contains(&output, "Available SVMs in the chain:")); +} \ No newline at end of file diff --git a/tests/e2e/mod.rs b/tests/e2e/mod.rs new file mode 100644 index 00000000..00a040fb --- /dev/null +++ b/tests/e2e/mod.rs @@ -0,0 +1,8 @@ +//! End-to-end tests for the OSVM CLI +//! +//! These tests verify the CLI functionality by running commands and checking their output. + +mod svm_tests; +mod node_tests; +mod common; +mod examples; \ No newline at end of file diff --git a/tests/e2e/node_tests.rs b/tests/e2e/node_tests.rs new file mode 100644 index 00000000..3ccc32a7 --- /dev/null +++ b/tests/e2e/node_tests.rs @@ -0,0 +1,146 @@ +//! End-to-end tests for node-related commands + +use crate::tests::e2e::common::{run_osvm_command, run_osvm_command_string, output_contains, create_temp_dir, create_mock_config, MockServer}; +use predicates::prelude::*; +use serial_test::serial; + +#[test] +#[serial] +fn test_nodes_list() { + let output = run_osvm_command_string(&["nodes", "list"]); + + // Verify the output contains expected headers + assert!(output_contains(&output, "OSVM - Node Management")); + assert!(output_contains(&output, "Managed SVM Nodes:")); + + // The output might show "No nodes are currently managed" if no nodes are configured + // or it might show a list of nodes if some are configured + assert!( + output_contains(&output, "No nodes are currently managed") || + output_contains(&output, "ID") && output_contains(&output, "SVM") && output_contains(&output, "TYPE") + ); +} + +#[test] +#[serial] +fn test_nodes_list_with_filters() { + // Test with network filter + let output = run_osvm_command_string(&["nodes", "list", "--network", "mainnet"]); + assert!(output_contains(&output, "OSVM - Node Management")); + + // Test with type filter + let output = run_osvm_command_string(&["nodes", "list", "--type", "validator"]); + assert!(output_contains(&output, "OSVM - Node Management")); + + // Test with status filter + let output = run_osvm_command_string(&["nodes", "list", "--status", "running"]); + assert!(output_contains(&output, "OSVM - Node Management")); + + // Test with JSON output + let output = run_osvm_command_string(&["nodes", "list", "--json"]); + // JSON output should start with a curly brace or square bracket + assert!(output.trim().starts_with('{') || output.trim().starts_with('[') || output.trim().is_empty()); +} + +#[test] +#[serial] +fn test_nodes_dashboard() { + // This test is more complex as it involves an interactive dashboard + // For now, we'll just verify the command doesn't immediately fail with "unknown command" + let assert = run_osvm_command() + .args(&["nodes", "dashboard"]) + .assert(); + + // The dashboard might not work in a test environment, so we're just checking + // that the command is recognized + assert.stderr(predicate::str::contains("Unknown command").not()); +} + +#[test] +#[serial] +fn test_nodes_get_invalid() { + let assert = run_osvm_command() + .args(&["nodes", "get", "invalid_node_id"]) + .assert(); + + // Verify the command fails with a non-zero exit code + assert.failure() + .stderr(predicate::str::contains("Node not found").or(predicate::str::contains("Error:"))); +} + +#[test] +#[serial] +fn test_examples_command() { + // Test the examples command + let output = run_osvm_command_string(&["examples"]); + + // Verify the output contains examples + assert!(output_contains(&output, "OSVM CLI Examples") || + output_contains(&output, "Available SVMs in the chain:") || + output_contains(&output, "Basic Commands")); + + // Test examples with category filter + let output = run_osvm_command_string(&["examples", "--category", "basic"]); + assert!(output_contains(&output, "Basic Commands")); + + // Test listing categories + let output = run_osvm_command_string(&["examples", "--list-categories"]); + assert!(output_contains(&output, "Available example categories:")); +} + +#[test] +#[serial] +fn test_verbose_output() { + // Test with verbose flag + let output = run_osvm_command_string(&["--verbose", "svm", "list"]); + + // Verbose output should include JSON RPC URL or other verbose information + assert!(output_contains(&output, "JSON RPC URL:") || + output_contains(&output, "Available SVMs in the chain:")); + + // Test with very verbose flag + let output = run_osvm_command_string(&["-vv", "svm", "list"]); + + // Very verbose output should include keypair info or other very verbose information + assert!(output_contains(&output, "Using keypair:") || + output_contains(&output, "Available SVMs in the chain:")); +} + +#[test] +#[serial] +fn test_no_color_flag() { + // Test with no-color flag + let output = run_osvm_command_string(&["--no-color", "svm", "list"]); + + // Output should still contain the expected text, but without color codes + assert!(output_contains(&output, "Available SVMs in the chain:")); +} + +#[test] +#[serial] +fn test_with_custom_config() { + // Create a temporary directory and config file + let temp_dir = create_temp_dir(); + let config_path = create_mock_config(&temp_dir); + + // Run the command with the config file + let output = run_osvm_command_string(&["-C", config_path.to_str().unwrap(), "nodes", "list"]); + + // Verify the output contains expected headers + assert!(output_contains(&output, "OSVM - Node Management")); +} + +#[test] +#[serial] +fn test_help_command() { + // Test the help command + let assert = run_osvm_command() + .arg("--help") + .assert(); + + // Verify the output contains help information + assert.success() + .stdout(predicate::str::contains("USAGE:")) + .stdout(predicate::str::contains("FLAGS:")) + .stdout(predicate::str::contains("SUBCOMMANDS:")); +} \ No newline at end of file diff --git a/tests/e2e/svm_tests.rs b/tests/e2e/svm_tests.rs new file mode 100644 index 00000000..01bdef64 --- /dev/null +++ b/tests/e2e/svm_tests.rs @@ -0,0 +1,92 @@ +//! End-to-end tests for SVM-related commands + +use crate::tests::e2e::common::{run_osvm_command, run_osvm_command_string, output_contains}; + +#[test] +fn test_svm_list() { + let output = run_osvm_command_string(&["svm", "list"]); + + // Verify the output contains expected headers + assert!(output_contains(&output, "Available SVMs in the chain:")); + assert!(output_contains(&output, "NAME")); + assert!(output_contains(&output, "DISPLAY NAME")); + assert!(output_contains(&output, "TOKEN")); + + // Verify the output contains some expected SVMs + assert!(output_contains(&output, "solana")); + assert!(output_contains(&output, "sonic")); + assert!(output_contains(&output, "opensvm")); +} + +#[test] +fn test_svm_get_solana() { + let output = run_osvm_command_string(&["svm", "get", "solana"]); + + // Verify the output contains expected Solana information + assert!(output_contains(&output, "SVM Information: Solana")); + assert!(output_contains(&output, "Token: SOL")); + assert!(output_contains(&output, "Website: https://solana.com")); + + // Verify network information is present + assert!(output_contains(&output, "MAINNET Network:")); + assert!(output_contains(&output, "TESTNET Network:")); + assert!(output_contains(&output, "DEVNET Network:")); + + // Verify system requirements are present + assert!(output_contains(&output, "Validator Requirements:")); + assert!(output_contains(&output, "RPC Node Requirements:")); +} + +#[test] +fn test_svm_get_invalid() { + let output = run_osvm_command(&["svm", "get", "invalid_svm"]); + + // Verify the command fails with a non-zero exit code + assert!(!output.status.success()); + + // Verify the error message + let error = String::from_utf8_lossy(&output.stderr); + assert!(error.contains("SVM not found") || error.contains("Error:")); +} + +#[test] +#[serial] +fn test_svm_dashboard() { + // This test is more complex as it involves an interactive dashboard + // For now, we'll just verify the command doesn't immediately fail with "unknown command" + let assert = run_osvm_command() + .args(&["svm", "dashboard"]) + .assert(); + + // The dashboard might not work in a test environment, so we're just checking + // that the command is recognized + assert.stderr(predicate::str::contains("Unknown command").not()); +} + +#[test] +#[serial] +fn test_svm_with_config_file() { + // Create a temporary directory and config file + let temp_dir = create_temp_dir(); + let config_path = create_mock_config(&temp_dir); + + // Run the command with the config file + let output = run_osvm_command_string(&["-C", config_path.to_str().unwrap(), "svm", "list"]); + + // Verify the output contains expected headers + assert!(output_contains(&output, "Available SVMs in the chain:")); +} + +#[test] +#[serial] +fn test_svm_with_url() { + // Create a mock server + let mock_server = MockServer::new(); + let _mock = mock_server.mock_svm_list(); + + // Run the command with a custom URL + let output = run_osvm_command_string(&["--url", &format!("http://{}", mock_server.server.host_with_port()), "svm", "list"]); + + // Verify the output contains expected headers + assert!(output_contains(&output, "Available SVMs in the chain:")); +} \ No newline at end of file diff --git a/tests/main.rs b/tests/main.rs new file mode 100644 index 00000000..e68f9e06 --- /dev/null +++ b/tests/main.rs @@ -0,0 +1,4 @@ +//! Main test file for OSVM CLI + +// Include the test modules +mod e2e; \ No newline at end of file diff --git a/tests/mod.rs b/tests/mod.rs new file mode 100644 index 00000000..9e61aab6 --- /dev/null +++ b/tests/mod.rs @@ -0,0 +1,3 @@ +//! Test modules for OSVM CLI + +pub mod e2e; \ No newline at end of file