From eb016c7a9a1d75c6c96d35ed3e522e0e542424e8 Mon Sep 17 00:00:00 2001 From: 0x009922 Date: Mon, 4 Sep 2023 17:24:08 +0700 Subject: [PATCH] [refactor] #3833, #2373, #3601: Split off Kagami (#3841) * [refactor]: remove Swarm from Kagami; introduce refactored `iroha_swarm` Signed-off-by: Dmitry Balashov * [refactor]: remove validator from Kagami; cleanup Signed-off-by: Dmitry Balashov * [misc]: fix workspace info, introduce `wasm_builder_cli` Signed-off-by: Dmitry Balashov * [ci]: update Genesis and Swarm cmds in scripts Signed-off-by: Dmitry Balashov * [feat]: produce workable `iroha_wasm_builder_cli` Signed-off-by: Dmitry Balashov * [feat]: enhance CLI UI with spinners Signed-off-by: Dmitry Balashov * [build]: remove `iroha_wasm_builder` dep from Kagami Signed-off-by: Dmitry Balashov * [build]: tree-shake unused spinners Signed-off-by: Dmitry Balashov * [test]: fix swarm tests Signed-off-by: Dmitry Balashov * [docs]: add README for `iroha_swarm` Signed-off-by: Dmitry Balashov * [refactor]: remove `UserInterface` struct Signed-off-by: Dmitry Balashov * [feat]: add `--outfile` arg for wasm cli Signed-off-by: Dmitry Balashov * [docs]: document how to build the default validator Signed-off-by: Dmitry Balashov * [chore]: unused imports Signed-off-by: Dmitry Balashov * [docs]: link the directory to the cli Signed-off-by: Dmitry Balashov * [docs]: enhance warning about an inlined validator Signed-off-by: Dmitry Balashov * [chore]: use stdout Signed-off-by: Dmitry Balashov * [refactor]: move `wasm_builder_cli` to `./tools/` Signed-off-by: Dmitry Balashov --------- Signed-off-by: Dmitry Balashov --- Cargo.lock | 77 +- Cargo.toml | 2 + default_validator/README.md | 8 + scripts/check.sh | 15 +- tools/kagami/Cargo.toml | 15 - tools/kagami/build.rs | 8 - tools/kagami/src/genesis.rs | 109 +- tools/kagami/src/main.rs | 22 - tools/kagami/src/swarm.rs | 1669 ---------------------------- tools/kagami/src/validator.rs | 79 -- tools/swarm/Cargo.toml | 25 + tools/swarm/README.md | 44 + tools/swarm/src/cli.rs | 116 ++ tools/swarm/src/compose.rs | 756 +++++++++++++ tools/swarm/src/main.rs | 56 + tools/swarm/src/ui.rs | 44 + tools/swarm/src/util.rs | 97 ++ tools/wasm_builder_cli/Cargo.toml | 16 + tools/wasm_builder_cli/README.md | 23 + tools/wasm_builder_cli/src/main.rs | 113 ++ wasm_builder/src/lib.rs | 5 + 21 files changed, 1408 insertions(+), 1891 deletions(-) create mode 100644 default_validator/README.md delete mode 100644 tools/kagami/build.rs delete mode 100644 tools/kagami/src/swarm.rs delete mode 100644 tools/kagami/src/validator.rs create mode 100644 tools/swarm/Cargo.toml create mode 100644 tools/swarm/README.md create mode 100644 tools/swarm/src/cli.rs create mode 100644 tools/swarm/src/compose.rs create mode 100644 tools/swarm/src/main.rs create mode 100644 tools/swarm/src/ui.rs create mode 100644 tools/swarm/src/util.rs create mode 100644 tools/wasm_builder_cli/Cargo.toml create mode 100644 tools/wasm_builder_cli/README.md create mode 100644 tools/wasm_builder_cli/src/main.rs diff --git a/Cargo.lock b/Cargo.lock index 0d2aa017d30..37b46a93d42 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1409,18 +1409,6 @@ version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "86e3bdc80eee6e16b2b6b0f87fbc98c04bee3455e35174c0de1a125d0688c632" -[[package]] -name = "duct" -version = "0.13.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "37ae3fc31835f74c2a7ceda3aeede378b0ae2e74c8f1c36559fcc9ae2a4e7d3e" -dependencies = [ - "libc", - "once_cell", - "os_pipe", - "shared_child", -] - [[package]] name = "dunce" version = "1.0.4" @@ -3418,6 +3406,27 @@ dependencies = [ name = "iroha_substrate" version = "2.0.0-pre-rc.19" +[[package]] +name = "iroha_swarm" +version = "2.0.0-pre-rc.19" +dependencies = [ + "clap 4.3.15", + "color-eyre", + "derive_more", + "expect-test", + "inquire", + "iroha_config", + "iroha_crypto", + "iroha_data_model", + "iroha_primitives", + "owo-colors", + "path-absolutize", + "pathdiff", + "serde", + "serde_json", + "serde_yaml", +] + [[package]] name = "iroha_telemetry" version = "2.0.0-pre-rc.19" @@ -3496,6 +3505,17 @@ dependencies = [ "wasm-opt", ] +[[package]] +name = "iroha_wasm_builder_cli" +version = "2.0.0-pre-rc.19" +dependencies = [ + "clap 4.3.15", + "color-eyre", + "iroha_wasm_builder", + "owo-colors", + "spinoff", +] + [[package]] name = "iroha_wasm_codec" version = "2.0.0-pre-rc.19" @@ -3606,28 +3626,15 @@ dependencies = [ "clap 4.3.15", "color-eyre", "derive_more", - "duct", - "expect-test", - "eyre", - "inquire", "iroha_config", "iroha_crypto", "iroha_data_model", "iroha_genesis", "iroha_primitives", "iroha_schema_gen", - "iroha_wasm_builder", - "owo-colors", "parity-scale-codec", - "path-absolutize", - "pathdiff", "serde", "serde_json", - "serde_yaml", - "spinoff", - "supports-color 2.0.0", - "tempfile", - "vergen", ] [[package]] @@ -4061,16 +4068,6 @@ dependencies = [ "vcpkg", ] -[[package]] -name = "os_pipe" -version = "1.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ae859aa07428ca9a929b936690f8b12dc5f11dd8c6992a18ca93919f28bc177" -dependencies = [ - "libc", - "windows-sys 0.48.0", -] - [[package]] name = "os_str_bytes" version = "6.5.1" @@ -5215,16 +5212,6 @@ dependencies = [ "lazy_static", ] -[[package]] -name = "shared_child" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b0d94659ad3c2137fef23ae75b03d5241d633f8acded53d672decfa0e6e0caef" -dependencies = [ - "libc", - "winapi", -] - [[package]] name = "shell-words" version = "1.1.0" diff --git a/Cargo.toml b/Cargo.toml index ddd2b0356ef..e9717ddedd8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -165,6 +165,8 @@ members = [ "tools/kagami", "tools/kura_inspector", "tools/parity_scale_decoder", + "tools/swarm", + "tools/wasm_builder_cli", "version", "version/derive", "wasm_codec", diff --git a/default_validator/README.md b/default_validator/README.md new file mode 100644 index 00000000000..98d12732107 --- /dev/null +++ b/default_validator/README.md @@ -0,0 +1,8 @@ +# `iroha_default_validator` + +Use the [Wasm Builder CLI](../tools/wasm_builder_cli) in order to build it: + +```bash +cargo run --bin iroha_wasm_builder_cli -- \ + build ./default_validator --optimize --outfile ./configs/peer/validator.wasm +``` \ No newline at end of file diff --git a/scripts/check.sh b/scripts/check.sh index 71779dec11b..74dafbf2b88 100755 --- a/scripts/check.sh +++ b/scripts/check.sh @@ -8,8 +8,8 @@ case $1 in exit 1 };; "genesis") - cargo run --release --bin kagami -- genesis --compiled-validator-path ./validator.wasm | diff - configs/peer/genesis.json || { - echo 'Please re-generate the genesis with `cargo run --release --bin kagami -- genesis > configs/peer/genesis.json`' + cargo run --release --bin kagami -- genesis --validator-path-in-genesis ./validator.wasm | diff - configs/peer/genesis.json || { + echo 'Please re-generate the genesis with `cargo run --release --bin kagami -- genesis --validator-path-in-genesis ./validator.wasm > configs/peer/genesis.json`' exit 1 };; "client") @@ -35,24 +35,25 @@ case $1 in # it is not a default behaviour because Kagami resolves `build` path relative # to the output file location temp_file="docker-compose.TMP.yml" + full_cmd="$cmd_base --outfile $temp_file" - eval "$cmd_base $temp_file" + eval "$full_cmd" diff "$temp_file" "$target" || { - echo "Please re-generate \`$target\` with \`$cmd_base $target\`" + echo "Please re-generate \`$target\` with \`$full_cmd\`" exit 1 } } command_base_for_single() { - echo "cargo run --release --bin kagami -- swarm -p 1 -s Iroha --force file --config-dir ./configs/peer --build ." + echo "cargo run --release --bin iroha_swarm -- -p 1 -s Iroha --force --config-dir ./configs/peer --build ." } command_base_for_multiple_local() { - echo "cargo run --release --bin kagami -- swarm -p 4 -s Iroha --force file --config-dir ./configs/peer --build ." + echo "cargo run --release --bin iroha_swarm -- -p 4 -s Iroha --force --config-dir ./configs/peer --build ." } command_base_for_default() { - echo "cargo run --release --bin kagami -- swarm -p 4 -s Iroha --force file --config-dir ./configs/peer --image hyperledger/iroha2:dev" + echo "cargo run --release --bin iroha_swarm -- -p 4 -s Iroha --force --config-dir ./configs/peer --image hyperledger/iroha2:dev" } diff --git a/tools/kagami/Cargo.toml b/tools/kagami/Cargo.toml index 92671bae4c3..8d3830559c9 100644 --- a/tools/kagami/Cargo.toml +++ b/tools/kagami/Cargo.toml @@ -16,25 +16,10 @@ iroha_data_model = { workspace = true } iroha_schema_gen = { workspace = true } iroha_primitives = { workspace = true } iroha_genesis = { workspace = true } -iroha_wasm_builder = { workspace = true } color-eyre = { workspace = true } clap = { workspace = true, features = ["derive"] } serde_json = { workspace = true } derive_more = { workspace = true } serde = { workspace = true, features = ["derive"] } -serde_yaml = { workspace = true } -expect-test = { workspace = true } -pathdiff = { workspace = true } -path-absolutize = { workspace = true } -spinoff = { workspace = true, features = ["aesthetic"] } -owo-colors = { workspace = true, features = ["supports-colors"] } -supports-color = { workspace = true } -inquire = { workspace = true } -duct = { workspace = true } -tempfile = { workspace = true } parity-scale-codec = { workspace = true } - -[build-dependencies] -eyre = { workspace = true } -vergen = { workspace = true, features = ["git", "gitoxide"] } diff --git a/tools/kagami/build.rs b/tools/kagami/build.rs deleted file mode 100644 index a612018291a..00000000000 --- a/tools/kagami/build.rs +++ /dev/null @@ -1,8 +0,0 @@ -//! Provides macros used to get the version - -use std::error::Error; - -fn main() -> Result<(), Box> { - vergen::EmitBuilder::builder().git_sha(false).emit()?; - Ok(()) -} diff --git a/tools/kagami/src/genesis.rs b/tools/kagami/src/genesis.rs index ca0c87f20ff..22c64d14053 100644 --- a/tools/kagami/src/genesis.rs +++ b/tools/kagami/src/genesis.rs @@ -8,7 +8,6 @@ use iroha_data_model::{ metadata::Limits, parameter::{default::*, ParametersBuilder}, prelude::AssetId, - validator::Validator, IdBox, }; use iroha_genesis::{RawGenesisBlock, RawGenesisBlockBuilder, ValidatorMode, ValidatorPath}; @@ -16,17 +15,26 @@ use serde_json::json; use super::*; +const INLINED_VALIDATOR_WARNING: &str = r#"WARN: You're using genesis with inlined validator. +Consider specifying a separate validator file using `--validator-path-in-genesis` instead. +Use `--help` for more information."#; + #[derive(Parser, Debug, Clone)] #[clap(group = ArgGroup::new("validator").required(true))] pub struct Args { - /// If this option provided validator will be inlined in the genesis. - #[clap(long, group = "validator")] - inlined_validator: bool, - /// If this option provided validator won't be included in the genesis and only path to the validator will be included. - /// Path is either absolute path to validator or relative to genesis location. - /// Validator can be generated using `kagami validator` command. - #[clap(long, group = "validator")] - compiled_validator_path: Option, + /// Reads the validator from the file at (relative to CWD) + /// and includes the content into the genesis. + /// + /// WARN: This approach can lead to reproducibility issues, as WASM builds are currently not + /// guaranteed to be reproducible. Additionally, inlining the validator bloats the genesis JSON + /// and makes it less readable. Consider specifying a separate validator file + /// using `--validator-path-in-genesis` instead. For more details, refer to + /// the related PR: https://github.com/hyperledger/iroha/pull/3434 + #[clap(long, group = "validator", value_name = "PATH")] + inline_validator_from_file: Option, + /// Specifies the that will be directly inserted into the genesis JSON as-is. + #[clap(long, group = "validator", value_name = "PATH")] + validator_path_in_genesis: Option, #[clap(subcommand)] mode: Option, } @@ -58,34 +66,62 @@ pub enum Mode { impl RunArgs for Args { fn run(self, writer: &mut BufWriter) -> Outcome { - if self.inlined_validator { - eprintln!("WARN: You're using genesis with inlined validator."); - eprintln!( - "Consider providing validator in separate file `--compiled-validator-path PATH`." - ); - eprintln!("Use `--help` to get more information."); - } - let validator_path = self.compiled_validator_path; - let genesis = match self.mode.unwrap_or_default() { - Mode::Default => generate_default(validator_path), + let Self { + inline_validator_from_file, + validator_path_in_genesis, + mode, + } = self; + + let validator: ValidatorMode = + match (inline_validator_from_file, validator_path_in_genesis) { + (Some(path), None) => { + eprintln!("{INLINED_VALIDATOR_WARNING}"); + ParsedValidatorArgs::Inline(path) + } + (None, Some(path)) => ParsedValidatorArgs::Path(path), + _ => unreachable!("clap invariant"), + } + .try_into()?; + + let genesis = match mode.unwrap_or_default() { + Mode::Default => generate_default(validator), Mode::Synthetic { domains, accounts_per_domain, assets_per_domain, - } => generate_synthetic( - validator_path, - domains, - accounts_per_domain, - assets_per_domain, - ), + } => generate_synthetic(validator, domains, accounts_per_domain, assets_per_domain), }?; writeln!(writer, "{}", serde_json::to_string_pretty(&genesis)?) .wrap_err("Failed to write serialized genesis to the buffer.") } } +enum ParsedValidatorArgs { + Inline(PathBuf), + Path(PathBuf), +} + +impl TryFrom for ValidatorMode { + type Error = color_eyre::Report; + + fn try_from(value: ParsedValidatorArgs) -> Result { + let mode = match value { + ParsedValidatorArgs::Path(path) => ValidatorMode::Path(ValidatorPath(path)), + ParsedValidatorArgs::Inline(path) => { + let validator = ValidatorMode::Path(ValidatorPath(path.clone())) + .try_into() + .wrap_err_with(|| { + format!("Failed to read the validator located at {}", path.display()) + })?; + ValidatorMode::Inline(validator) + } + }; + Ok(mode) + } +} + #[allow(clippy::too_many_lines)] -pub fn generate_default(validator_path: Option) -> color_eyre::Result { +pub fn generate_default(validator: ValidatorMode) -> color_eyre::Result { let mut meta = Metadata::new(); meta.insert_with_limits( "key".parse()?, @@ -93,11 +129,6 @@ pub fn generate_default(validator_path: Option) -> color_eyre::Result ValidatorMode::Path(ValidatorPath(validator_path)), - None => ValidatorMode::Inline(construct_validator()?), - }; - let mut genesis = RawGenesisBlockBuilder::new() .domain_with_metadata("wonderland".parse()?, meta.clone()) .account_with_metadata( @@ -177,26 +208,12 @@ pub fn generate_default(validator_path: Option) -> color_eyre::Result color_eyre::Result { - let temp_dir = tempfile::tempdir() - .wrap_err("Failed to generate a tempdir for validator sources")? - .into_path(); - let path = super::validator::compute_validator_path(temp_dir)?; - let wasm_blob = super::validator::construct_validator(path)?; - Ok(Validator::new(WasmSmartContract::from_compiled(wasm_blob))) -} - fn generate_synthetic( - validator_path: Option, + validator: ValidatorMode, domains: u64, accounts_per_domain: u64, assets_per_domain: u64, ) -> color_eyre::Result { - let validator = match validator_path { - Some(validator_path) => ValidatorMode::Path(ValidatorPath(validator_path)), - None => ValidatorMode::Inline(construct_validator()?), - }; - // Add default `Domain` and `Account` to still be able to query let mut builder = RawGenesisBlockBuilder::new() .domain("wonderland".parse()?) diff --git a/tools/kagami/src/main.rs b/tools/kagami/src/main.rs index c4f27feed1f..b869fbd8c20 100644 --- a/tools/kagami/src/main.rs +++ b/tools/kagami/src/main.rs @@ -20,8 +20,6 @@ mod crypto; mod docs; mod genesis; mod schema; -mod swarm; -mod validator; /// Outcome shorthand used throughout this crate pub(crate) type Outcome = color_eyre::Result<()>; @@ -31,20 +29,6 @@ pub(crate) type Outcome = color_eyre::Result<()>; // you need to change either, you should definitely change both. pub const DEFAULT_PUBLIC_KEY: &str = "ed01207233bfc89dcbd68c19fde6ce6158225298ec1131b6a130d1aeb454c1ab5183c0"; -pub const GIT_REVISION: &str = env!("VERGEN_GIT_SHA"); -pub const GIT_ORIGIN: &str = "https://github.com/hyperledger/iroha.git"; -/// Config directory that is generated in the output directory -pub const DIR_CONFIG: &str = "config"; -/// Config directory inside of the docker image -pub const DIR_CONFIG_IN_DOCKER: &str = "/config"; -pub const DIR_CLONE: &str = "iroha-cloned"; -pub const FILE_VALIDATOR: &str = "validator.wasm"; -pub const FILE_CONFIG: &str = "config.json"; -pub const FILE_GENESIS: &str = "genesis.json"; -pub const FILE_COMPOSE: &str = "docker-compose.yml"; -pub const FORCE_ARG_SUGGESTION: &str = - "You can pass `--outdir-force` flag to remove the directory without prompting"; -pub const GENESIS_KEYPAIR_SEED: &[u8; 7] = b"genesis"; fn main() -> Outcome { color_eyre::install()?; @@ -77,10 +61,6 @@ pub enum Args { Config(config::Args), /// Generate a Markdown reference of configuration parameters Docs(Box), - /// Generate the default validator - Validator(validator::Args), - /// Generate Docker Compose configuration - Swarm(swarm::Args), } impl RunArgs for Args { @@ -93,8 +73,6 @@ impl RunArgs for Args { Genesis(args) => args.run(writer), Config(args) => args.run(writer), Docs(args) => args.run(writer), - Validator(args) => args.run(writer), - Swarm(args) => args.run(), } } } diff --git a/tools/kagami/src/swarm.rs b/tools/kagami/src/swarm.rs deleted file mode 100644 index fe3d48eda63..00000000000 --- a/tools/kagami/src/swarm.rs +++ /dev/null @@ -1,1669 +0,0 @@ -use std::{ - collections::BTreeSet, - ffi::OsStr, - fs::File, - io::Write, - num::NonZeroU16, - ops::Deref, - path::{Path, PathBuf}, -}; - -use color_eyre::{ - eyre::{eyre, Context, ContextCompat}, - Report, Result, -}; -use iroha_crypto::{error::Error as IrohaCryptoError, KeyGenConfiguration, KeyPair}; -use iroha_data_model::prelude::PeerId; -use path_absolutize::Absolutize; -use serialize_docker_compose::{DockerCompose, DockerComposeService, ServiceSource}; -use ui::UserInterface; - -use super::*; - -mod clap_args { - use clap::{Args, Subcommand}; - - use super::*; - - #[derive(Args, Debug)] - pub struct SwarmArgs { - /// How many peers to generate within the Docker Compose setup. - #[arg(long, short)] - pub peers: NonZeroU16, - /// The Unicode `seed` string for deterministic key-generation. - /// - // TODO: Check for length limitations, and if non-UTF-8 sequences are working. - #[arg(long, short)] - pub seed: Option, - /// Re-create the target directory (for `dir` subcommand) or file (for `file` subcommand) - /// if they already exist. - #[arg(long)] - pub force: bool, - - #[command(subcommand)] - pub command: SwarmMode, - } - - #[derive(Subcommand, Debug)] - pub enum SwarmMode { - /// Produce a directory with Docker Compose configuration, Iroha configuration, and an option - /// to clone Iroha and use it as a source. - /// - /// This command builds Docker Compose configuration in a specified directory. If the source - /// is a GitHub repo, it will be cloned into the directory. Also, the default configuration is - /// built and put into `/config` directory, unless `--no-default-configuration` flag is - /// provided. The default configuration is equivalent to running `kagami config peer`, - /// `kagami validator`, and `kagami genesis default --compiled-validator-path ./validator.wasm` - /// consecutively. - /// - /// Default configuration building will fail if Kagami is run outside of Iroha repo (tracking - /// issue: https://github.com/hyperledger/iroha/issues/3473). If you are going to run it outside - /// of the repo, make sure to pass `--no-default-configuration` flag. - Dir { - /// Target directory where to place generated files. - /// - /// If the directory is not empty, Kagami will prompt it's re-creation. If the TTY is not - /// interactive, Kagami will stop execution with non-zero exit code. In order to re-create - /// the directory anyway, pass `--force` flag. - outdir: PathBuf, - /// Do not create default configuration in the `/config` directory. - /// - /// Default `config.json`, `genesis.json` and `validator.wasm` are generated and put into - /// the `/config` directory. That directory is specified in the `volumes` field - /// of the Docker Compose file. - /// - /// Setting this flag prevents copying of default configuration files into the output folder. - /// The `config` directory will still be created, but the necessary configuration should be put - /// there by the user manually. - #[arg(long)] - no_default_configuration: bool, - #[command(flatten)] - source: ModeDirSource, - }, - /// Produce only a single Docker Compose configuration file - File { - /// Path to a generated Docker Compose configuration. - /// - /// If file exists, Kagami will prompt its overwriting. If the TTY is not - /// interactive, Kagami will stop execution with non-zero exit code. In order to - /// overwrite the file anyway, pass `--force` flag. - outfile: PathBuf, - /// Path to a directory with Iroha configuration. It will be mapped as volume for containers. - /// - /// The directory should contain `config.json` and `genesis.json`. - #[arg(long)] - config_dir: PathBuf, - #[command(flatten)] - source: ModeFileSource, - }, - } - - #[derive(Args, Debug)] - #[group(required = true, multiple = false)] - pub struct ModeDirSource { - /// Use Iroha GitHub source as a build source - /// - /// Clone `hyperledger/iroha` repo from the revision Kagami is built itself, - /// and use the cloned source code to build images from. - #[arg(long)] - pub build_from_github: bool, - /// Use specified docker image. - /// - /// Be careful with specifying a Dockerhub image as a source: Kagami Swarm only guarantees that - /// the docker-compose configuration it generates is compatible with the same Git revision it - /// is built from itself. Therefore, if specified image is not compatible with the version of Swarm - /// you are running, the generated configuration might not work. - #[arg(long)] - pub image: Option, - /// Use local path location of the Iroha source code to build images from. - /// - /// If the path is relative, it will be resolved relative to the CWD. - #[arg(long, value_name = "PATH")] - pub build: Option, - } - - #[derive(Args, Debug)] - #[group(required = true, multiple = false)] - // FIXME: I haven't found a way how to share `image` and `build` options between `file` and - // `dir` modes with correct grouping logic. `command(flatten)` doesn't work for it, - // so it's hard to share a single struct with "base source options" - pub struct ModeFileSource { - /// Same as `--image` for `swarm dir` subcommand - #[arg(long)] - pub image: Option, - /// Same as `--build` for `swarm build` subcommand - #[arg(long, value_name = "PATH")] - pub build: Option, - } - - #[cfg(test)] - mod tests { - use std::fmt::{Debug, Display, Formatter}; - - use clap::{ArgMatches, Command, Error as ClapError}; - - use super::*; - - struct ClapErrorWrap(ClapError); - - impl From for ClapErrorWrap { - fn from(value: ClapError) -> Self { - Self(value) - } - } - - impl Debug for ClapErrorWrap { - fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { - Display::fmt(&self.0, f) - } - } - - fn match_args(args_str: impl AsRef) -> Result { - let cmd = Command::new("test"); - let cmd = SwarmArgs::augment_args(cmd); - let matches = cmd.try_get_matches_from( - std::iter::once("test").chain(args_str.as_ref().split(' ')), - )?; - Ok(matches) - } - - #[test] - fn works_in_file_mode() { - let _ = match_args("-p 20 file --build . --config-dir ./config sample.yml").unwrap(); - } - - #[test] - fn works_in_dir_mode_with_github_source() { - let _ = match_args("-p 20 dir --build-from-github swarm").unwrap(); - } - - #[test] - fn doesnt_allow_config_dir_for_dir_mode() { - let _ = match_args("-p 1 dir --build-from-github --config-dir ./ swarm").unwrap_err(); - } - - #[test] - fn doesnt_allow_multiple_sources_in_dir_mode() { - let _ = match_args("-p 1 dir --build-from-github --build . swarm").unwrap_err(); - } - - #[test] - fn doesnt_allow_multiple_sources_in_file_mode() { - let _ = match_args("-p 1 file --build . --image hp/iroha --config-dir ./ test.yml") - .unwrap_err(); - } - - #[test] - fn doesnt_allow_github_source_in_file_mode() { - let _ = - match_args("-p 1 file --build-from-github --config-dir ./ test.yml").unwrap_err(); - } - - #[test] - fn doesnt_allow_omitting_source_in_dir_mode() { - let _ = match_args("-p 1 dir ./test").unwrap_err(); - } - - #[test] - fn doesnt_allow_omitting_source_in_file_mode() { - let _ = match_args("-p 1 file test.yml --config-dir ./").unwrap_err(); - } - } -} - -pub use clap_args::SwarmArgs as Args; -use clap_args::{ModeDirSource, ModeFileSource}; - -impl Args { - pub fn run(self) -> Outcome { - let parsed: ParsedArgs = self.into(); - parsed.run() - } -} - -/// Type-strong version of [`Args`] with no ambiguity between arguments relationships -struct ParsedArgs { - peers: NonZeroU16, - seed: Option, - /// User allowance to override existing files/directories - force: bool, - mode: ParsedMode, -} - -impl From for ParsedArgs { - fn from( - Args { - peers, - force, - seed, - command, - }: Args, - ) -> Self { - let mode: ParsedMode = match command { - clap_args::SwarmMode::File { - outfile, - config_dir, - source, - } => ParsedMode::File { - target_file: outfile, - config_dir, - image_source: source.into(), - }, - clap_args::SwarmMode::Dir { - outdir, - no_default_configuration, - source, - } => ParsedMode::Directory { - target_dir: outdir, - no_default_configuration, - image_source: source.into(), - }, - }; - - Self { - peers, - force, - seed, - mode, - } - } -} - -impl ParsedArgs { - pub fn run(self) -> Outcome { - let ui = UserInterface::new(); - - let Self { - peers, - seed, - force, - mode, - } = self; - let seed = seed.map(String::into_bytes); - let seed = seed.as_deref(); - - match mode { - ParsedMode::Directory { - target_dir, - no_default_configuration, - image_source, - } => { - let target_file_raw = target_dir.join(FILE_COMPOSE); - let target_dir = TargetDirectory::new(AbsolutePath::absolutize(&target_dir)?); - let config_dir = AbsolutePath::absolutize(&target_dir.path.join(DIR_CONFIG))?; - let target_file = AbsolutePath::absolutize(&target_file_raw)?; - - let prepare_dir_strategy = if force { - PrepareDirectory::ForceRecreate - } else { - PrepareDirectory::Prompt - }; - - if let EarlyEnding::Halt = target_dir - .prepare(&prepare_dir_strategy, &ui) - .wrap_err("Failed to prepare directory")? - { - return Ok(()); - } - - let image_source = image_source - .resolve(&target_dir, &ui) - .wrap_err("Failed to resolve the source of image")?; - - let ui = if no_default_configuration { - PrepareConfig::GenerateOnlyDirectory - } else { - PrepareConfig::GenerateDefault - } - .run(&config_dir, &image_source, ui) - .wrap_err("Failed to prepare configuration")?; - - DockerComposeBuilder { - target_file: &target_file, - config_dir: &config_dir, - image_source, - peers, - seed, - } - .build_and_write()?; - - ui.log_directory_mode_complete(&target_dir.path, &target_file_raw); - - Ok(()) - } - ParsedMode::File { - target_file, - config_dir, - image_source, - } => { - let target_file_raw = target_file; - let target_file = AbsolutePath::absolutize(&target_file_raw)?; - let config_dir = AbsolutePath::absolutize(&config_dir)?; - - if target_file.exists() && !force { - if let ui::PromptAnswer::No = ui.prompt_remove_target_file(&target_file)? { - return Ok(()); - } - } - - let image_source = image_source - .resolve() - .wrap_err("Failed to resolve the source of image")?; - - DockerComposeBuilder { - target_file: &target_file, - config_dir: &config_dir, - image_source, - peers, - seed, - } - .build_and_write()?; - - ui.log_file_mode_complete(&target_file, &target_file_raw); - - Ok(()) - } - } - } -} - -enum ParsedMode { - Directory { - target_dir: PathBuf, - no_default_configuration: bool, - image_source: SourceForDirectory, - }, - File { - target_file: PathBuf, - config_dir: PathBuf, - image_source: SourceForFile, - }, -} - -enum SourceForDirectory { - SameAsForFile(SourceForFile), - BuildFromGitHub, -} - -impl From for SourceForDirectory { - fn from(value: ModeDirSource) -> Self { - match value { - ModeDirSource { - build: Some(path), - image: None, - build_from_github: false, - } => Self::SameAsForFile(SourceForFile::Build { path }), - ModeDirSource { - build: None, - image: Some(name), - build_from_github: false, - } => Self::SameAsForFile(SourceForFile::Image { name }), - ModeDirSource { - build: None, - image: None, - build_from_github: true, - } => Self::BuildFromGitHub, - _ => unreachable!("clap invariant"), - } - } -} - -impl SourceForDirectory { - /// Has a side effect: if self is [`Self::BuildFromGitHub`], it clones the repo into - /// the target directory. - fn resolve(self, target: &TargetDirectory, ui: &UserInterface) -> Result { - match self { - Self::SameAsForFile(source_for_file) => source_for_file.resolve(), - Self::BuildFromGitHub => { - let clone_dir = target.path.join(DIR_CLONE); - let clone_dir = AbsolutePath::absolutize(&clone_dir)?; - - ui.log_cloning_repo(); - - shallow_git_clone(GIT_ORIGIN, GIT_REVISION, &clone_dir) - .wrap_err("Failed to clone the repo")?; - - Ok(ResolvedImageSource::Build { path: clone_dir }) - } - } - } -} - -enum SourceForFile { - Image { name: String }, - Build { path: PathBuf }, -} - -impl From for SourceForFile { - fn from(value: ModeFileSource) -> Self { - match value { - ModeFileSource { - image: Some(name), - build: None, - } => Self::Image { name }, - ModeFileSource { - image: None, - build: Some(path), - } => Self::Build { path }, - _ => unreachable!("clap invariant"), - } - } -} - -impl SourceForFile { - fn resolve(self) -> Result { - let resolved = match self { - Self::Image { name } => ResolvedImageSource::Image { name }, - Self::Build { path: relative } => { - let absolute = - AbsolutePath::absolutize(&relative).wrap_err("Failed to resolve build path")?; - ResolvedImageSource::Build { path: absolute } - } - }; - - Ok(resolved) - } -} - -#[derive(Debug)] -enum ResolvedImageSource { - Image { name: String }, - Build { path: AbsolutePath }, -} - -pub fn shallow_git_clone( - remote: impl AsRef, - revision: impl AsRef, - dir: &AbsolutePath, -) -> Result<()> { - use duct::cmd; - - std::fs::create_dir(dir)?; - - cmd!("git", "init").dir(dir).run()?; - cmd!("git", "remote", "add", "origin", remote.as_ref()) - .dir(dir) - .run()?; - cmd!("git", "fetch", "--depth=1", "origin", revision.as_ref()) - .dir(dir) - .run()?; - cmd!( - "git", - "-c", - "advice.detachedHead=false", - "checkout", - "FETCH_HEAD" - ) - .dir(dir) - .run()?; - - Ok(()) -} - -enum PrepareConfig { - GenerateDefault, - GenerateOnlyDirectory, -} - -impl PrepareConfig { - fn run( - &self, - config_dir: &AbsolutePath, - source: &ResolvedImageSource, - ui: UserInterface, - ) -> Result { - std::fs::create_dir(config_dir).wrap_err("Failed to create the config directory")?; - - let ui = match self { - Self::GenerateOnlyDirectory => { - ui.warn_no_default_config(config_dir); - ui - } - Self::GenerateDefault => { - let path_validator = PathBuf::from(FILE_VALIDATOR); - - let raw_genesis_block = { - let block = super::genesis::generate_default(Some(path_validator.clone())) - .wrap_err("Failed to generate genesis")?; - serde_json::to_string_pretty(&block)? - }; - - let default_config = { - let proxy = iroha_config::iroha::ConfigurationProxy::default(); - serde_json::to_string_pretty(&proxy)? - }; - - let spinner = ui.spinner_validator(); - - let validator_path = if let ResolvedImageSource::Build { ref path } = source { - super::validator::compute_validator_path_with_build_dir(path).wrap_err( - "Failed to construct the validator path from swarm build directory", - )? - } else { - let out_dir = tempfile::tempdir() - .wrap_err("Failed to generate a tempdir for validator sources")? - .into_path(); - super::validator::compute_validator_path(out_dir) - .wrap_err("Failed to construct the validator")? - }; - let validator = super::validator::construct_validator(validator_path)?; - - let ui = spinner.done(); - - File::create(config_dir.join(FILE_GENESIS))? - .write_all(raw_genesis_block.as_bytes())?; - File::create(config_dir.join(FILE_CONFIG))?.write_all(default_config.as_bytes())?; - File::create(config_dir.join(path_validator))?.write_all(validator.as_slice())?; - - ui.log_default_configuration_is_written(config_dir); - ui - } - }; - - Ok(ui) - } -} - -enum PrepareDirectory { - ForceRecreate, - Prompt, -} - -enum EarlyEnding { - Halt, - Continue, -} - -#[derive(Clone, Debug)] -struct TargetDirectory { - path: AbsolutePath, -} - -impl TargetDirectory { - fn new(path: AbsolutePath) -> Self { - Self { path } - } - - fn prepare(&self, strategy: &PrepareDirectory, ui: &UserInterface) -> Result { - // FIXME: use [`std::fs::try_exists`] when it is stable - let was_removed = if self.path.exists() { - match strategy { - PrepareDirectory::ForceRecreate => { - self.remove_dir()?; - } - PrepareDirectory::Prompt => { - if let EarlyEnding::Halt = self.remove_directory_with_prompt(ui)? { - return Ok(EarlyEnding::Halt); - } - } - } - true - } else { - false - }; - - self.make_dir_recursive()?; - - ui.log_target_directory_ready( - &self.path, - if was_removed { - ui::TargetDirectoryAction::Recreated - } else { - ui::TargetDirectoryAction::Created - }, - ); - - Ok(EarlyEnding::Continue) - } - - /// `rm -r ` - fn remove_dir(&self) -> Result<()> { - std::fs::remove_dir_all(&self.path) - .wrap_err_with(|| eyre!("Failed to remove the directory: {}", self.path.display())) - } - - /// If user says "no", program should just exit, so it returns [`EarlyEnding::Halt`]. - /// - /// # Errors - /// - /// - If TTY is not interactive - fn remove_directory_with_prompt(&self, ui: &UserInterface) -> Result { - if let ui::PromptAnswer::Yes = - ui.prompt_remove_target_dir(&self.path).wrap_err_with(|| { - eyre!( - "Failed to prompt removal for the directory: {}", - self.path.display() - ) - })? - { - self.remove_dir()?; - Ok(EarlyEnding::Continue) - } else { - Ok(EarlyEnding::Halt) - } - } - - /// `mkdir -r ` - fn make_dir_recursive(&self) -> Result<()> { - std::fs::create_dir_all(&self.path).wrap_err_with(|| { - eyre!( - "Failed to recursively create the directory: {}", - self.path.display() - ) - }) - } -} - -#[derive(Debug)] -struct DockerComposeBuilder<'a> { - /// Needed to compute a relative source build path - target_file: &'a AbsolutePath, - /// Needed to put into `volumes` - config_dir: &'a AbsolutePath, - image_source: ResolvedImageSource, - peers: NonZeroU16, - /// Crypto seed to use for keys generation - seed: Option<&'a [u8]>, -} - -impl DockerComposeBuilder<'_> { - fn build(&self) -> Result { - let target_file_dir = self.target_file.parent().ok_or_else(|| { - eyre!( - "Cannot get a directory of a file {}", - self.target_file.display() - ) - })?; - - let peers = peer_generator::generate_peers(self.peers, self.seed) - .wrap_err("Failed to generate peers")?; - let genesis_key_pair = generate_key_pair(self.seed, GENESIS_KEYPAIR_SEED) - .wrap_err("Failed to generate genesis key pair")?; - let service_source = match &self.image_source { - ResolvedImageSource::Build { path } => { - ServiceSource::Build(path.relative_to(target_file_dir)?) - } - ResolvedImageSource::Image { name } => ServiceSource::Image(name.clone()), - }; - let volumes = vec![( - self.config_dir - .relative_to(target_file_dir)? - .to_str() - .wrap_err("Config directory path is not a valid string")? - .to_owned(), - DIR_CONFIG_IN_DOCKER.to_owned(), - )]; - - let trusted_peers: BTreeSet = - peers.values().map(peer_generator::Peer::id).collect(); - - let mut peers_iter = peers.iter(); - - let first_peer_service = { - let (name, peer) = peers_iter.next().expect("There is non-zero count of peers"); - let service = DockerComposeService::new( - peer, - service_source.clone(), - volumes.clone(), - trusted_peers.clone(), - Some(genesis_key_pair), - ); - - (name.clone(), service) - }; - - let services = peers_iter - .map(|(name, peer)| { - let service = DockerComposeService::new( - peer, - service_source.clone(), - volumes.clone(), - trusted_peers.clone(), - None, - ); - - (name.clone(), service) - }) - .chain(std::iter::once(first_peer_service)) - .collect(); - - let compose = DockerCompose::new(services); - Ok(compose) - } - - fn build_and_write(&self) -> Result<()> { - let target_file = self.target_file; - let compose = self - .build() - .wrap_err("Failed to build a docker compose file")?; - compose.write_file(&target_file.path) - } -} - -#[derive(Clone, Debug)] -pub struct AbsolutePath { - path: PathBuf, -} - -impl Deref for AbsolutePath { - type Target = PathBuf; - - fn deref(&self) -> &Self::Target { - &self.path - } -} - -impl AsRef for AbsolutePath { - fn as_ref(&self) -> &Path { - self.path.as_path() - } -} - -impl AsRef for AbsolutePath { - fn as_ref(&self) -> &OsStr { - self.path.as_ref() - } -} - -impl TryFrom for AbsolutePath { - type Error = Report; - - fn try_from(value: PathBuf) -> std::result::Result { - Self::absolutize(&value) - } -} - -impl AbsolutePath { - fn absolutize(path: &PathBuf) -> Result { - Ok(Self { - path: if path.is_absolute() { - path.clone() - } else { - path.absolutize()?.to_path_buf() - }, - }) - } - - /// Relative path from self to other. - fn relative_to(&self, other: &(impl AsRef + ?Sized)) -> Result { - pathdiff::diff_paths(self, other) - .ok_or_else(|| { - eyre!( - "failed to build relative path from {} to {}", - other.as_ref().display(), - self.display(), - ) - }) - // docker-compose might not like "test" path, but "./test" instead - .map(|rel| { - if rel.starts_with("..") { - rel - } else { - Path::new("./").join(rel) - - } - }) - } -} - -/// Swarm-specific seed-based key pair generation -pub fn generate_key_pair( - base_seed: Option<&[u8]>, - additional_seed: &[u8], -) -> Result { - let cfg = base_seed - .map(|base| { - let seed: Vec<_> = base.iter().chain(additional_seed).copied().collect(); - KeyGenConfiguration::default().use_seed(seed) - }) - .unwrap_or_default(); - - KeyPair::generate_with_configuration(cfg) -} - -mod peer_generator { - use std::{collections::BTreeMap, num::NonZeroU16}; - - use color_eyre::{eyre::Context, Report}; - use iroha_crypto::KeyPair; - use iroha_data_model::prelude::PeerId; - use iroha_primitives::addr::{SocketAddr, SocketAddrHost}; - - const BASE_PORT_P2P: u16 = 1337; - const BASE_PORT_API: u16 = 8080; - const BASE_PORT_TELEMETRY: u16 = 8180; - const BASE_SERVICE_NAME: &'_ str = "iroha"; - - pub struct Peer { - pub name: String, - pub port_p2p: u16, - pub port_api: u16, - pub port_telemetry: u16, - pub key_pair: KeyPair, - } - - impl Peer { - pub fn id(&self) -> PeerId { - PeerId::new(&self.addr(self.port_p2p), self.key_pair.public_key()) - } - - pub fn addr(&self, port: u16) -> SocketAddr { - SocketAddr::Host(SocketAddrHost { - host: self.name.clone().into(), - port, - }) - } - } - - pub fn generate_peers( - peers: NonZeroU16, - base_seed: Option<&[u8]>, - ) -> Result, Report> { - (0u16..peers.get()) - .map(|i| { - let service_name = format!("{BASE_SERVICE_NAME}{i}"); - - let key_pair = super::generate_key_pair(base_seed, service_name.as_bytes()) - .wrap_err("Failed to generate key pair")?; - - let peer = Peer { - name: service_name.clone(), - port_p2p: BASE_PORT_P2P + i, - port_api: BASE_PORT_API + i, - port_telemetry: BASE_PORT_TELEMETRY + i, - key_pair, - }; - - Ok((service_name, peer)) - }) - .collect() - } -} - -mod serialize_docker_compose { - use std::{ - collections::{BTreeMap, BTreeSet}, - fmt::Display, - fs::File, - io::Write, - path::PathBuf, - }; - - use color_eyre::eyre::{eyre, Context}; - use iroha_crypto::{KeyPair, PrivateKey, PublicKey}; - use iroha_data_model::prelude::PeerId; - use iroha_primitives::addr::SocketAddr; - use serde::{ser::Error as _, Serialize, Serializer}; - - use super::peer_generator::Peer; - - const COMMAND_SUBMIT_GENESIS: &str = "iroha --submit-genesis"; - const DOCKER_COMPOSE_VERSION: &str = "3.8"; - const PLATFORM_ARCHITECTURE: &str = "linux/amd64"; - - #[derive(Serialize, Debug)] - pub struct DockerCompose { - version: DockerComposeVersion, - services: BTreeMap, - } - - impl DockerCompose { - pub fn new(services: BTreeMap) -> Self { - Self { - version: DockerComposeVersion, - services, - } - } - - pub fn write_file(&self, path: &PathBuf) -> Result<(), color_eyre::Report> { - let yaml = serde_yaml::to_string(self).wrap_err("Failed to serialise YAML")?; - File::create(path) - .wrap_err_with(|| eyre!("Failed to create file {}", path.display()))? - .write_all(yaml.as_bytes()) - .wrap_err_with(|| eyre!("Failed to write YAML content into {}", path.display()))?; - Ok(()) - } - } - - #[derive(Debug)] - struct DockerComposeVersion; - - impl Serialize for DockerComposeVersion { - fn serialize(&self, serializer: S) -> Result - where - S: Serializer, - { - serializer.serialize_str(DOCKER_COMPOSE_VERSION) - } - } - - #[derive(Debug)] - struct PlatformArchitecture; - - impl Serialize for PlatformArchitecture { - fn serialize(&self, serializer: S) -> Result - where - S: Serializer, - { - serializer.serialize_str(PLATFORM_ARCHITECTURE) - } - } - - #[derive(Serialize, Debug)] - pub struct DockerComposeService { - #[serde(flatten)] - source: ServiceSource, - platform: PlatformArchitecture, - environment: FullPeerEnv, - ports: Vec>, - volumes: Vec>, - init: AlwaysTrue, - #[serde(skip_serializing_if = "ServiceCommand::is_none")] - command: ServiceCommand, - } - - impl DockerComposeService { - pub fn new( - peer: &Peer, - source: ServiceSource, - volumes: Vec<(String, String)>, - trusted_peers: BTreeSet, - genesis_key_pair: Option, - ) -> Self { - let ports = vec![ - PairColon(peer.port_p2p, peer.port_p2p), - PairColon(peer.port_api, peer.port_api), - PairColon(peer.port_telemetry, peer.port_telemetry), - ]; - - let command = if genesis_key_pair.is_some() { - ServiceCommand::SubmitGenesis - } else { - ServiceCommand::None - }; - - let compact_env = CompactPeerEnv { - trusted_peers, - key_pair: peer.key_pair.clone(), - genesis_key_pair, - p2p_addr: peer.addr(peer.port_p2p), - api_addr: peer.addr(peer.port_api), - telemetry_addr: peer.addr(peer.port_telemetry), - }; - - Self { - source, - platform: PlatformArchitecture, - command, - init: AlwaysTrue, - volumes: volumes.into_iter().map(|(a, b)| PairColon(a, b)).collect(), - ports, - environment: compact_env.into(), - } - } - } - - #[derive(Debug)] - struct AlwaysTrue; - - impl Serialize for AlwaysTrue { - fn serialize(&self, serializer: S) -> Result - where - S: Serializer, - { - serializer.serialize_bool(true) - } - } - - #[derive(Debug)] - enum ServiceCommand { - SubmitGenesis, - None, - } - - impl ServiceCommand { - fn is_none(&self) -> bool { - matches!(self, Self::None) - } - } - - impl Serialize for ServiceCommand { - fn serialize(&self, serializer: S) -> Result - where - S: Serializer, - { - match self { - Self::None => serializer.serialize_none(), - Self::SubmitGenesis => serializer.serialize_str(COMMAND_SUBMIT_GENESIS), - } - } - } - - /// Serializes as `"{0}:{1}"` - #[derive(derive_more::Display, Debug)] - #[display(fmt = "{_0}:{_1}")] - struct PairColon(T, U) - where - T: Display, - U: Display; - - impl Serialize for PairColon - where - T: Display, - U: Display, - { - fn serialize(&self, serializer: S) -> Result - where - S: Serializer, - { - serializer.collect_str(self) - } - } - - #[derive(Serialize, Clone, Debug)] - #[serde(rename_all = "lowercase")] - pub enum ServiceSource { - Image(String), - Build(PathBuf), - } - - #[derive(Serialize, Debug)] - #[serde(rename_all = "UPPERCASE")] - struct FullPeerEnv { - iroha_public_key: PublicKey, - iroha_private_key: SerializeAsJsonStr, - torii_p2p_addr: SocketAddr, - torii_api_url: SocketAddr, - torii_telemetry_url: SocketAddr, - #[serde(skip_serializing_if = "Option::is_none")] - iroha_genesis_account_public_key: Option, - #[serde(skip_serializing_if = "Option::is_none")] - iroha_genesis_account_private_key: Option>, - sumeragi_trusted_peers: SerializeAsJsonStr>, - } - - struct CompactPeerEnv { - key_pair: KeyPair, - /// Genesis key pair is only needed for a peer that is submitting the genesis block - genesis_key_pair: Option, - p2p_addr: SocketAddr, - api_addr: SocketAddr, - telemetry_addr: SocketAddr, - trusted_peers: BTreeSet, - } - - impl From for FullPeerEnv { - fn from(value: CompactPeerEnv) -> Self { - let (genesis_public_key, genesis_private_key) = - value.genesis_key_pair.map_or((None, None), |key_pair| { - ( - Some(key_pair.public_key().clone()), - Some(SerializeAsJsonStr(key_pair.private_key().clone())), - ) - }); - - Self { - iroha_public_key: value.key_pair.public_key().clone(), - iroha_private_key: SerializeAsJsonStr(value.key_pair.private_key().clone()), - iroha_genesis_account_public_key: genesis_public_key, - iroha_genesis_account_private_key: genesis_private_key, - torii_p2p_addr: value.p2p_addr, - torii_api_url: value.api_addr, - torii_telemetry_url: value.telemetry_addr, - sumeragi_trusted_peers: SerializeAsJsonStr(value.trusted_peers), - } - } - } - - #[derive(Debug)] - struct SerializeAsJsonStr(T); - - impl serde::Serialize for SerializeAsJsonStr - where - T: serde::Serialize, - { - fn serialize(&self, serializer: S) -> Result - where - S: Serializer, - { - let json = serde_json::to_string(&self.0).map_err(|json_err| { - S::Error::custom(format!("failed to serialize as JSON: {json_err}")) - })?; - serializer.serialize_str(&json) - } - } - - #[cfg(test)] - mod test { - use std::{ - cell::RefCell, - collections::{BTreeMap, BTreeSet, HashMap, HashSet}, - env::VarError, - ffi::OsStr, - path::PathBuf, - str::FromStr, - }; - - use color_eyre::eyre::Context; - use iroha_config::{ - base::proxy::{FetchEnv, LoadFromEnv, Override}, - iroha::ConfigurationProxy, - }; - use iroha_crypto::{KeyGenConfiguration, KeyPair}; - use iroha_primitives::addr::SocketAddr; - - use super::{ - CompactPeerEnv, DockerCompose, DockerComposeService, DockerComposeVersion, FullPeerEnv, - PairColon, PlatformArchitecture, ServiceSource, - }; - use crate::swarm::serialize_docker_compose::{AlwaysTrue, ServiceCommand}; - - struct TestEnv { - env: HashMap, - /// Set of env variables that weren't fetched yet - untouched: RefCell>, - } - - impl From for TestEnv { - fn from(peer_env: FullPeerEnv) -> Self { - let json = serde_json::to_string(&peer_env).expect("Must be serializable"); - let env: HashMap<_, _> = - serde_json::from_str(&json).expect("Must be deserializable into a hash map"); - let untouched = env.keys().map(Clone::clone).collect(); - Self { - env, - untouched: RefCell::new(untouched), - } - } - } - - impl From for TestEnv { - fn from(value: CompactPeerEnv) -> Self { - let full: FullPeerEnv = value.into(); - full.into() - } - } - - impl FetchEnv for TestEnv { - fn fetch>(&self, key: K) -> Result { - let key_str = key - .as_ref() - .to_str() - .ok_or_else(|| VarError::NotUnicode(key.as_ref().into()))?; - - let res = self - .env - .get(key_str) - .ok_or(VarError::NotPresent) - .map(std::clone::Clone::clone); - - if res.is_ok() { - self.untouched.borrow_mut().remove(key_str); - } - - res - } - } - - impl TestEnv { - fn assert_everything_covered(&self) { - assert_eq!(*self.untouched.borrow(), HashSet::new()); - } - } - - #[test] - fn default_config_with_swarm_env_are_exhaustive() { - let keypair = KeyPair::generate().unwrap(); - let env: TestEnv = CompactPeerEnv { - key_pair: keypair.clone(), - genesis_key_pair: Some(keypair), - p2p_addr: SocketAddr::from_str("127.0.0.1:1337").unwrap(), - api_addr: SocketAddr::from_str("127.0.0.1:1338").unwrap(), - telemetry_addr: SocketAddr::from_str("127.0.0.1:1339").unwrap(), - trusted_peers: BTreeSet::new(), - } - .into(); - - let proxy = ConfigurationProxy::default() - .override_with(ConfigurationProxy::from_env(&env).expect("valid env")); - - let _cfg = proxy - .build() - .wrap_err("Failed to build configuration") - .expect("Default configuration with swarm's env should be exhaustive"); - - env.assert_everything_covered(); - } - - #[test] - fn serialize_image_source() { - let source = ServiceSource::Image("hyperledger/iroha2:stable".to_owned()); - let serialised = serde_json::to_string(&source).unwrap(); - assert_eq!(serialised, r#"{"image":"hyperledger/iroha2:stable"}"#); - } - - #[test] - fn serialize_docker_compose() { - let compose = DockerCompose { - version: DockerComposeVersion, - services: { - let mut map = BTreeMap::new(); - - let key_pair = KeyPair::generate_with_configuration( - KeyGenConfiguration::default().use_seed(vec![1, 5, 1, 2, 2, 3, 4, 1, 2, 3]), - ) - .unwrap(); - - map.insert( - "iroha0".to_owned(), - DockerComposeService { - platform: PlatformArchitecture, - source: ServiceSource::Build(PathBuf::from(".")), - environment: CompactPeerEnv { - key_pair: key_pair.clone(), - genesis_key_pair: Some(key_pair), - p2p_addr: SocketAddr::from_str("iroha1:1339").unwrap(), - api_addr: SocketAddr::from_str("iroha1:1338").unwrap(), - telemetry_addr: SocketAddr::from_str("iroha1:1337").unwrap(), - trusted_peers: BTreeSet::new(), - } - .into(), - ports: vec![ - PairColon(1337, 1337), - PairColon(8080, 8080), - PairColon(8081, 8081), - ], - volumes: vec![PairColon( - "./configs/peer/legacy_stable".to_owned(), - "/config".to_owned(), - )], - init: AlwaysTrue, - command: ServiceCommand::SubmitGenesis, - }, - ); - - map - }, - }; - - let actual = serde_yaml::to_string(&compose).expect("Should be serialisable"); - let expected = expect_test::expect![[r#" - version: '3.8' - services: - iroha0: - build: . - platform: linux/amd64 - environment: - IROHA_PUBLIC_KEY: ed012039E5BF092186FACC358770792A493CA98A83740643A3D41389483CF334F748C8 - IROHA_PRIVATE_KEY: '{"digest_function":"ed25519","payload":"db9d90d20f969177bd5882f9fe211d14d1399d5440d04e3468783d169bbc4a8e39e5bf092186facc358770792a493ca98a83740643a3d41389483cf334f748c8"}' - TORII_P2P_ADDR: iroha1:1339 - TORII_API_URL: iroha1:1338 - TORII_TELEMETRY_URL: iroha1:1337 - IROHA_GENESIS_ACCOUNT_PUBLIC_KEY: ed012039E5BF092186FACC358770792A493CA98A83740643A3D41389483CF334F748C8 - IROHA_GENESIS_ACCOUNT_PRIVATE_KEY: '{"digest_function":"ed25519","payload":"db9d90d20f969177bd5882f9fe211d14d1399d5440d04e3468783d169bbc4a8e39e5bf092186facc358770792a493ca98a83740643a3d41389483cf334f748c8"}' - SUMERAGI_TRUSTED_PEERS: '[]' - ports: - - 1337:1337 - - 8080:8080 - - 8081:8081 - volumes: - - ./configs/peer/legacy_stable:/config - init: true - command: iroha --submit-genesis - "#]]; - expected.assert_eq(&actual); - } - - #[test] - fn empty_genesis_key_pair_is_skipped_in_env() { - let env: FullPeerEnv = CompactPeerEnv { - key_pair: KeyPair::generate_with_configuration( - KeyGenConfiguration::default().use_seed(vec![0, 1, 2]), - ) - .unwrap(), - genesis_key_pair: None, - p2p_addr: SocketAddr::from_str("iroha0:1337").unwrap(), - api_addr: SocketAddr::from_str("iroha0:1337").unwrap(), - telemetry_addr: SocketAddr::from_str("iroha0:1337").unwrap(), - trusted_peers: BTreeSet::new(), - } - .into(); - - let actual = serde_yaml::to_string(&env).unwrap(); - let expected = expect_test::expect![[r#" - IROHA_PUBLIC_KEY: ed0120415388A90FA238196737746A70565D041CFB32EAA0C89FF8CB244C7F832A6EBD - IROHA_PRIVATE_KEY: '{"digest_function":"ed25519","payload":"6bf163fd75192b81a78cb20c5f8cb917f591ac6635f2577e6ca305c27a456a5d415388a90fa238196737746a70565d041cfb32eaa0c89ff8cb244c7f832a6ebd"}' - TORII_P2P_ADDR: iroha0:1337 - TORII_API_URL: iroha0:1337 - TORII_TELEMETRY_URL: iroha0:1337 - SUMERAGI_TRUSTED_PEERS: '[]' - "#]]; - expected.assert_eq(&actual); - } - } -} - -mod ui { - use std::path::Path; - - use color_eyre::Help; - use owo_colors::OwoColorize; - - use super::{AbsolutePath, Result, FORCE_ARG_SUGGESTION}; - - mod prefix { - use owo_colors::{FgColorDisplay, OwoColorize}; - - pub fn info() -> FgColorDisplay<'static, owo_colors::colors::BrightBlue, &'static str> { - "ℹ".bright_blue() - } - - pub fn success() -> FgColorDisplay<'static, owo_colors::colors::Green, &'static str> { - "✓".green() - } - - pub fn warning() -> FgColorDisplay<'static, owo_colors::colors::Yellow, &'static str> { - "‼".yellow() - } - } - - pub(super) struct UserInterface; - - pub(super) enum PromptAnswer { - Yes, - No, - } - - impl From for PromptAnswer { - fn from(value: bool) -> Self { - if value { - Self::Yes - } else { - Self::No - } - } - } - - #[derive(Copy, Clone)] - pub(super) enum TargetDirectoryAction { - Created, - Recreated, - } - - impl UserInterface { - pub(super) fn new() -> Self { - Self - } - - #[allow(clippy::unused_self)] - pub(super) fn log_target_directory_ready( - &self, - dir: &AbsolutePath, - action: TargetDirectoryAction, - ) { - println!( - "{} {} directory: {}", - prefix::info(), - match action { - TargetDirectoryAction::Created => "Created", - TargetDirectoryAction::Recreated => "Re-created", - }, - dir.display().green().bold() - ); - } - - #[allow(clippy::unused_self)] - pub(super) fn log_default_configuration_is_written(&self, dir: &AbsolutePath) { - println!( - "{} Generated default configuration in {}", - prefix::info(), - dir.display().green().bold() - ); - } - - #[allow(clippy::unused_self)] - pub(super) fn warn_no_default_config(&self, dir: &AbsolutePath) { - println!( - "{} {}\n\n {}\n", - prefix::warning().bold(), - "Config directory is created, but the configuration itself is not.\ - \n Without any configuration, generated peers will be unable to start.\ - \n Don't forget to put the configuration into:" - .yellow(), - dir.display().bold().yellow() - ); - } - - #[allow(clippy::unused_self)] - pub(super) fn prompt_remove_target_dir(&self, dir: &AbsolutePath) -> Result { - inquire::Confirm::new(&format!( - "Directory {} already exists. Remove it?", - dir.display().blue().bold() - )) - .with_default(false) - .prompt() - .suggestion(FORCE_ARG_SUGGESTION) - .map(PromptAnswer::from) - } - - #[allow(clippy::unused_self)] - pub(super) fn prompt_remove_target_file( - &self, - file: &AbsolutePath, - ) -> Result { - inquire::Confirm::new(&format!( - "File {} already exists. Remove it?", - file.display().blue().bold() - )) - .with_default(false) - .prompt() - .suggestion(FORCE_ARG_SUGGESTION) - .map(PromptAnswer::from) - } - - #[allow(clippy::unused_self)] - pub(super) fn log_cloning_repo(&self) { - println!("{} Cloning git repo...", prefix::info()); - } - - pub(super) fn spinner_validator(self) -> SpinnerValidator { - SpinnerValidator::new(self) - } - - #[allow(clippy::unused_self)] - pub(super) fn log_directory_mode_complete(&self, dir: &AbsolutePath, file_raw: &Path) { - println!( - "{} Docker compose configuration is ready at:\n\n {}\ - \n\n You could run `{} {} {}`", - prefix::success(), - dir.display().green().bold(), - "docker compose -f".blue(), - file_raw.display().blue().bold(), - "up".blue(), - ); - } - - #[allow(clippy::unused_self)] - pub(super) fn log_file_mode_complete(&self, file: &AbsolutePath, file_raw: &Path) { - println!( - "{} Docker compose configuration is ready at:\n\n {}\ - \n\n You could run `{} {} {}`", - prefix::success(), - file.display().green().bold(), - "docker compose -f".blue(), - file_raw.display().blue().bold(), - "up".blue(), - ); - } - } - - struct Spinner { - inner: spinoff::Spinner, - ui: UserInterface, - } - - impl Spinner { - fn new(message: impl AsRef, ui: UserInterface) -> Self { - let inner = spinoff::Spinner::new( - spinoff::spinners::Dots, - message.as_ref().to_owned(), - spinoff::Color::White, - ); - - Self { inner, ui } - } - - fn done(self, message: impl AsRef) -> UserInterface { - self.inner - .stop_and_persist(&format!("{}", prefix::success()), message.as_ref()); - self.ui - } - } - - pub(super) struct SpinnerValidator(Spinner); - - impl SpinnerValidator { - fn new(ui: UserInterface) -> Self { - Self(Spinner::new("Constructing the default validator...", ui)) - } - - pub(super) fn done(self) -> UserInterface { - self.0.done("Constructed the validator") - } - } -} - -#[cfg(test)] -mod tests { - use std::path::{Path, PathBuf}; - - use super::{AbsolutePath, Absolutize, DockerComposeBuilder, ResolvedImageSource}; - - impl AbsolutePath { - fn from_virtual(path: &PathBuf, virtual_root: impl AsRef + Sized) -> Self { - let path = path - .absolutize_virtually(virtual_root) - .unwrap() - .to_path_buf(); - Self { path } - } - } - - #[test] - fn relative_inner_path_starts_with_dot() { - let root = PathBuf::from("/"); - let a = AbsolutePath::from_virtual(&PathBuf::from("./a/b/c"), &root); - let b = AbsolutePath::from_virtual(&PathBuf::from("./"), &root); - - assert_eq!(a.relative_to(&b).unwrap(), PathBuf::from("./a/b/c")); - } - - #[test] - fn relative_outer_path_starts_with_dots() { - let root = Path::new("/"); - let a = AbsolutePath::from_virtual(&PathBuf::from("./a/b/c"), root); - let b = AbsolutePath::from_virtual(&PathBuf::from("./cde"), root); - - assert_eq!(b.relative_to(&a).unwrap(), PathBuf::from("../../../cde")); - } - - #[test] - fn generate_peers_deterministically() { - let root = Path::new("/"); - let seed = Some(b"iroha".to_vec()); - let seed = seed.as_deref(); - - let composed = DockerComposeBuilder { - target_file: &AbsolutePath::from_virtual( - &PathBuf::from("/test/docker-compose.yml"), - root, - ), - config_dir: &AbsolutePath::from_virtual(&PathBuf::from("/test/config"), root), - peers: 4.try_into().unwrap(), - image_source: ResolvedImageSource::Build { - path: AbsolutePath::from_virtual(&PathBuf::from("/test/iroha-cloned"), root), - }, - seed, - } - .build() - .expect("should build with no errors"); - - let yaml = serde_yaml::to_string(&composed).unwrap(); - let expected = expect_test::expect![[r#" - version: '3.8' - services: - iroha0: - build: ./iroha-cloned - platform: linux/amd64 - environment: - IROHA_PUBLIC_KEY: ed0120F0321EB4139163C35F88BF78520FF7071499D7F4E79854550028A196C7B49E13 - IROHA_PRIVATE_KEY: '{"digest_function":"ed25519","payload":"5f8d1291bf6b762ee748a87182345d135fd167062857aa4f20ba39f25e74c4b0f0321eb4139163c35f88bf78520ff7071499d7f4e79854550028a196c7b49e13"}' - TORII_P2P_ADDR: iroha0:1337 - TORII_API_URL: iroha0:8080 - TORII_TELEMETRY_URL: iroha0:8180 - IROHA_GENESIS_ACCOUNT_PUBLIC_KEY: ed01203420F48A9EEB12513B8EB7DAF71979CE80A1013F5F341C10DCDA4F6AA19F97A9 - IROHA_GENESIS_ACCOUNT_PRIVATE_KEY: '{"digest_function":"ed25519","payload":"5a6d5f06a90d29ad906e2f6ea8b41b4ef187849d0d397081a4a15ffcbe71e7c73420f48a9eeb12513b8eb7daf71979ce80a1013f5f341c10dcda4f6aa19f97a9"}' - SUMERAGI_TRUSTED_PEERS: '[{"address":"iroha2:1339","public_key":"ed0120312C1B7B5DE23D366ADCF23CD6DB92CE18B2AA283C7D9F5033B969C2DC2B92F4"},{"address":"iroha3:1340","public_key":"ed0120854457B2E3D6082181DA73DC01C1E6F93A72D0C45268DC8845755287E98A5DEE"},{"address":"iroha1:1338","public_key":"ed0120A88554AA5C86D28D0EEBEC497235664433E807881CD31E12A1AF6C4D8B0F026C"},{"address":"iroha0:1337","public_key":"ed0120F0321EB4139163C35F88BF78520FF7071499D7F4E79854550028A196C7B49E13"}]' - ports: - - 1337:1337 - - 8080:8080 - - 8180:8180 - volumes: - - ./config:/config - init: true - command: iroha --submit-genesis - iroha1: - build: ./iroha-cloned - platform: linux/amd64 - environment: - IROHA_PUBLIC_KEY: ed0120A88554AA5C86D28D0EEBEC497235664433E807881CD31E12A1AF6C4D8B0F026C - IROHA_PRIVATE_KEY: '{"digest_function":"ed25519","payload":"8d34d2c6a699c61e7a9d5aabbbd07629029dfb4f9a0800d65aa6570113edb465a88554aa5c86d28d0eebec497235664433e807881cd31e12a1af6c4d8b0f026c"}' - TORII_P2P_ADDR: iroha1:1338 - TORII_API_URL: iroha1:8081 - TORII_TELEMETRY_URL: iroha1:8181 - SUMERAGI_TRUSTED_PEERS: '[{"address":"iroha2:1339","public_key":"ed0120312C1B7B5DE23D366ADCF23CD6DB92CE18B2AA283C7D9F5033B969C2DC2B92F4"},{"address":"iroha3:1340","public_key":"ed0120854457B2E3D6082181DA73DC01C1E6F93A72D0C45268DC8845755287E98A5DEE"},{"address":"iroha1:1338","public_key":"ed0120A88554AA5C86D28D0EEBEC497235664433E807881CD31E12A1AF6C4D8B0F026C"},{"address":"iroha0:1337","public_key":"ed0120F0321EB4139163C35F88BF78520FF7071499D7F4E79854550028A196C7B49E13"}]' - ports: - - 1338:1338 - - 8081:8081 - - 8181:8181 - volumes: - - ./config:/config - init: true - iroha2: - build: ./iroha-cloned - platform: linux/amd64 - environment: - IROHA_PUBLIC_KEY: ed0120312C1B7B5DE23D366ADCF23CD6DB92CE18B2AA283C7D9F5033B969C2DC2B92F4 - IROHA_PRIVATE_KEY: '{"digest_function":"ed25519","payload":"cf4515a82289f312868027568c0da0ee3f0fde7fef1b69deb47b19fde7cbc169312c1b7b5de23d366adcf23cd6db92ce18b2aa283c7d9f5033b969c2dc2b92f4"}' - TORII_P2P_ADDR: iroha2:1339 - TORII_API_URL: iroha2:8082 - TORII_TELEMETRY_URL: iroha2:8182 - SUMERAGI_TRUSTED_PEERS: '[{"address":"iroha2:1339","public_key":"ed0120312C1B7B5DE23D366ADCF23CD6DB92CE18B2AA283C7D9F5033B969C2DC2B92F4"},{"address":"iroha3:1340","public_key":"ed0120854457B2E3D6082181DA73DC01C1E6F93A72D0C45268DC8845755287E98A5DEE"},{"address":"iroha1:1338","public_key":"ed0120A88554AA5C86D28D0EEBEC497235664433E807881CD31E12A1AF6C4D8B0F026C"},{"address":"iroha0:1337","public_key":"ed0120F0321EB4139163C35F88BF78520FF7071499D7F4E79854550028A196C7B49E13"}]' - ports: - - 1339:1339 - - 8082:8082 - - 8182:8182 - volumes: - - ./config:/config - init: true - iroha3: - build: ./iroha-cloned - platform: linux/amd64 - environment: - IROHA_PUBLIC_KEY: ed0120854457B2E3D6082181DA73DC01C1E6F93A72D0C45268DC8845755287E98A5DEE - IROHA_PRIVATE_KEY: '{"digest_function":"ed25519","payload":"ab0e99c2b845b4ac7b3e88d25a860793c7eb600a25c66c75cba0bae91e955aa6854457b2e3d6082181da73dc01c1e6f93a72d0c45268dc8845755287e98a5dee"}' - TORII_P2P_ADDR: iroha3:1340 - TORII_API_URL: iroha3:8083 - TORII_TELEMETRY_URL: iroha3:8183 - SUMERAGI_TRUSTED_PEERS: '[{"address":"iroha2:1339","public_key":"ed0120312C1B7B5DE23D366ADCF23CD6DB92CE18B2AA283C7D9F5033B969C2DC2B92F4"},{"address":"iroha3:1340","public_key":"ed0120854457B2E3D6082181DA73DC01C1E6F93A72D0C45268DC8845755287E98A5DEE"},{"address":"iroha1:1338","public_key":"ed0120A88554AA5C86D28D0EEBEC497235664433E807881CD31E12A1AF6C4D8B0F026C"},{"address":"iroha0:1337","public_key":"ed0120F0321EB4139163C35F88BF78520FF7071499D7F4E79854550028A196C7B49E13"}]' - ports: - - 1340:1340 - - 8083:8083 - - 8183:8183 - volumes: - - ./config:/config - init: true - "#]]; - expected.assert_eq(&yaml); - } -} diff --git a/tools/kagami/src/validator.rs b/tools/kagami/src/validator.rs deleted file mode 100644 index b29d6998a4d..00000000000 --- a/tools/kagami/src/validator.rs +++ /dev/null @@ -1,79 +0,0 @@ -use std::path::{Path, PathBuf}; - -use color_eyre::Result; -use path_absolutize::Absolutize; -use swarm::AbsolutePath; - -use super::*; - -#[derive(ClapArgs, Debug, Clone)] -pub struct Args { - /// Directory path to clone Iroha sources to. Only - /// used in case when `kagami` is run as a separate - /// binary. A temporary directory is created for this - /// purpose if this option is not provided but cloning - /// is still needed. - #[clap(long)] - clone_dir: Option, -} - -impl RunArgs for Args { - fn run(self, writer: &mut BufWriter) -> Outcome { - let clone_dir = match self.clone_dir { - Some(dir) => dir, - None => tempfile::tempdir() - .wrap_err("Failed to generate a tempdir for validator sources")? - .into_path(), - }; - let path = compute_validator_path(clone_dir.as_path())?; - writer - .write_all(&construct_validator(path)?) - .wrap_err("Failed to write wasm validator into the buffer.") - } -} - -/// A function computing where the validator file should be stored depending on whether `kagami` is -/// run as a standalone binary or via cargo. This is determined based on whether -/// the `CARGO_MANIFEST_DIR` env variable is set. -pub fn compute_validator_path(out_dir: impl AsRef) -> Result { - std::env::var("CARGO_MANIFEST_DIR").map_or_else( - |_| { - let out_dir = AbsolutePath::try_from(PathBuf::from(out_dir.as_ref()).join(DIR_CLONE))?; - swarm::shallow_git_clone(GIT_ORIGIN, GIT_REVISION, &out_dir)?; - Ok(out_dir.to_path_buf().join("default_validator")) - }, - |manifest_dir| Ok(Path::new(&manifest_dir).join("../../default_validator")), - ) -} - -/// Variant of [`compute_validator_path()`] to be used via `kagami swarm` to avoid double repo -/// cloning if `swarm` subcommand has already done that. -pub fn compute_validator_path_with_build_dir(build_dir: impl AsRef) -> Result { - let validator_path = build_dir - .as_ref() - .join("default_validator") - .absolutize() - .map(|abs_path| abs_path.to_path_buf()) - .wrap_err_with(|| { - format!( - "Failed to construct absolute path for: {}", - build_dir.as_ref().display(), - ) - }); - // Setting this so that [`Builder`](iroha_wasm_builder::Builder) doesn't pollute - // the swarm output dir with a `target` remnant - std::env::set_var( - "IROHA_WASM_BUILDER_OUT_DIR", - build_dir.as_ref().join("target"), - ); - validator_path -} - -pub fn construct_validator(relative_path: impl AsRef) -> Result> { - let wasm_blob = iroha_wasm_builder::Builder::new(relative_path.as_ref()) - .build()? - .optimize()? - .into_bytes()?; - - Ok(wasm_blob) -} diff --git a/tools/swarm/Cargo.toml b/tools/swarm/Cargo.toml new file mode 100644 index 00000000000..cbd4508bf93 --- /dev/null +++ b/tools/swarm/Cargo.toml @@ -0,0 +1,25 @@ +[package] +name = "iroha_swarm" + +edition.workspace = true +version.workspace = true +authors.workspace = true +license.workspace = true + +[dependencies] +iroha_crypto.workspace = true +iroha_data_model.workspace = true +iroha_primitives.workspace = true +iroha_config.workspace = true +color-eyre.workspace = true +expect-test.workspace = true +path-absolutize.workspace = true +pathdiff.workspace = true +owo-colors = { workspace = true, features = ["supports-colors"] } +serde = { workspace = true, features = ["derive"] } +clap = { workspace = true, features = ["derive"] } +serde_yaml.workspace = true +serde_json.workspace = true +derive_more.workspace = true +inquire.workspace = true + diff --git a/tools/swarm/README.md b/tools/swarm/README.md new file mode 100644 index 00000000000..3db15c69284 --- /dev/null +++ b/tools/swarm/README.md @@ -0,0 +1,44 @@ +# `iroha_swarm` + +CLI to generate Docker Compose configuration. + +## Usage + +```bash +iroha_swarm +``` + +**Options:** + +- **`--outfile `** (required): specify the output file name, e.g. `./docker-compose.yml`. If the file exists, the prompt appears (might be disabled with `--force` option). +- **`--config-dir `** (required): specify the path to the directory containing `config.json` and `genesis.json`. The path to the config will be written into the file specified by `--outfile` relative to its location. +- **Image source** (required): + - **`--image `**: specify image name, like `hyperledger/iroha2:dev` + - **`--build `**: specify path to the Iroha repo +- **`--peers ` (`-p`)** (required): amount of peers to generate +- **`--seed ` (`-s`)** (optional): specify a string to use as a cryptographic seed for keys generation. Allows to generate compose configurations deterministically. UTF-8 bytes of the string will be used. +- **`--force`** (optional): override file specified with `--outfile` if it exists + +## Examples + +Generate `docker-compose.dev.yml` with 5 peers, using `iroha` utf-8 bytes as a cryptographic seed, using `./configs/peer` as a directory with configuration, and using `.` as a directory with `Dockerfile` of Iroha: + +```bash +iroha_swarm \ + --build . \ + --peers 5 \ + --seed iroha \ + --config-dir ./configs/peer \ + --outfile docker-compose.dev.yml +``` + +Same, but using an existing Docker image instead: + +```bash +iroha_swarm \ + --image hyperledger/iroha2:dev \ + --peers 5 \ + --seed iroha \ + --config-dir ./configs/peer \ + --outfile docker-compose.dev.yml +``` diff --git a/tools/swarm/src/cli.rs b/tools/swarm/src/cli.rs new file mode 100644 index 00000000000..ffe08c3fe79 --- /dev/null +++ b/tools/swarm/src/cli.rs @@ -0,0 +1,116 @@ +use std::{num::NonZeroU16, path::PathBuf}; + +use clap::{Args, Parser}; + +#[derive(Parser, Debug)] +pub struct Cli { + /// How many peers to generate within the Docker Compose setup. + #[arg(long, short)] + pub peers: NonZeroU16, + /// The Unicode `seed` string for deterministic key-generation. + #[arg(long, short)] + pub seed: Option, + /// Re-create the target file if it already exists. + #[arg(long)] + pub force: bool, + /// Path to a generated Docker Compose configuration. + /// + /// If file exists, the app will prompt its overwriting. If the TTY is not + /// interactive, the app will stop execution with a non-zero exit code. In order to + /// overwrite the file anyway, pass `--force` flag. + #[arg(long, short)] + pub outfile: PathBuf, + /// Path to a directory with Iroha configuration. It will be mapped as volume for containers. + /// + /// The directory should contain `config.json` and `genesis.json`. + #[arg(long, short)] + pub config_dir: PathBuf, + #[command(flatten)] + pub source: SourceArgs, +} + +#[derive(Args, Debug)] +#[group(required = true, multiple = false)] +pub struct SourceArgs { + /// Use specified docker image. + /// + /// Be careful with specifying a Dockerhub image as a source: Swarm only guarantees that + /// the docker-compose configuration it generates is compatible with the same Git revision it + /// is built from itself. Therefore, if specified image is not compatible with the version of Swarm + /// you are running, the generated configuration might not work. + #[arg(long)] + pub image: Option, + /// Use local path location of the Iroha source code to build images from. + /// + /// If the path is relative, it will be resolved relative to the CWD. + #[arg(long, value_name = "PATH")] + pub build: Option, +} + +pub enum SourceParsed { + Image { name: String }, + Build { path: PathBuf }, +} + +impl From for SourceParsed { + fn from(value: SourceArgs) -> Self { + match value { + SourceArgs { + image: Some(name), + build: None, + } => Self::Image { name }, + SourceArgs { + image: None, + build: Some(path), + } => Self::Build { path }, + _ => unreachable!("clap invariant"), + } + } +} + +#[cfg(test)] +mod tests { + use std::fmt::{Debug, Display, Formatter}; + + use clap::{ArgMatches, Command, Error as ClapError}; + + use super::*; + + struct ClapErrorWrap(ClapError); + + impl From for ClapErrorWrap { + fn from(value: ClapError) -> Self { + Self(value) + } + } + + impl Debug for ClapErrorWrap { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + Display::fmt(&self.0, f) + } + } + + fn match_args(args_str: impl AsRef) -> Result { + let cmd = Command::new("test"); + let cmd = Cli::augment_args(cmd); + let matches = + cmd.try_get_matches_from(std::iter::once("test").chain(args_str.as_ref().split(' ')))?; + Ok(matches) + } + + #[test] + fn work_with_build_source() { + let _ = match_args("-p 20 --build . --config-dir ./config --outfile sample.yml").unwrap(); + } + + #[test] + fn doesnt_allow_multiple_sources() { + let _ = match_args("-p 1 --build . --image hp/iroha --config-dir ./ --outfile test.yml") + .unwrap_err(); + } + + #[test] + fn doesnt_allow_omitting_source() { + let _ = match_args("-p 1 --outfile test.yml --config-dir ./").unwrap_err(); + } +} diff --git a/tools/swarm/src/compose.rs b/tools/swarm/src/compose.rs new file mode 100644 index 00000000000..7be11b69e96 --- /dev/null +++ b/tools/swarm/src/compose.rs @@ -0,0 +1,756 @@ +use std::{ + collections::{BTreeMap, BTreeSet}, + fmt::Display, + fs::File, + io::Write, + num::NonZeroU16, + path::PathBuf, +}; + +use color_eyre::eyre::{eyre, Context, ContextCompat}; +use iroha_crypto::{ + error::Error as IrohaCryptoError, KeyGenConfiguration, KeyPair, PrivateKey, PublicKey, +}; +use iroha_data_model::prelude::PeerId; +use iroha_primitives::addr::SocketAddr; +use peer_generator::Peer; +use serde::{ser::Error as _, Serialize, Serializer}; + +use crate::{cli::SourceParsed, util::AbsolutePath}; + +/// Config directory inside of the docker image +const DIR_CONFIG_IN_DOCKER: &str = "/config"; +const GENESIS_KEYPAIR_SEED: &[u8; 7] = b"genesis"; +const COMMAND_SUBMIT_GENESIS: &str = "iroha --submit-genesis"; +const DOCKER_COMPOSE_VERSION: &str = "3.8"; +const PLATFORM_ARCHITECTURE: &str = "linux/amd64"; + +#[derive(Serialize, Debug)] +pub struct DockerCompose { + version: DockerComposeVersion, + services: BTreeMap, +} + +impl DockerCompose { + pub fn new(services: BTreeMap) -> Self { + Self { + version: DockerComposeVersion, + services, + } + } + + pub fn write_file(&self, path: &PathBuf) -> Result<(), color_eyre::Report> { + let yaml = serde_yaml::to_string(self).wrap_err("Failed to serialise YAML")?; + File::create(path) + .wrap_err_with(|| eyre!("Failed to create file {}", path.display()))? + .write_all(yaml.as_bytes()) + .wrap_err_with(|| eyre!("Failed to write YAML content into {}", path.display()))?; + Ok(()) + } +} + +#[derive(Debug)] +struct DockerComposeVersion; + +impl Serialize for DockerComposeVersion { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + serializer.serialize_str(DOCKER_COMPOSE_VERSION) + } +} + +#[derive(Debug)] +struct PlatformArchitecture; + +impl Serialize for PlatformArchitecture { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + serializer.serialize_str(PLATFORM_ARCHITECTURE) + } +} + +#[derive(Serialize, Debug)] +pub struct DockerComposeService { + #[serde(flatten)] + source: ServiceSource, + platform: PlatformArchitecture, + environment: FullPeerEnv, + ports: Vec>, + volumes: Vec>, + init: AlwaysTrue, + #[serde(skip_serializing_if = "ServiceCommand::is_none")] + command: ServiceCommand, +} + +impl DockerComposeService { + pub fn new( + peer: &Peer, + source: ServiceSource, + volumes: Vec<(String, String)>, + trusted_peers: BTreeSet, + genesis_key_pair: Option, + ) -> Self { + let ports = vec![ + PairColon(peer.port_p2p, peer.port_p2p), + PairColon(peer.port_api, peer.port_api), + PairColon(peer.port_telemetry, peer.port_telemetry), + ]; + + let command = if genesis_key_pair.is_some() { + ServiceCommand::SubmitGenesis + } else { + ServiceCommand::None + }; + + let compact_env = CompactPeerEnv { + trusted_peers, + key_pair: peer.key_pair.clone(), + genesis_key_pair, + p2p_addr: peer.addr(peer.port_p2p), + api_addr: peer.addr(peer.port_api), + telemetry_addr: peer.addr(peer.port_telemetry), + }; + + Self { + source, + platform: PlatformArchitecture, + command, + init: AlwaysTrue, + volumes: volumes.into_iter().map(|(a, b)| PairColon(a, b)).collect(), + ports, + environment: compact_env.into(), + } + } +} + +#[derive(Debug)] +struct AlwaysTrue; + +impl Serialize for AlwaysTrue { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + serializer.serialize_bool(true) + } +} + +#[derive(Debug)] +enum ServiceCommand { + SubmitGenesis, + None, +} + +impl ServiceCommand { + fn is_none(&self) -> bool { + matches!(self, Self::None) + } +} + +impl Serialize for ServiceCommand { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + match self { + Self::None => serializer.serialize_none(), + Self::SubmitGenesis => serializer.serialize_str(COMMAND_SUBMIT_GENESIS), + } + } +} + +/// Serializes as `"{0}:{1}"` +#[derive(derive_more::Display, Debug)] +#[display(fmt = "{_0}:{_1}")] +struct PairColon(T, U) +where + T: Display, + U: Display; + +impl Serialize for PairColon +where + T: Display, + U: Display, +{ + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + serializer.collect_str(self) + } +} + +#[derive(Serialize, Clone, Debug)] +#[serde(rename_all = "lowercase")] +pub enum ServiceSource { + Image(String), + Build(PathBuf), +} + +#[derive(Serialize, Debug)] +#[serde(rename_all = "UPPERCASE")] +struct FullPeerEnv { + iroha_public_key: PublicKey, + iroha_private_key: SerializeAsJsonStr, + torii_p2p_addr: SocketAddr, + torii_api_url: SocketAddr, + torii_telemetry_url: SocketAddr, + #[serde(skip_serializing_if = "Option::is_none")] + iroha_genesis_account_public_key: Option, + #[serde(skip_serializing_if = "Option::is_none")] + iroha_genesis_account_private_key: Option>, + sumeragi_trusted_peers: SerializeAsJsonStr>, +} + +struct CompactPeerEnv { + key_pair: KeyPair, + /// Genesis key pair is only needed for a peer that is submitting the genesis block + genesis_key_pair: Option, + p2p_addr: SocketAddr, + api_addr: SocketAddr, + telemetry_addr: SocketAddr, + trusted_peers: BTreeSet, +} + +impl From for FullPeerEnv { + fn from(value: CompactPeerEnv) -> Self { + let (genesis_public_key, genesis_private_key) = + value.genesis_key_pair.map_or((None, None), |key_pair| { + ( + Some(key_pair.public_key().clone()), + Some(SerializeAsJsonStr(key_pair.private_key().clone())), + ) + }); + + Self { + iroha_public_key: value.key_pair.public_key().clone(), + iroha_private_key: SerializeAsJsonStr(value.key_pair.private_key().clone()), + iroha_genesis_account_public_key: genesis_public_key, + iroha_genesis_account_private_key: genesis_private_key, + torii_p2p_addr: value.p2p_addr, + torii_api_url: value.api_addr, + torii_telemetry_url: value.telemetry_addr, + sumeragi_trusted_peers: SerializeAsJsonStr(value.trusted_peers), + } + } +} + +#[derive(Debug)] +struct SerializeAsJsonStr(T); + +impl serde::Serialize for SerializeAsJsonStr +where + T: serde::Serialize, +{ + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + let json = serde_json::to_string(&self.0).map_err(|json_err| { + S::Error::custom(format!("failed to serialize as JSON: {json_err}")) + })?; + serializer.serialize_str(&json) + } +} + +#[derive(Debug)] +pub struct DockerComposeBuilder<'a> { + /// Needed to compute a relative source build path + pub target_file: &'a AbsolutePath, + /// Needed to put into `volumes` + pub config_dir: &'a AbsolutePath, + pub image_source: ResolvedImageSource, + pub peers: NonZeroU16, + /// Crypto seed to use for keys generation + pub seed: Option<&'a [u8]>, +} + +impl DockerComposeBuilder<'_> { + fn build(&self) -> color_eyre::Result { + let target_file_dir = self.target_file.parent().ok_or_else(|| { + eyre!( + "Cannot get a directory of a file {}", + self.target_file.display() + ) + })?; + + let peers = peer_generator::generate_peers(self.peers, self.seed) + .wrap_err("Failed to generate peers")?; + let genesis_key_pair = generate_key_pair(self.seed, GENESIS_KEYPAIR_SEED) + .wrap_err("Failed to generate genesis key pair")?; + let service_source = match &self.image_source { + ResolvedImageSource::Build { path } => { + ServiceSource::Build(path.relative_to(target_file_dir)?) + } + ResolvedImageSource::Image { name } => ServiceSource::Image(name.clone()), + }; + let volumes = vec![( + self.config_dir + .relative_to(target_file_dir)? + .to_str() + .wrap_err("Config directory path is not a valid string")? + .to_owned(), + DIR_CONFIG_IN_DOCKER.to_owned(), + )]; + + let trusted_peers: BTreeSet = + peers.values().map(peer_generator::Peer::id).collect(); + + let mut peers_iter = peers.iter(); + + let first_peer_service = { + let (name, peer) = peers_iter.next().expect("There is non-zero count of peers"); + let service = DockerComposeService::new( + peer, + service_source.clone(), + volumes.clone(), + trusted_peers.clone(), + Some(genesis_key_pair), + ); + + (name.clone(), service) + }; + + let services = peers_iter + .map(|(name, peer)| { + let service = DockerComposeService::new( + peer, + service_source.clone(), + volumes.clone(), + trusted_peers.clone(), + None, + ); + + (name.clone(), service) + }) + .chain(std::iter::once(first_peer_service)) + .collect(); + + let compose = DockerCompose::new(services); + Ok(compose) + } + + pub(crate) fn build_and_write(&self) -> color_eyre::Result<()> { + let target_file = self.target_file; + let compose = self + .build() + .wrap_err("Failed to build a docker compose file")?; + compose.write_file(&target_file.path) + } +} + +fn generate_key_pair( + base_seed: Option<&[u8]>, + additional_seed: &[u8], +) -> color_eyre::Result { + let cfg = base_seed + .map(|base| { + let seed: Vec<_> = base.iter().chain(additional_seed).copied().collect(); + KeyGenConfiguration::default().use_seed(seed) + }) + .unwrap_or_default(); + + KeyPair::generate_with_configuration(cfg) +} + +mod peer_generator { + use std::{collections::BTreeMap, num::NonZeroU16}; + + use color_eyre::{eyre::Context, Report}; + use iroha_crypto::KeyPair; + use iroha_data_model::prelude::PeerId; + use iroha_primitives::addr::{SocketAddr, SocketAddrHost}; + + const BASE_PORT_P2P: u16 = 1337; + const BASE_PORT_API: u16 = 8080; + const BASE_PORT_TELEMETRY: u16 = 8180; + const BASE_SERVICE_NAME: &'_ str = "iroha"; + + pub struct Peer { + pub name: String, + pub port_p2p: u16, + pub port_api: u16, + pub port_telemetry: u16, + pub key_pair: KeyPair, + } + + impl Peer { + pub fn id(&self) -> PeerId { + PeerId::new(&self.addr(self.port_p2p), self.key_pair.public_key()) + } + + pub fn addr(&self, port: u16) -> SocketAddr { + SocketAddr::Host(SocketAddrHost { + host: self.name.clone().into(), + port, + }) + } + } + + pub fn generate_peers( + peers: NonZeroU16, + base_seed: Option<&[u8]>, + ) -> Result, Report> { + (0u16..peers.get()) + .map(|i| { + let service_name = format!("{BASE_SERVICE_NAME}{i}"); + + let key_pair = super::generate_key_pair(base_seed, service_name.as_bytes()) + .wrap_err("Failed to generate key pair")?; + + let peer = Peer { + name: service_name.clone(), + port_p2p: BASE_PORT_P2P + i, + port_api: BASE_PORT_API + i, + port_telemetry: BASE_PORT_TELEMETRY + i, + key_pair, + }; + + Ok((service_name, peer)) + }) + .collect() + } +} + +#[derive(Debug)] +pub enum ResolvedImageSource { + Image { name: String }, + Build { path: AbsolutePath }, +} + +impl TryFrom for ResolvedImageSource { + type Error = color_eyre::Report; + + fn try_from(value: SourceParsed) -> Result { + let resolved = match value { + SourceParsed::Image { name } => Self::Image { name }, + SourceParsed::Build { path: relative } => { + let absolute = + AbsolutePath::absolutize(&relative).wrap_err("Failed to resolve build path")?; + Self::Build { path: absolute } + } + }; + + Ok(resolved) + } +} + +#[cfg(test)] +mod tests { + use std::{ + cell::RefCell, + collections::{BTreeMap, BTreeSet, HashMap, HashSet}, + env::VarError, + ffi::OsStr, + path::{Path, PathBuf}, + str::FromStr, + }; + + use color_eyre::eyre::Context; + use iroha_config::{ + base::proxy::{FetchEnv, LoadFromEnv, Override}, + iroha::ConfigurationProxy, + }; + use iroha_crypto::{KeyGenConfiguration, KeyPair}; + use iroha_primitives::addr::SocketAddr; + use path_absolutize::Absolutize; + + use super::*; + + impl AbsolutePath { + pub(crate) fn from_virtual(path: &PathBuf, virtual_root: impl AsRef + Sized) -> Self { + let path = path + .absolutize_virtually(virtual_root) + .unwrap() + .to_path_buf(); + Self { path } + } + } + + struct TestEnv { + env: HashMap, + /// Set of env variables that weren't fetched yet + untouched: RefCell>, + } + + impl From for TestEnv { + fn from(peer_env: FullPeerEnv) -> Self { + let json = serde_json::to_string(&peer_env).expect("Must be serializable"); + let env: HashMap<_, _> = + serde_json::from_str(&json).expect("Must be deserializable into a hash map"); + let untouched = env.keys().map(Clone::clone).collect(); + Self { + env, + untouched: RefCell::new(untouched), + } + } + } + + impl From for TestEnv { + fn from(value: CompactPeerEnv) -> Self { + let full: FullPeerEnv = value.into(); + full.into() + } + } + + impl FetchEnv for TestEnv { + fn fetch>(&self, key: K) -> Result { + let key_str = key + .as_ref() + .to_str() + .ok_or_else(|| VarError::NotUnicode(key.as_ref().into()))?; + + let res = self + .env + .get(key_str) + .ok_or(VarError::NotPresent) + .map(std::clone::Clone::clone); + + if res.is_ok() { + self.untouched.borrow_mut().remove(key_str); + } + + res + } + } + + impl TestEnv { + fn assert_everything_covered(&self) { + assert_eq!(*self.untouched.borrow(), HashSet::new()); + } + } + + #[test] + fn default_config_with_swarm_env_are_exhaustive() { + let keypair = KeyPair::generate().unwrap(); + let env: TestEnv = CompactPeerEnv { + key_pair: keypair.clone(), + genesis_key_pair: Some(keypair), + p2p_addr: SocketAddr::from_str("127.0.0.1:1337").unwrap(), + api_addr: SocketAddr::from_str("127.0.0.1:1338").unwrap(), + telemetry_addr: SocketAddr::from_str("127.0.0.1:1339").unwrap(), + trusted_peers: BTreeSet::new(), + } + .into(); + + let proxy = ConfigurationProxy::default() + .override_with(ConfigurationProxy::from_env(&env).expect("valid env")); + + let _cfg = proxy + .build() + .wrap_err("Failed to build configuration") + .expect("Default configuration with swarm's env should be exhaustive"); + + env.assert_everything_covered(); + } + + #[test] + fn serialize_image_source() { + let source = ServiceSource::Image("hyperledger/iroha2:stable".to_owned()); + let serialised = serde_json::to_string(&source).unwrap(); + assert_eq!(serialised, r#"{"image":"hyperledger/iroha2:stable"}"#); + } + + #[test] + fn serialize_docker_compose() { + let compose = DockerCompose { + version: DockerComposeVersion, + services: { + let mut map = BTreeMap::new(); + + let key_pair = KeyPair::generate_with_configuration( + KeyGenConfiguration::default().use_seed(vec![1, 5, 1, 2, 2, 3, 4, 1, 2, 3]), + ) + .unwrap(); + + map.insert( + "iroha0".to_owned(), + DockerComposeService { + platform: PlatformArchitecture, + source: ServiceSource::Build(PathBuf::from(".")), + environment: CompactPeerEnv { + key_pair: key_pair.clone(), + genesis_key_pair: Some(key_pair), + p2p_addr: SocketAddr::from_str("iroha1:1339").unwrap(), + api_addr: SocketAddr::from_str("iroha1:1338").unwrap(), + telemetry_addr: SocketAddr::from_str("iroha1:1337").unwrap(), + trusted_peers: BTreeSet::new(), + } + .into(), + ports: vec![ + PairColon(1337, 1337), + PairColon(8080, 8080), + PairColon(8081, 8081), + ], + volumes: vec![PairColon( + "./configs/peer/legacy_stable".to_owned(), + "/config".to_owned(), + )], + init: AlwaysTrue, + command: ServiceCommand::SubmitGenesis, + }, + ); + + map + }, + }; + + let actual = serde_yaml::to_string(&compose).expect("Should be serialisable"); + let expected = expect_test::expect![[r#" + version: '3.8' + services: + iroha0: + build: . + platform: linux/amd64 + environment: + IROHA_PUBLIC_KEY: ed012039E5BF092186FACC358770792A493CA98A83740643A3D41389483CF334F748C8 + IROHA_PRIVATE_KEY: '{"digest_function":"ed25519","payload":"db9d90d20f969177bd5882f9fe211d14d1399d5440d04e3468783d169bbc4a8e39e5bf092186facc358770792a493ca98a83740643a3d41389483cf334f748c8"}' + TORII_P2P_ADDR: iroha1:1339 + TORII_API_URL: iroha1:1338 + TORII_TELEMETRY_URL: iroha1:1337 + IROHA_GENESIS_ACCOUNT_PUBLIC_KEY: ed012039E5BF092186FACC358770792A493CA98A83740643A3D41389483CF334F748C8 + IROHA_GENESIS_ACCOUNT_PRIVATE_KEY: '{"digest_function":"ed25519","payload":"db9d90d20f969177bd5882f9fe211d14d1399d5440d04e3468783d169bbc4a8e39e5bf092186facc358770792a493ca98a83740643a3d41389483cf334f748c8"}' + SUMERAGI_TRUSTED_PEERS: '[]' + ports: + - 1337:1337 + - 8080:8080 + - 8081:8081 + volumes: + - ./configs/peer/legacy_stable:/config + init: true + command: iroha --submit-genesis + "#]]; + expected.assert_eq(&actual); + } + + #[test] + fn empty_genesis_key_pair_is_skipped_in_env() { + let env: FullPeerEnv = CompactPeerEnv { + key_pair: KeyPair::generate_with_configuration( + KeyGenConfiguration::default().use_seed(vec![0, 1, 2]), + ) + .unwrap(), + genesis_key_pair: None, + p2p_addr: SocketAddr::from_str("iroha0:1337").unwrap(), + api_addr: SocketAddr::from_str("iroha0:1337").unwrap(), + telemetry_addr: SocketAddr::from_str("iroha0:1337").unwrap(), + trusted_peers: BTreeSet::new(), + } + .into(); + + let actual = serde_yaml::to_string(&env).unwrap(); + let expected = expect_test::expect![[r#" + IROHA_PUBLIC_KEY: ed0120415388A90FA238196737746A70565D041CFB32EAA0C89FF8CB244C7F832A6EBD + IROHA_PRIVATE_KEY: '{"digest_function":"ed25519","payload":"6bf163fd75192b81a78cb20c5f8cb917f591ac6635f2577e6ca305c27a456a5d415388a90fa238196737746a70565d041cfb32eaa0c89ff8cb244c7f832a6ebd"}' + TORII_P2P_ADDR: iroha0:1337 + TORII_API_URL: iroha0:1337 + TORII_TELEMETRY_URL: iroha0:1337 + SUMERAGI_TRUSTED_PEERS: '[]' + "#]]; + expected.assert_eq(&actual); + } + + #[test] + fn generate_peers_deterministically() { + let root = Path::new("/"); + let seed = Some(b"iroha".to_vec()); + let seed = seed.as_deref(); + + let composed = DockerComposeBuilder { + target_file: &AbsolutePath::from_virtual( + &PathBuf::from("/test/docker-compose.yml"), + root, + ), + config_dir: &AbsolutePath::from_virtual(&PathBuf::from("/test/config"), root), + peers: 4.try_into().unwrap(), + image_source: ResolvedImageSource::Build { + path: AbsolutePath::from_virtual(&PathBuf::from("/test/iroha-cloned"), root), + }, + seed, + } + .build() + .expect("should build with no errors"); + + let yaml = serde_yaml::to_string(&composed).unwrap(); + let expected = expect_test::expect![[r#" + version: '3.8' + services: + iroha0: + build: ./iroha-cloned + platform: linux/amd64 + environment: + IROHA_PUBLIC_KEY: ed0120F0321EB4139163C35F88BF78520FF7071499D7F4E79854550028A196C7B49E13 + IROHA_PRIVATE_KEY: '{"digest_function":"ed25519","payload":"5f8d1291bf6b762ee748a87182345d135fd167062857aa4f20ba39f25e74c4b0f0321eb4139163c35f88bf78520ff7071499d7f4e79854550028a196c7b49e13"}' + TORII_P2P_ADDR: iroha0:1337 + TORII_API_URL: iroha0:8080 + TORII_TELEMETRY_URL: iroha0:8180 + IROHA_GENESIS_ACCOUNT_PUBLIC_KEY: ed01203420F48A9EEB12513B8EB7DAF71979CE80A1013F5F341C10DCDA4F6AA19F97A9 + IROHA_GENESIS_ACCOUNT_PRIVATE_KEY: '{"digest_function":"ed25519","payload":"5a6d5f06a90d29ad906e2f6ea8b41b4ef187849d0d397081a4a15ffcbe71e7c73420f48a9eeb12513b8eb7daf71979ce80a1013f5f341c10dcda4f6aa19f97a9"}' + SUMERAGI_TRUSTED_PEERS: '[{"address":"iroha2:1339","public_key":"ed0120312C1B7B5DE23D366ADCF23CD6DB92CE18B2AA283C7D9F5033B969C2DC2B92F4"},{"address":"iroha3:1340","public_key":"ed0120854457B2E3D6082181DA73DC01C1E6F93A72D0C45268DC8845755287E98A5DEE"},{"address":"iroha1:1338","public_key":"ed0120A88554AA5C86D28D0EEBEC497235664433E807881CD31E12A1AF6C4D8B0F026C"},{"address":"iroha0:1337","public_key":"ed0120F0321EB4139163C35F88BF78520FF7071499D7F4E79854550028A196C7B49E13"}]' + ports: + - 1337:1337 + - 8080:8080 + - 8180:8180 + volumes: + - ./config:/config + init: true + command: iroha --submit-genesis + iroha1: + build: ./iroha-cloned + platform: linux/amd64 + environment: + IROHA_PUBLIC_KEY: ed0120A88554AA5C86D28D0EEBEC497235664433E807881CD31E12A1AF6C4D8B0F026C + IROHA_PRIVATE_KEY: '{"digest_function":"ed25519","payload":"8d34d2c6a699c61e7a9d5aabbbd07629029dfb4f9a0800d65aa6570113edb465a88554aa5c86d28d0eebec497235664433e807881cd31e12a1af6c4d8b0f026c"}' + TORII_P2P_ADDR: iroha1:1338 + TORII_API_URL: iroha1:8081 + TORII_TELEMETRY_URL: iroha1:8181 + SUMERAGI_TRUSTED_PEERS: '[{"address":"iroha2:1339","public_key":"ed0120312C1B7B5DE23D366ADCF23CD6DB92CE18B2AA283C7D9F5033B969C2DC2B92F4"},{"address":"iroha3:1340","public_key":"ed0120854457B2E3D6082181DA73DC01C1E6F93A72D0C45268DC8845755287E98A5DEE"},{"address":"iroha1:1338","public_key":"ed0120A88554AA5C86D28D0EEBEC497235664433E807881CD31E12A1AF6C4D8B0F026C"},{"address":"iroha0:1337","public_key":"ed0120F0321EB4139163C35F88BF78520FF7071499D7F4E79854550028A196C7B49E13"}]' + ports: + - 1338:1338 + - 8081:8081 + - 8181:8181 + volumes: + - ./config:/config + init: true + iroha2: + build: ./iroha-cloned + platform: linux/amd64 + environment: + IROHA_PUBLIC_KEY: ed0120312C1B7B5DE23D366ADCF23CD6DB92CE18B2AA283C7D9F5033B969C2DC2B92F4 + IROHA_PRIVATE_KEY: '{"digest_function":"ed25519","payload":"cf4515a82289f312868027568c0da0ee3f0fde7fef1b69deb47b19fde7cbc169312c1b7b5de23d366adcf23cd6db92ce18b2aa283c7d9f5033b969c2dc2b92f4"}' + TORII_P2P_ADDR: iroha2:1339 + TORII_API_URL: iroha2:8082 + TORII_TELEMETRY_URL: iroha2:8182 + SUMERAGI_TRUSTED_PEERS: '[{"address":"iroha2:1339","public_key":"ed0120312C1B7B5DE23D366ADCF23CD6DB92CE18B2AA283C7D9F5033B969C2DC2B92F4"},{"address":"iroha3:1340","public_key":"ed0120854457B2E3D6082181DA73DC01C1E6F93A72D0C45268DC8845755287E98A5DEE"},{"address":"iroha1:1338","public_key":"ed0120A88554AA5C86D28D0EEBEC497235664433E807881CD31E12A1AF6C4D8B0F026C"},{"address":"iroha0:1337","public_key":"ed0120F0321EB4139163C35F88BF78520FF7071499D7F4E79854550028A196C7B49E13"}]' + ports: + - 1339:1339 + - 8082:8082 + - 8182:8182 + volumes: + - ./config:/config + init: true + iroha3: + build: ./iroha-cloned + platform: linux/amd64 + environment: + IROHA_PUBLIC_KEY: ed0120854457B2E3D6082181DA73DC01C1E6F93A72D0C45268DC8845755287E98A5DEE + IROHA_PRIVATE_KEY: '{"digest_function":"ed25519","payload":"ab0e99c2b845b4ac7b3e88d25a860793c7eb600a25c66c75cba0bae91e955aa6854457b2e3d6082181da73dc01c1e6f93a72d0c45268dc8845755287e98a5dee"}' + TORII_P2P_ADDR: iroha3:1340 + TORII_API_URL: iroha3:8083 + TORII_TELEMETRY_URL: iroha3:8183 + SUMERAGI_TRUSTED_PEERS: '[{"address":"iroha2:1339","public_key":"ed0120312C1B7B5DE23D366ADCF23CD6DB92CE18B2AA283C7D9F5033B969C2DC2B92F4"},{"address":"iroha3:1340","public_key":"ed0120854457B2E3D6082181DA73DC01C1E6F93A72D0C45268DC8845755287E98A5DEE"},{"address":"iroha1:1338","public_key":"ed0120A88554AA5C86D28D0EEBEC497235664433E807881CD31E12A1AF6C4D8B0F026C"},{"address":"iroha0:1337","public_key":"ed0120F0321EB4139163C35F88BF78520FF7071499D7F4E79854550028A196C7B49E13"}]' + ports: + - 1340:1340 + - 8083:8083 + - 8183:8183 + volumes: + - ./config:/config + init: true + "#]]; + expected.assert_eq(&yaml); + } +} diff --git a/tools/swarm/src/main.rs b/tools/swarm/src/main.rs new file mode 100644 index 00000000000..2f3d832e7c3 --- /dev/null +++ b/tools/swarm/src/main.rs @@ -0,0 +1,56 @@ +mod cli; +mod compose; +mod ui; +mod util; + +use clap::Parser; +use cli::Cli; +use color_eyre::{eyre::Context, Result}; +use util::AbsolutePath; + +use crate::{cli::SourceParsed, compose::ResolvedImageSource}; + +fn main() -> Result<()> { + color_eyre::install()?; + + let Cli { + peers, + seed, + force, + source: image_source, + outfile: target_file_raw, + config_dir: config_dir_raw, + } = Cli::parse(); + + let seed = seed.map(String::into_bytes); + let seed = seed.as_deref(); + + let image_source: ResolvedImageSource = { + let parsed: SourceParsed = image_source.into(); + parsed + .try_into() + .wrap_err("Failed to resolve the source of image")? + }; + + let target_file = AbsolutePath::absolutize(&target_file_raw)?; + let config_dir = AbsolutePath::absolutize(&config_dir_raw)?; + + if target_file.exists() && !force { + if let ui::PromptAnswer::No = ui::prompt_remove_target_file(&target_file)? { + return Ok(()); + } + } + + compose::DockerComposeBuilder { + target_file: &target_file, + config_dir: &config_dir, + image_source, + peers, + seed, + } + .build_and_write()?; + + ui::log_file_mode_complete(&target_file, &target_file_raw); + + Ok(()) +} diff --git a/tools/swarm/src/ui.rs b/tools/swarm/src/ui.rs new file mode 100644 index 00000000000..b67ac8509cd --- /dev/null +++ b/tools/swarm/src/ui.rs @@ -0,0 +1,44 @@ +use std::path::Path; + +use color_eyre::Help; +use owo_colors::OwoColorize; + +use super::Result; +use crate::util::AbsolutePath; + +pub enum PromptAnswer { + Yes, + No, +} + +impl From for PromptAnswer { + fn from(value: bool) -> Self { + if value { + Self::Yes + } else { + Self::No + } + } +} + +pub fn prompt_remove_target_file(file: &AbsolutePath) -> Result { + inquire::Confirm::new(&format!( + "File {} already exists. Remove it?", + file.display().blue().bold() + )) + .with_default(false) + .prompt() + .suggestion("You can pass `--force` flag to remove the file anyway") + .map(PromptAnswer::from) +} + +pub fn log_file_mode_complete(file: &AbsolutePath, file_raw: &Path) { + println!( + "✓ Docker compose configuration is ready at:\n\n {}\ + \n\n You could run `{} {} {}`", + file.display().green().bold(), + "docker compose -f".blue(), + file_raw.display().blue().bold(), + "up".blue(), + ); +} diff --git a/tools/swarm/src/util.rs b/tools/swarm/src/util.rs new file mode 100644 index 00000000000..4855577df8d --- /dev/null +++ b/tools/swarm/src/util.rs @@ -0,0 +1,97 @@ +use std::{ + ffi::OsStr, + ops::Deref, + path::{Path, PathBuf}, +}; + +use color_eyre::{eyre::eyre, Report}; +use path_absolutize::Absolutize; + +#[derive(Clone, Debug)] +pub struct AbsolutePath { + pub path: PathBuf, +} + +impl Deref for AbsolutePath { + type Target = PathBuf; + + fn deref(&self) -> &Self::Target { + &self.path + } +} + +impl AsRef for AbsolutePath { + fn as_ref(&self) -> &Path { + self.path.as_path() + } +} + +impl AsRef for AbsolutePath { + fn as_ref(&self) -> &OsStr { + self.path.as_ref() + } +} + +impl TryFrom for AbsolutePath { + type Error = Report; + + fn try_from(value: PathBuf) -> Result { + Self::absolutize(&value) + } +} + +impl AbsolutePath { + pub fn absolutize(path: &PathBuf) -> color_eyre::Result { + Ok(Self { + path: if path.is_absolute() { + path.clone() + } else { + path.absolutize()?.to_path_buf() + }, + }) + } + + /// Relative path from self to other. + pub fn relative_to(&self, other: &(impl AsRef + ?Sized)) -> color_eyre::Result { + pathdiff::diff_paths(self, other) + .ok_or_else(|| { + eyre!( + "failed to build relative path from {} to {}", + other.as_ref().display(), + self.display(), + ) + }) + // docker-compose might not like "test" path, but "./test" instead + .map(|rel| { + if rel.starts_with("..") { + rel + } else { + Path::new("./").join(rel) + + } + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn relative_inner_path_starts_with_dot() { + let root = PathBuf::from("/"); + let a = AbsolutePath::from_virtual(&PathBuf::from("./a/b/c"), &root); + let b = AbsolutePath::from_virtual(&PathBuf::from("./"), &root); + + assert_eq!(a.relative_to(&b).unwrap(), PathBuf::from("./a/b/c")); + } + + #[test] + fn relative_outer_path_starts_with_dots() { + let root = Path::new("/"); + let a = AbsolutePath::from_virtual(&PathBuf::from("./a/b/c"), root); + let b = AbsolutePath::from_virtual(&PathBuf::from("./cde"), root); + + assert_eq!(b.relative_to(&a).unwrap(), PathBuf::from("../../../cde")); + } +} diff --git a/tools/wasm_builder_cli/Cargo.toml b/tools/wasm_builder_cli/Cargo.toml new file mode 100644 index 00000000000..51f4d0e0e56 --- /dev/null +++ b/tools/wasm_builder_cli/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "iroha_wasm_builder_cli" + +edition.workspace = true +version.workspace = true +authors.workspace = true +license.workspace = true + + +[dependencies] +iroha_wasm_builder.workspace = true + +clap = { workspace = true, features = ["derive"] } +color-eyre.workspace = true +spinoff = { workspace = true, features = ["binary", "dots12"] } +owo-colors = { workspace = true, features = ["supports-colors"] } diff --git a/tools/wasm_builder_cli/README.md b/tools/wasm_builder_cli/README.md new file mode 100644 index 00000000000..9585039b2fc --- /dev/null +++ b/tools/wasm_builder_cli/README.md @@ -0,0 +1,23 @@ +# `iroha_wasm_builder_cli` + +A CLI around [`iroha_wasm_builder`](../wasm_builder) crate. + +## Usage + +**Check the smartcontract:** + +```bash +iroha_wasm_builder_cli check path/to/project +``` + +**Build the smartcontract:** + +```bash +iroha_wasm_builder_cli build path/to/project --outfile ./smartcontract.wasm +``` + +**Build with options:** + +```bash +iroha_wasm_builder_cli build path/to/project --optimize --format --outfile ./smartcontract.wasm +``` diff --git a/tools/wasm_builder_cli/src/main.rs b/tools/wasm_builder_cli/src/main.rs new file mode 100644 index 00000000000..dde4bd00e49 --- /dev/null +++ b/tools/wasm_builder_cli/src/main.rs @@ -0,0 +1,113 @@ +use std::path::PathBuf; + +use clap::{Args, Parser}; +use color_eyre::eyre::{eyre, Context}; +use iroha_wasm_builder::Builder; +use owo_colors::OwoColorize; + +#[derive(Parser, Debug)] +#[command(name = "iroha_wasm_builder_cli", version, author)] +enum Cli { + /// Apply `cargo check` to the smartcontract + Check { + #[command(flatten)] + common: CommonArgs, + }, + /// Build the smartcontract + Build { + #[command(flatten)] + common: CommonArgs, + /// Enable smartcontract formatting using `cargo fmt`. + // TODO: why it is a part of `build` in wasm_builder? + #[arg(long)] + format: bool, + /// Optimize WASM output. + #[arg(long)] + optimize: bool, + /// Where to store the output WASM. If the file exists, it will be overwritten. + #[arg(long)] + outfile: PathBuf, + }, +} + +#[derive(Args, Debug)] +struct CommonArgs { + /// Path to the smartcontract + path: PathBuf, +} + +fn main() -> color_eyre::Result<()> { + match Cli::parse() { + Cli::Check { + common: CommonArgs { path }, + } => { + let builder = Builder::new(&path); + builder.check()?; + } + Cli::Build { + common: CommonArgs { path }, + format, + optimize, + outfile, + } => { + let builder = Builder::new(&path); + let builder = if format { builder.format() } else { builder }; + + let output = { + let sp = spinoff::Spinner::new_with_stream( + spinoff::spinners::Dots12, + "Building the smartcontract", + None, + spinoff::Streams::Stderr, + ); + + match builder.build() { + Ok(output) => { + sp.success("Smartcontract is built"); + output + } + err => { + sp.fail("Building failed"); + err? + } + } + }; + + let output = if optimize { + let sp = spinoff::Spinner::new_with_stream( + spinoff::spinners::Binary, + "Optimizing the output", + None, + spinoff::Streams::Stderr, + ); + + match output.optimize() { + Ok(optimized) => { + sp.success("Output is optimized"); + optimized + } + err => { + sp.fail("Optimization failed"); + err? + } + } + } else { + output + }; + + std::fs::copy(output.wasm_file_path(), &outfile).wrap_err_with(|| { + eyre!( + "Failed to write the resulting file into {}", + outfile.display() + ) + })?; + + println!( + "✓ File is written into {}", + outfile.display().green().bold() + ); + } + } + + Ok(()) +} diff --git a/wasm_builder/src/lib.rs b/wasm_builder/src/lib.rs index 184ae68a9e0..1ddbe8a19dc 100644 --- a/wasm_builder/src/lib.rs +++ b/wasm_builder/src/lib.rs @@ -384,6 +384,11 @@ impl Output { Ok(wasm_data) } + + /// Get the file path of the underlying WASM + pub fn wasm_file_path(&self) -> &PathBuf { + &self.wasm_file + } } // TODO: Remove cargo invocation (#2152)