From c9b52672b1561eca7046fa5e8b19a2d398a84bd9 Mon Sep 17 00:00:00 2001 From: Dmitry Balashov Date: Wed, 7 Jun 2023 10:00:00 +0900 Subject: [PATCH 1/6] [feat]: impl `--outfile`, refactor the rest Signed-off-by: Dmitry Balashov --- tools/kagami/src/swarm.rs | 458 ++++++++++++++++++++++++++------------ 1 file changed, 320 insertions(+), 138 deletions(-) diff --git a/tools/kagami/src/swarm.rs b/tools/kagami/src/swarm.rs index dff49b4bae7..0a723eac9cf 100644 --- a/tools/kagami/src/swarm.rs +++ b/tools/kagami/src/swarm.rs @@ -8,6 +8,7 @@ use std::{ path::{Path, PathBuf}, }; +use clap::ArgGroup; use color_eyre::{ eyre::{eyre, Context, ContextCompat}, Result, @@ -31,27 +32,40 @@ const FILE_VALIDATOR: &str = "validator.wasm"; const FILE_CONFIG: &str = "config.json"; const FILE_GENESIS: &str = "genesis.json"; const FILE_COMPOSE: &str = "docker-compose.yml"; -const DIR_FORCE_SUGGESTION: &str = - "You can pass `--outdir-force` flag to remove the directory without prompting"; +const FORCE_ARG_SUGGESTION: &str = + "You can pass `--force` flag to remove the file/directory without prompting"; const GENESIS_KEYPAIR_SEED: &[u8; 7] = b"genesis"; #[derive(ClapArgs, Debug)] +#[command(group = ArgGroup::new("output").required(true).multiple(false))] +#[command(group = ArgGroup::new("source").required(true).multiple(false))] +#[command(group = ArgGroup::new("output-dir").required(false))] pub struct Args { - #[command(flatten)] - source: ImageSourceArgs, - /// How many peers to generate within the docker-compose. + /// How many peers to generate within the Docker Compose setup. #[arg(long, short)] peers: NonZeroU16, + /// Might be useful for deterministic key generation. + /// + /// It could be any string. Its UTF-8 bytes will be used as a seed. + #[arg(long, short)] + seed: Option, /// 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 `--outdir-force` flag. + /// + /// Example: + /// + /// ```bash + /// kagami swarm --outdir ./compose --peers 4 --image hyperledger/iroha2:lts + /// ``` + #[arg(long, groups = ["output", "output-dir"])] + outdir: Option, + /// Re-create the target directory (for `--outdir`) or file (for `--outfile`) + /// if they already exist. #[arg(long)] - outdir: PathBuf, - /// Re-create the target directory if it already exists. - #[arg(long)] - outdir_force: bool, + force: bool, /// Do not create default configuration in the `/config` directory. /// /// Default `config.json`, `genesis.json` and `validator.wasm` are generated and put into @@ -60,137 +74,270 @@ pub struct Args { /// /// 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)] + #[arg(long, requires = "output-dir")] no_default_configuration: bool, - /// Might be useful for deterministic key generation. + /// Emit only a single Docker Compose configuration into a specified path /// - /// It could be any string. Its UTF-8 bytes will be used as a seed. - #[arg(long, short)] - seed: Option, + /// Example: + /// + /// ```bash + /// kagami swarm --outfile docker-compose.yml --peers 1 --build ~/Git/iroha + /// ``` + #[arg(long, group = "output", requires = "config_dir")] + outfile: Option, + /// TODO + #[arg(long, requires = "output-file")] + config_dir: Option, + /// Use specified docker image. + #[arg(long, group = "source")] + 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", group = "source")] + build: Option, + /// 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, group = "source", requires = "output-dir")] + build_from_github: bool, } impl Args { pub fn run(self) -> Outcome { - let ui = UserInterface::new(); + let parsed: ParsedArgs = self.into(); + parsed.run() + } +} - let prepare_dir_strategy = if self.outdir_force { - PrepareDirectoryStrategy::ForceRecreate - } else { - PrepareDirectoryStrategy::Prompt +/// 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, + seed, + build, + build_from_github, + image, + outfile, + config_dir, + outdir, + force, + no_default_configuration, + }: Args, + ) -> Self { + let mode = match ( + outfile, + config_dir, + outdir, + no_default_configuration, + build_from_github, + ) { + (Some(target_file), Some(config_dir), None, false, false) => ParsedMode::File { + target_file, + config_dir, + image_source: match (build, image) { + (Some(path), None) => SourceForFile::Build { path }, + (None, Some(name)) => SourceForFile::Image { name }, + _ => unreachable!("clap invariant"), + }, + }, + (None, None, Some(path), no_default_configuration, _) => ParsedMode::Directory { + target_dir: path, + no_default_configuration, + image_source: match (build_from_github, build, image) { + (true, None, None) => SourceForDirectory::BuildFromGitHub, + (false, Some(path), None) => { + SourceForDirectory::SameAsForFile(SourceForFile::Build { path }) + } + (false, None, Some(name)) => { + SourceForDirectory::SameAsForFile(SourceForFile::Image { name }) + } + _ => unreachable!("clap invariant"), + }, + }, + _ => unreachable!("clap invariant"), }; - let source = ImageSource::from(self.source); - let target_dir = TargetDirectory::new(AbsolutePath::absolutize(self.outdir)?); - if let EarlyEnding::Halt = target_dir - .prepare(&prepare_dir_strategy, &ui) - .wrap_err("Failed to prepare directory")? - { - return Ok(()); + Self { + peers, + seed, + force, + mode, } + } +} - let config_dir = AbsolutePath::absolutize(target_dir.path.join(DIR_CONFIG))?; +impl ParsedArgs { + pub fn run(self) -> Outcome { + let ui = UserInterface::new(); - let source = source - .resolve(&target_dir, &ui) - .wrap_err("Failed to resolve the source of image")?; + 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_dir = TargetDirectory::new(AbsolutePath::absolutize(target_dir)?); + let config_dir = AbsolutePath::absolutize(target_dir.path.join(DIR_CONFIG))?; + let target_file = AbsolutePath::absolutize(target_dir.path.join(FILE_COMPOSE))?; + + let prepare_dir_strategy = if force { + PrepareDirectoryStrategy::ForceRecreate + } else { + PrepareDirectoryStrategy::Prompt + }; - let ui = if self.no_default_configuration { - PrepareConfigurationStrategy::GenerateOnlyDirectory - } else { - PrepareConfigurationStrategy::GenerateDefault - } - .run(&config_dir, ui) - .wrap_err("Failed to prepare configuration")?; + if let EarlyEnding::Halt = target_dir + .prepare(&prepare_dir_strategy, &ui) + .wrap_err("Failed to prepare directory")? + { + return Ok(()); + } - DockerComposeBuilder { - target_dir: target_dir.path.clone(), - config_dir, - source, - peers: self.peers, - seed: self.seed.map(String::into_bytes), - } - .build() - .wrap_err("Failed to build docker compose")? - .write_file(&target_dir.path.join(FILE_COMPOSE)) - .wrap_err("Failed to write compose file")?; + let image_source = image_source + .resolve(&target_dir, &ui) + .wrap_err("Failed to resolve the source of image")?; - ui.log_complete(&target_dir.path); + let ui = if no_default_configuration { + PrepareConfigurationStrategy::GenerateOnlyDirectory + } else { + PrepareConfigurationStrategy::GenerateDefault + } + .run(&config_dir, ui) + .wrap_err("Failed to prepare configuration")?; + + DockerComposeBuilder { + target_file: &target_file, + config_dir: &config_dir, + image_source, + peers, + seed, + } + .build_and_write()?; - Ok(()) - } -} + ui.log_directory_mode_complete(&target_dir.path); -#[derive(ClapArgs, Clone, Debug)] -#[group(required = true, multiple = false)] -struct ImageSourceArgs { - /// Use specified docker image. - #[arg(long)] - 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")] - build: Option, - /// Clone `hyperledger/iroha` repo from the revision Kagami is built itself, - /// and use the cloned source code to build images from. - #[arg(long)] - build_from_github: bool, -} + Ok(()) + } + ParsedMode::File { + target_file, + config_dir, + image_source, + } => { + let target_file = AbsolutePath::absolutize(target_file)?; + 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(()); + } + } -/// Parsed version of [`ImageSourceArgs`] -#[derive(Clone, Debug)] -enum ImageSource { - Image { name: String }, - GitHub { revision: String }, - Path(PathBuf), -} + let image_source = image_source + .resolve() + .wrap_err("Failed to resolve the source of image")?; -impl From for ImageSource { - fn from(args: ImageSourceArgs) -> Self { - match args { - ImageSourceArgs { - image: Some(name), .. - } => Self::Image { name }, - ImageSourceArgs { - build_from_github: true, - .. - } => Self::GitHub { - revision: GIT_REVISION.to_owned(), - }, - ImageSourceArgs { - build: Some(path), .. - } => Self::Path(path), - _ => unreachable!("Clap must ensure the invariant"), + DockerComposeBuilder { + target_file: &target_file, + config_dir: &config_dir, + image_source, + peers, + seed, + } + .build_and_write()?; + + ui.log_file_mode_complete(&target_file); + + Ok(()) + } } } } -impl ImageSource { - /// Has a side effect: if self is [`Self::GitHub`], it clones the repo into +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 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 { - let source = match self { - Self::Path(path) => ResolvedImageSource::Build { - path: AbsolutePath::absolutize(path).wrap_err("Failed to resolve build path")?, - }, - Self::GitHub { revision } => { + 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, revision, &clone_dir) + shallow_git_clone(GIT_ORIGIN, GIT_REVISION, &clone_dir) .wrap_err("Failed to clone the repo")?; - ResolvedImageSource::Build { path: clone_dir } + Ok(ResolvedImageSource::Build { path: clone_dir }) } + } + } +} + +enum SourceForFile { + Image { name: String }, + Build { path: PathBuf }, +} + +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(source) + Ok(resolved) } } +#[derive(Debug)] +enum ResolvedImageSource { + Image { name: String }, + Build { path: AbsolutePath }, +} + fn shallow_git_clone( remote: impl AsRef, revision: impl AsRef, @@ -220,12 +367,6 @@ fn shallow_git_clone( Ok(()) } -#[derive(Debug)] -enum ResolvedImageSource { - Image { name: String }, - Build { path: AbsolutePath }, -} - enum PrepareConfigurationStrategy { GenerateDefault, GenerateOnlyDirectory, @@ -370,31 +511,32 @@ impl TargetDirectory { } #[derive(Debug)] -struct DockerComposeBuilder { - target_dir: AbsolutePath, - config_dir: AbsolutePath, - source: ResolvedImageSource, +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, - seed: Option>, + /// Crypto seed to use for keys generation + seed: Option<&'a [u8]>, } -impl DockerComposeBuilder { +impl DockerComposeBuilder<'_> { fn build(&self) -> Result { - let base_seed = self.seed.as_deref(); - - let peers = peer_generator::generate_peers(self.peers, base_seed) + let peers = peer_generator::generate_peers(self.peers, self.seed) .wrap_err("Failed to generate peers")?; - let genesis_key_pair = generate_key_pair(base_seed, GENESIS_KEYPAIR_SEED) + 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.source { + let service_source = match &self.image_source { ResolvedImageSource::Build { path } => { - ServiceSource::Build(path.relative_to(&self.target_dir)?) + ServiceSource::Build(path.relative_to(self.target_file)?) } ResolvedImageSource::Image { name } => ServiceSource::Image(name.clone()), }; let volumes = vec![( self.config_dir - .relative_to(&self.target_dir)? + .relative_to(self.target_file)? .to_str() .wrap_err("Config directory path is not a valid string")? .to_owned(), @@ -437,6 +579,14 @@ impl DockerComposeBuilder { 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)] @@ -586,7 +736,7 @@ mod serialize_docker_compose { use iroha_primitives::addr::SocketAddr; use serde::{ser::Error as _, Serialize, Serializer}; - use crate::swarm::peer_generator::Peer; + use super::peer_generator::Peer; const COMMAND_SUBMIT_GENESIS: &str = "iroha --submit-genesis"; const DOCKER_COMPOSE_VERSION: &str = "3.8"; @@ -608,9 +758,9 @@ mod serialize_docker_compose { 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))? + .wrap_err_with(|| eyre!("Failed to create file {}", path.display()))? .write_all(yaml.as_bytes()) - .wrap_err("Failed to write YAML content")?; + .wrap_err_with(|| eyre!("Failed to write YAML content into {}", path.display()))?; Ok(()) } } @@ -1022,7 +1172,7 @@ mod ui { use owo_colors::OwoColorize; use super::{AbsolutePath, Result}; - use crate::swarm::DIR_FORCE_SUGGESTION; + use crate::swarm::FORCE_ARG_SUGGESTION; mod prefix { use owo_colors::{FgColorDisplay, OwoColorize}; @@ -1047,6 +1197,16 @@ mod ui { 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, @@ -1105,14 +1265,23 @@ mod ui { )) .with_default(false) .prompt() - .suggestion(DIR_FORCE_SUGGESTION) - .map(|flag| { - if flag { - PromptAnswer::Yes - } else { - PromptAnswer::No - } - }) + .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)] @@ -1125,7 +1294,7 @@ mod ui { } #[allow(clippy::unused_self)] - pub(super) fn log_complete(&self, dir: &AbsolutePath) { + pub(super) fn log_directory_mode_complete(&self, dir: &AbsolutePath) { println!( "{} Docker compose configuration is ready at:\n\n {}\ \n\n You could `{}` in it.", @@ -1134,6 +1303,18 @@ mod ui { "docker compose up".blue() ); } + + #[allow(clippy::unused_self)] + pub(super) fn log_file_mode_complete(&self, file: &AbsolutePath) { + println!( + "{} Docker compose configuration is ready at:\n\n {}\ + \n\n You could run `{} {}`", + prefix::success(), + file.display().green().bold(), + "docker compose up -f".blue(), + file.display().blue().bold() + ); + } } struct Spinner { @@ -1209,16 +1390,17 @@ mod tests { #[test] fn generate_peers_deterministically() { let root = Path::new("/"); - let seed: Vec<_> = b"iroha".to_vec(); + let seed = Some(b"iroha".to_vec()); + let seed = seed.as_deref(); let composed = DockerComposeBuilder { - target_dir: AbsolutePath::from_virtual(&PathBuf::from("/test"), root), - config_dir: AbsolutePath::from_virtual(&PathBuf::from("/test/config"), root), + target_file: &AbsolutePath::from_virtual(&PathBuf::from("/test"), root), + config_dir: &AbsolutePath::from_virtual(&PathBuf::from("/test/config"), root), peers: 4.try_into().unwrap(), - source: ResolvedImageSource::Build { + image_source: ResolvedImageSource::Build { path: AbsolutePath::from_virtual(&PathBuf::from("/test/iroha-cloned"), root), }, - seed: Some(seed), + seed, } .build() .expect("should build with no errors"); From 42d6bb77721814fa41a2dc89800a1e6a646d07c0 Mon Sep 17 00:00:00 2001 From: Dmitry Balashov Date: Thu, 8 Jun 2023 09:00:44 +0900 Subject: [PATCH 2/6] [refactor]: refactor CLI; chores Signed-off-by: Dmitry Balashov --- tools/kagami/src/main.rs | 18 +- tools/kagami/src/swarm.rs | 398 ++++++++++++++++++++++++-------------- 2 files changed, 257 insertions(+), 159 deletions(-) diff --git a/tools/kagami/src/main.rs b/tools/kagami/src/main.rs index 42959ce0eeb..d0acb37e61e 100644 --- a/tools/kagami/src/main.rs +++ b/tools/kagami/src/main.rs @@ -58,23 +58,7 @@ pub enum Args { Docs(Box), /// Generate the default validator Validator(validator::Args), - /// Generate a docker-compose configuration for a variable number of peers - /// using a Dockerhub image, GitHub repo, or a local Iroha repo. - /// - /// This command builds the 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. - /// - /// 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. + /// Generate Docker Compose configuration Swarm(swarm::Args), } diff --git a/tools/kagami/src/swarm.rs b/tools/kagami/src/swarm.rs index 0a723eac9cf..cfae6729710 100644 --- a/tools/kagami/src/swarm.rs +++ b/tools/kagami/src/swarm.rs @@ -36,74 +36,216 @@ const FORCE_ARG_SUGGESTION: &str = "You can pass `--force` flag to remove the file/directory without prompting"; const GENESIS_KEYPAIR_SEED: &[u8; 7] = b"genesis"; -#[derive(ClapArgs, Debug)] -#[command(group = ArgGroup::new("output").required(true).multiple(false))] -#[command(group = ArgGroup::new("source").required(true).multiple(false))] -#[command(group = ArgGroup::new("output-dir").required(false))] -pub struct Args { - /// How many peers to generate within the Docker Compose setup. - #[arg(long, short)] - peers: NonZeroU16, - /// Might be useful for deterministic key generation. - /// - /// It could be any string. Its UTF-8 bytes will be used as a seed. - #[arg(long, short)] - seed: Option, - /// 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 `--outdir-force` flag. - /// - /// Example: - /// - /// ```bash - /// kagami swarm --outdir ./compose --peers 4 --image hyperledger/iroha2:lts - /// ``` - #[arg(long, groups = ["output", "output-dir"])] - outdir: Option, - /// Re-create the target directory (for `--outdir`) or file (for `--outfile`) - /// if they already exist. - #[arg(long)] - force: bool, - /// 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, requires = "output-dir")] - no_default_configuration: bool, - /// Emit only a single Docker Compose configuration into a specified path - /// - /// Example: - /// - /// ```bash - /// kagami swarm --outfile docker-compose.yml --peers 1 --build ~/Git/iroha - /// ``` - #[arg(long, group = "output", requires = "config_dir")] - outfile: Option, - /// TODO - #[arg(long, requires = "output-file")] - config_dir: Option, - /// Use specified docker image. - #[arg(long, group = "source")] - 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", group = "source")] - build: Option, - /// 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, group = "source", requires = "output-dir")] - build_from_github: bool, +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, + /// Might be useful for deterministic key generation. + /// + /// It could be any string. Its UTF-8 bytes will be used as a seed. + #[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. + /// + /// Example: + /// + /// ```bash + /// kagami swarm --outdir ./compose --peers 4 --image hyperledger/iroha2:lts + /// ``` + #[arg(long)] + 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. + #[arg(long)] + 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::{ + ffi::OsString, + fmt::{Debug, Display, Formatter}, + }; + + use clap::{ArgMatches, Command, Error as ClapError, FromArgMatches}; + use expect_test::expect; + + 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 mut 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 --outfile sample.yml") + .unwrap(); + } + + #[test] + fn works_in_dir_mode_with_github_source() { + let _ = match_args("-p 20 dir --build-from-github --outdir swarm").unwrap(); + } + + #[test] + fn doesnt_allow_config_dir_for_dir_mode() { + let _ = match_args("-p 1 dir --build-from-github --outdir swarm --config-dir ./") + .unwrap_err(); + } + + #[test] + fn doesnt_allow_multiple_sources_in_dir_mode() { + let _ = + match_args("-p 1 dir --build-from-github --build . --outdir swarm").unwrap_err(); + } + + #[test] + fn doesnt_allow_multiple_sources_in_file_mode() { + let _ = match_args( + "-p 1 file --build . --image hp/iroha --outfile test.yml --config-dir ./", + ) + .unwrap_err(); + } + + #[test] + fn doesnt_allow_github_source_in_file_mode() { + let _ = match_args("-p 1 file --build-from-github --outfile test.yml --config-dir ./") + .unwrap_err(); + } + + #[test] + fn doesnt_allow_omitting_source_in_dir_mode() { + let _ = match_args("-p 1 dir --outdir ./test").unwrap_err(); + } + + #[test] + fn doesnt_allow_omitting_source_in_file_mode() { + let _ = match_args("-p 1 file --outfile test.yml --config-dir ./").unwrap_err(); + } + } } +pub use clap_args::SwarmArgs as Args; + impl Args { pub fn run(self) -> Outcome { let parsed: ParsedArgs = self.into(); @@ -121,59 +263,8 @@ struct ParsedArgs { } impl From for ParsedArgs { - fn from( - Args { - peers, - seed, - build, - build_from_github, - image, - outfile, - config_dir, - outdir, - force, - no_default_configuration, - }: Args, - ) -> Self { - let mode = match ( - outfile, - config_dir, - outdir, - no_default_configuration, - build_from_github, - ) { - (Some(target_file), Some(config_dir), None, false, false) => ParsedMode::File { - target_file, - config_dir, - image_source: match (build, image) { - (Some(path), None) => SourceForFile::Build { path }, - (None, Some(name)) => SourceForFile::Image { name }, - _ => unreachable!("clap invariant"), - }, - }, - (None, None, Some(path), no_default_configuration, _) => ParsedMode::Directory { - target_dir: path, - no_default_configuration, - image_source: match (build_from_github, build, image) { - (true, None, None) => SourceForDirectory::BuildFromGitHub, - (false, Some(path), None) => { - SourceForDirectory::SameAsForFile(SourceForFile::Build { path }) - } - (false, None, Some(name)) => { - SourceForDirectory::SameAsForFile(SourceForFile::Image { name }) - } - _ => unreachable!("clap invariant"), - }, - }, - _ => unreachable!("clap invariant"), - }; - - Self { - peers, - seed, - force, - mode, - } + fn from(args: Args) -> Self { + todo!() } } @@ -196,9 +287,10 @@ impl ParsedArgs { no_default_configuration, image_source, } => { - 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_dir.path.join(FILE_COMPOSE))?; + 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 { PrepareDirectoryStrategy::ForceRecreate @@ -234,7 +326,7 @@ impl ParsedArgs { } .build_and_write()?; - ui.log_directory_mode_complete(&target_dir.path); + ui.log_directory_mode_complete(&target_dir.path, &target_file_raw); Ok(()) } @@ -243,8 +335,9 @@ impl ParsedArgs { config_dir, image_source, } => { - let target_file = AbsolutePath::absolutize(target_file)?; - let config_dir = AbsolutePath::absolutize(config_dir)?; + 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)? { @@ -265,7 +358,7 @@ impl ParsedArgs { } .build_and_write()?; - ui.log_file_mode_complete(&target_file); + ui.log_file_mode_complete(&target_file, &target_file_raw); Ok(()) } @@ -299,7 +392,7 @@ impl SourceForDirectory { 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)?; + let clone_dir = AbsolutePath::absolutize(&clone_dir)?; ui.log_cloning_repo(); @@ -323,7 +416,7 @@ impl SourceForFile { Self::Image { name } => ResolvedImageSource::Image { name }, Self::Build { path: relative } => { let absolute = - AbsolutePath::absolutize(relative).wrap_err("Failed to resolve build path")?; + AbsolutePath::absolutize(&relative).wrap_err("Failed to resolve build path")?; ResolvedImageSource::Build { path: absolute } } }; @@ -524,19 +617,26 @@ struct DockerComposeBuilder<'a> { 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(self.target_file)?) + ServiceSource::Build(path.relative_to(target_file_dir)?) } ResolvedImageSource::Image { name } => ServiceSource::Image(name.clone()), }; let volumes = vec![( self.config_dir - .relative_to(self.target_file)? + .relative_to(target_file_dir)? .to_str() .wrap_err("Config directory path is not a valid string")? .to_owned(), @@ -615,10 +715,10 @@ impl AsRef for AbsolutePath { } impl AbsolutePath { - fn absolutize(path: PathBuf) -> Result { + fn absolutize(path: &PathBuf) -> Result { Ok(Self { path: if path.is_absolute() { - path + path.clone() } else { path.absolutize()?.to_path_buf() }, @@ -626,12 +726,12 @@ impl AbsolutePath { } /// Relative path from self to other. - fn relative_to(&self, other: &AbsolutePath) -> Result { + 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.display(), + other.as_ref().display(), self.display(), ) }) @@ -1294,25 +1394,36 @@ mod ui { } #[allow(clippy::unused_self)] - pub(super) fn log_directory_mode_complete(&self, dir: &AbsolutePath) { + pub(super) fn log_directory_mode_complete( + &self, + dir: &AbsolutePath, + file_raw: &std::path::PathBuf, + ) { println!( "{} Docker compose configuration is ready at:\n\n {}\ - \n\n You could `{}` in it.", + \n\n You could run `{} {} {}`", prefix::success(), dir.display().green().bold(), - "docker compose up".blue() + "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) { + pub(super) fn log_file_mode_complete( + &self, + file: &AbsolutePath, + file_raw: &std::path::PathBuf, + ) { println!( "{} Docker compose configuration is ready at:\n\n {}\ - \n\n You could run `{} {}`", + \n\n You could run `{} {} {}`", prefix::success(), file.display().green().bold(), - "docker compose up -f".blue(), - file.display().blue().bold() + "docker compose -f".blue(), + file_raw.display().blue().bold(), + "up".blue(), ); } } @@ -1394,7 +1505,10 @@ mod tests { let seed = seed.as_deref(); let composed = DockerComposeBuilder { - target_file: &AbsolutePath::from_virtual(&PathBuf::from("/test"), root), + 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 { From 14a3b36007546c66f667fea4013f4f0ee10f9542 Mon Sep 17 00:00:00 2001 From: Dmitry Balashov Date: Thu, 8 Jun 2023 09:16:07 +0900 Subject: [PATCH 3/6] [feat]: parse arguments Signed-off-by: Dmitry Balashov --- tools/kagami/src/swarm.rs | 109 +++++++++++++++++++++++++++++--------- 1 file changed, 85 insertions(+), 24 deletions(-) diff --git a/tools/kagami/src/swarm.rs b/tools/kagami/src/swarm.rs index cfae6729710..8f425bfa79d 100644 --- a/tools/kagami/src/swarm.rs +++ b/tools/kagami/src/swarm.rs @@ -8,7 +8,6 @@ use std::{ path::{Path, PathBuf}, }; -use clap::ArgGroup; use color_eyre::{ eyre::{eyre, Context, ContextCompat}, Result, @@ -19,7 +18,7 @@ use path_absolutize::Absolutize; use serialize_docker_compose::{DockerCompose, DockerComposeService, ServiceSource}; use ui::UserInterface; -use super::{ClapArgs, Outcome}; +use super::Outcome; const GIT_REVISION: &str = env!("VERGEN_GIT_SHA"); const GIT_ORIGIN: &str = "https://github.com/hyperledger/iroha.git"; @@ -162,13 +161,9 @@ mod clap_args { #[cfg(test)] mod tests { - use std::{ - ffi::OsString, - fmt::{Debug, Display, Formatter}, - }; + use std::fmt::{Debug, Display, Formatter}; - use clap::{ArgMatches, Command, Error as ClapError, FromArgMatches}; - use expect_test::expect; + use clap::{ArgMatches, Command, Error as ClapError}; use super::*; @@ -188,9 +183,9 @@ mod clap_args { fn match_args(args_str: impl AsRef) -> Result { let cmd = Command::new("test"); - let mut cmd = SwarmArgs::augment_args(cmd); + let cmd = SwarmArgs::augment_args(cmd); let matches = cmd.try_get_matches_from( - std::iter::once("test").chain(args_str.as_ref().split(" ")), + std::iter::once("test").chain(args_str.as_ref().split(' ')), )?; Ok(matches) } @@ -245,6 +240,7 @@ mod clap_args { } pub use clap_args::SwarmArgs as Args; +use clap_args::{ModeDirSource, ModeFileSource}; impl Args { pub fn run(self) -> Outcome { @@ -263,8 +259,41 @@ struct ParsedArgs { } impl From for ParsedArgs { - fn from(args: Args) -> Self { - todo!() + 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, + } } } @@ -384,6 +413,29 @@ enum SourceForDirectory { 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. @@ -410,6 +462,22 @@ enum SourceForFile { 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 { @@ -1268,11 +1336,12 @@ mod serialize_docker_compose { } mod ui { + use std::path::Path; + use color_eyre::Help; use owo_colors::OwoColorize; - use super::{AbsolutePath, Result}; - use crate::swarm::FORCE_ARG_SUGGESTION; + use super::{AbsolutePath, Result, FORCE_ARG_SUGGESTION}; mod prefix { use owo_colors::{FgColorDisplay, OwoColorize}; @@ -1394,11 +1463,7 @@ mod ui { } #[allow(clippy::unused_self)] - pub(super) fn log_directory_mode_complete( - &self, - dir: &AbsolutePath, - file_raw: &std::path::PathBuf, - ) { + 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 `{} {} {}`", @@ -1411,11 +1476,7 @@ mod ui { } #[allow(clippy::unused_self)] - pub(super) fn log_file_mode_complete( - &self, - file: &AbsolutePath, - file_raw: &std::path::PathBuf, - ) { + 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 `{} {} {}`", From 61c35ea420d3bacf272b754de437f09f6e3b231c Mon Sep 17 00:00:00 2001 From: Dmitry Balashov Date: Thu, 8 Jun 2023 09:29:31 +0900 Subject: [PATCH 4/6] [refactor]: make `--outfile` and `--outdir` positional Signed-off-by: Dmitry Balashov --- tools/kagami/src/swarm.rs | 33 ++++++++++----------------------- 1 file changed, 10 insertions(+), 23 deletions(-) diff --git a/tools/kagami/src/swarm.rs b/tools/kagami/src/swarm.rs index 8f425bfa79d..046c7531976 100644 --- a/tools/kagami/src/swarm.rs +++ b/tools/kagami/src/swarm.rs @@ -80,13 +80,6 @@ mod clap_args { /// 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. - /// - /// Example: - /// - /// ```bash - /// kagami swarm --outdir ./compose --peers 4 --image hyperledger/iroha2:lts - /// ``` - #[arg(long)] outdir: PathBuf, /// Do not create default configuration in the `/config` directory. /// @@ -109,7 +102,6 @@ mod clap_args { /// 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. - #[arg(long)] outfile: PathBuf, /// Path to a directory with Iroha configuration. It will be mapped as volume for containers. /// @@ -192,49 +184,44 @@ mod clap_args { #[test] fn works_in_file_mode() { - let _ = match_args("-p 20 file --build . --config-dir ./config --outfile sample.yml") - .unwrap(); + 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 --outdir swarm").unwrap(); + 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 --outdir swarm --config-dir ./") - .unwrap_err(); + 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 . --outdir swarm").unwrap_err(); + 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 --outfile test.yml --config-dir ./", - ) - .unwrap_err(); + 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 --outfile test.yml --config-dir ./") - .unwrap_err(); + 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 --outdir ./test").unwrap_err(); + let _ = match_args("-p 1 dir ./test").unwrap_err(); } #[test] fn doesnt_allow_omitting_source_in_file_mode() { - let _ = match_args("-p 1 file --outfile test.yml --config-dir ./").unwrap_err(); + let _ = match_args("-p 1 file test.yml --config-dir ./").unwrap_err(); } } } From 4cff05a9535b23d072392197bef6e0d8dc92f9e7 Mon Sep 17 00:00:00 2001 From: 0x009922 Date: Mon, 26 Jun 2023 07:50:54 +0700 Subject: [PATCH 5/6] Apply suggestions from code review Co-authored-by: Aleksandr Petrosyan Signed-off-by: 0x009922 --- tools/kagami/src/swarm.rs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tools/kagami/src/swarm.rs b/tools/kagami/src/swarm.rs index 046c7531976..d224c261c14 100644 --- a/tools/kagami/src/swarm.rs +++ b/tools/kagami/src/swarm.rs @@ -45,9 +45,10 @@ mod clap_args { /// How many peers to generate within the Docker Compose setup. #[arg(long, short)] pub peers: NonZeroU16, - /// Might be useful for deterministic key generation. + /// Used for deterministic key-generation. /// - /// It could be any string. Its UTF-8 bytes will be used as a seed. + /// Any valid UTF-8 sequence is acceptable. + // 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) From e8124de10dbcd03822599ebabe0f4ff2ddc1b4c5 Mon Sep 17 00:00:00 2001 From: Dmitry Balashov Date: Mon, 26 Jun 2023 18:46:40 +0700 Subject: [PATCH 6/6] [chore]: fix format Signed-off-by: Dmitry Balashov --- tools/kagami/src/swarm.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tools/kagami/src/swarm.rs b/tools/kagami/src/swarm.rs index d224c261c14..1b486a37bd9 100644 --- a/tools/kagami/src/swarm.rs +++ b/tools/kagami/src/swarm.rs @@ -45,10 +45,10 @@ mod clap_args { /// How many peers to generate within the Docker Compose setup. #[arg(long, short)] pub peers: NonZeroU16, - /// Used for deterministic key-generation. + /// Used for deterministic key-generation. /// - /// Any valid UTF-8 sequence is acceptable. - // TODO: Check for length limitations, and if non-UTF-8 sequences are working. + /// Any valid UTF-8 sequence is acceptable. + // 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)