diff --git a/Cargo.toml b/Cargo.toml index 4fa1b13d..64548b8d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -45,6 +45,7 @@ anyhow = "1.0" chrono = { version = "0.4", features = [ "serde" ] } clap = { version = "4.0", features = [ "derive" ] } derive_more = "0.99" +figment = { version = "0.10", features = [ "json" ] } rust-embed = "8.0" serde = { version = "1.0", features = [ "derive" ] } serde_json = "1.0" diff --git a/project-words.txt b/project-words.txt index 8d077bf1..ee851649 100644 --- a/project-words.txt +++ b/project-words.txt @@ -1,28 +1,52 @@ AAAAB +Avalonia +CIFS +Cockburn +Crossplane +Dockerfiles +EAAAADAQABAAABAQC +EPEL +Gossman +Hostnames +MAAACBA +MVVM +NOPASSWD +OAAAAN +Pulumi +RAII +RUSTDOCFLAGS +Repomix +Rustdoc +SCRIPTDIR +Scriptability +Silverlight +Subissue +Taplo +Tera +Testcontain +Testcontainers +Testinfra +Torrust addgroup adduser appender appendonly architecting autorestart -Avalonia buildx chdir childlogdir chkdsk chrono -CIFS clippy clonable cloneable cloudinit -Cockburn concepsts connrefused containerd cpus creds -Crossplane custompass customuser dearmor @@ -30,33 +54,28 @@ debootstrap debuginfo derefs distutils -Dockerfiles doctest doctests downcasted downcasting dpkg dtolnay -EAAAADAQABAAABAQC ehthumbs elif -Émojis endfor endraw epel -EPEL eprintln exitcode getent -Gossman handleable hexdump -Hostnames hotfixes htdocs hugepages impls journalctl +jsonlint keepalive keygen keyrings @@ -66,7 +85,6 @@ logfile logicaldisk loglevel lxdbr -MAAACBA maxbytes mgmt millis @@ -74,7 +92,6 @@ mockall mocksecret mtorrust multiprocess -MVVM myapp myenv nameof @@ -86,10 +103,8 @@ nocapture noconfirm nodaemon noninteractive -NOPASSWD nslookup nullglob -OAAAAN oneline pacman parameterizing @@ -104,10 +119,7 @@ preconfigured prereq println publickey -Pulumi pytest -RAII -Repomix reprovisioning reqwest resolv @@ -118,25 +130,19 @@ rstest runbooks runcmd rustc -Rustdoc -RUSTDOCFLAGS rustflags rustup -Scriptability -SCRIPTDIR secureboot serde serverurl shellcheck -Silverlight smorimoto -spëcial spki +spëcial sqlx sshpass startretries stringly -Subissue subissues subshell substates @@ -145,18 +151,12 @@ supervisord swappability sysfs sysv -Taplo taskkill tasklist -Tera terraformrc -tést -Testcontain testcontainer testcontainers -Testcontainers testhost -Testinfra testkey testpass testuser @@ -168,7 +168,7 @@ tlsv tmpfiles tmpfs torrust -Torrust +tést unergonomic unrepresentable unsubscription @@ -180,6 +180,7 @@ vbqajnc viewmodel webservers writeln +Émojis значение ключ конфиг diff --git a/src/app.rs b/src/app.rs index 5edffee1..72b7692a 100644 --- a/src/app.rs +++ b/src/app.rs @@ -56,7 +56,7 @@ pub fn run() { match cli.command { Some(command) => { - if let Err(e) = presentation::execute(command) { + if let Err(e) = presentation::execute(command, &cli.global.working_dir) { presentation::handle_error(&e); std::process::exit(1); } diff --git a/src/presentation/cli/args.rs b/src/presentation/cli/args.rs index c7043e52..a3d15b96 100644 --- a/src/presentation/cli/args.rs +++ b/src/presentation/cli/args.rs @@ -55,6 +55,20 @@ pub struct GlobalArgs { /// observability and the application cannot function without it. #[arg(long, default_value = "./data/logs", global = true)] pub log_dir: PathBuf, + + /// Working directory for environment data (default: .) + /// + /// Root directory where environment data will be stored. Each environment + /// creates subdirectories within this location for build files and state. + /// This is useful for testing or when you want to manage environments in + /// a different location than the current directory. + /// + /// Examples: + /// - Default: './data' (relative to current directory) + /// - Testing: '/tmp/test-workspace' (absolute path) + /// - Production: '/var/lib/torrust-deployer' (system location) + #[arg(long, default_value = ".", global = true)] + pub working_dir: PathBuf, } impl GlobalArgs { @@ -81,6 +95,7 @@ impl GlobalArgs { /// log_stderr_format: LogFormat::Pretty, /// log_output: LogOutput::FileAndStderr, /// log_dir: PathBuf::from("/tmp/logs"), + /// working_dir: PathBuf::from("."), /// }; /// let config = args.logging_config(); /// // config will have specified log formats and directory diff --git a/src/presentation/cli/commands.rs b/src/presentation/cli/commands.rs index bdccf1bc..a90f0bd1 100644 --- a/src/presentation/cli/commands.rs +++ b/src/presentation/cli/commands.rs @@ -5,12 +5,29 @@ use clap::Subcommand; +use std::path::PathBuf; + /// Available CLI commands /// /// This enum defines all the subcommands available in the CLI application. /// Each variant represents a specific operation that can be performed. #[derive(Subcommand, Debug)] pub enum Commands { + /// Create a new deployment environment + /// + /// This command creates a new environment based on a configuration file. + /// The configuration file specifies the environment name, SSH credentials, + /// and other settings required for environment creation. + Create { + /// Path to the environment configuration file + /// + /// The configuration file must be in JSON format and contain all + /// required fields for environment creation. Use --help for more + /// information about the configuration format. + #[arg(long, short = 'f', value_name = "FILE")] + env_file: PathBuf, + }, + /// Destroy an existing deployment environment /// /// This command will tear down all infrastructure associated with the diff --git a/src/presentation/cli/mod.rs b/src/presentation/cli/mod.rs index 2e634f07..45f7e55d 100644 --- a/src/presentation/cli/mod.rs +++ b/src/presentation/cli/mod.rs @@ -47,6 +47,7 @@ mod tests { Commands::Destroy { environment } => { assert_eq!(environment, "test-env"); } + Commands::Create { .. } => panic!("Expected Destroy command"), } } @@ -62,6 +63,7 @@ mod tests { Commands::Destroy { environment } => { assert_eq!(environment, env_name); } + Commands::Create { .. } => panic!("Expected Destroy command"), } } } @@ -102,6 +104,7 @@ mod tests { Commands::Destroy { environment } => { assert_eq!(environment, "test-env"); } + Commands::Create { .. } => panic!("Expected Destroy command"), } // Log options are set but we don't compare them as they don't implement PartialEq @@ -163,4 +166,99 @@ mod tests { "Help text should mention environment parameter" ); } + + #[test] + fn it_should_parse_create_subcommand() { + let args = vec![ + "torrust-tracker-deployer", + "create", + "--env-file", + "config.json", + ]; + let cli = Cli::try_parse_from(args).unwrap(); + + assert!(cli.command.is_some()); + match cli.command.unwrap() { + Commands::Create { env_file } => { + assert_eq!(env_file, std::path::PathBuf::from("config.json")); + } + Commands::Destroy { .. } => panic!("Expected Create command"), + } + } + + #[test] + fn it_should_parse_create_with_short_flag() { + let args = vec!["torrust-tracker-deployer", "create", "-f", "env.json"]; + let cli = Cli::try_parse_from(args).unwrap(); + + match cli.command.unwrap() { + Commands::Create { env_file } => { + assert_eq!(env_file, std::path::PathBuf::from("env.json")); + } + Commands::Destroy { .. } => panic!("Expected Create command"), + } + } + + #[test] + fn it_should_require_env_file_parameter_for_create() { + let args = vec!["torrust-tracker-deployer", "create"]; + let result = Cli::try_parse_from(args); + + assert!(result.is_err()); + let error = result.unwrap_err(); + let error_message = error.to_string(); + assert!( + error_message.contains("required") || error_message.contains("--env-file"), + "Error message should indicate missing required --env-file: {error_message}" + ); + } + + #[test] + fn it_should_parse_working_dir_global_option() { + let args = vec![ + "torrust-tracker-deployer", + "--working-dir", + "/tmp/workspace", + "create", + "--env-file", + "config.json", + ]; + let cli = Cli::try_parse_from(args).unwrap(); + + assert_eq!( + cli.global.working_dir, + std::path::PathBuf::from("/tmp/workspace") + ); + + match cli.command.unwrap() { + Commands::Create { env_file } => { + assert_eq!(env_file, std::path::PathBuf::from("config.json")); + } + Commands::Destroy { .. } => panic!("Expected Create command"), + } + } + + #[test] + fn it_should_use_default_working_dir_when_not_specified() { + let args = vec!["torrust-tracker-deployer", "create", "-f", "config.json"]; + let cli = Cli::try_parse_from(args).unwrap(); + + assert_eq!(cli.global.working_dir, std::path::PathBuf::from(".")); + } + + #[test] + fn it_should_show_create_help() { + let args = vec!["torrust-tracker-deployer", "create", "--help"]; + let result = Cli::try_parse_from(args); + + assert!(result.is_err()); + let error = result.unwrap_err(); + assert_eq!(error.kind(), clap::error::ErrorKind::DisplayHelp); + + let help_text = error.to_string(); + assert!( + help_text.contains("env-file") || help_text.contains("configuration"), + "Help text should mention env-file parameter" + ); + } } diff --git a/src/presentation/commands/create/config_loader.rs b/src/presentation/commands/create/config_loader.rs new file mode 100644 index 00000000..2b36f6d8 --- /dev/null +++ b/src/presentation/commands/create/config_loader.rs @@ -0,0 +1,267 @@ +//! Configuration Loader with Figment Integration +//! +//! This module provides configuration loading from JSON files using Figment. +//! Figment is used only in the presentation layer as a delivery mechanism, +//! following DDD architecture boundaries. + +use std::path::Path; + +use figment::{ + providers::{Format, Json}, + Figment, +}; + +use crate::domain::config::EnvironmentCreationConfig; + +use super::errors::{ConfigFormat, CreateSubcommandError}; + +/// Configuration loader using Figment for JSON file parsing +/// +/// This loader is part of the presentation layer and handles the specifics +/// of loading and parsing configuration files. It uses Figment for flexible +/// configuration file handling and validation. +/// +/// # Architecture Note +/// +/// Figment stays in the presentation layer as a delivery mechanism. +/// The domain layer remains independent of configuration parsing libraries. +pub struct ConfigLoader; + +impl ConfigLoader { + /// Load environment creation configuration from a JSON file + /// + /// This method loads and parses a JSON configuration file, then validates + /// it according to domain rules. All errors are wrapped in presentation + /// layer error types with helpful guidance. + /// + /// # Arguments + /// + /// * `config_path` - Path to the JSON configuration file + /// + /// # Returns + /// + /// * `Ok(EnvironmentCreationConfig)` - Successfully loaded and validated configuration + /// * `Err(CreateSubcommandError)` - File not found, parsing failed, or validation failed + /// + /// # Errors + /// + /// Returns an error if: + /// - Configuration file doesn't exist + /// - JSON parsing fails (syntax errors, type mismatches) + /// - Domain validation fails (invalid names, missing SSH keys, etc.) + /// + /// All errors include detailed troubleshooting guidance via `.help()`. + /// + /// # Examples + /// + /// ```rust,no_run + /// use std::path::Path; + /// use torrust_tracker_deployer_lib::presentation::commands::create::ConfigLoader; + /// + /// let loader = ConfigLoader; + /// let config = loader.load_from_file(Path::new("config/environment.json"))?; + /// + /// println!("Loaded environment: {}", config.environment.name); + /// # Ok::<(), Box>(()) + /// ``` + pub fn load_from_file( + &self, + config_path: &Path, + ) -> Result { + // Step 1: Verify file exists + if !config_path.exists() { + return Err(CreateSubcommandError::ConfigFileNotFound { + path: config_path.to_path_buf(), + }); + } + + // Step 2: Load with Figment + // We don't use defaults here because we want explicit configuration + let config: EnvironmentCreationConfig = Figment::new() + .merge(Json::file(config_path)) + .extract() + .map_err(|source| CreateSubcommandError::ConfigParsingFailed { + path: config_path.to_path_buf(), + format: ConfigFormat::Json, + source: Box::new(source), + })?; + + // Step 3: Validate using domain rules + // This converts string-based config to domain types and validates + config + .clone() + .to_environment_params() + .map_err(CreateSubcommandError::ConfigValidationFailed)?; + + Ok(config) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + use tempfile::TempDir; + + #[test] + fn it_should_load_valid_json_configuration() { + let temp_dir = TempDir::new().unwrap(); + let config_path = temp_dir.path().join("config.json"); + + // Write a valid configuration file + let config_json = r#"{ + "environment": { + "name": "test-env" + }, + "ssh_credentials": { + "private_key_path": "fixtures/testing_rsa", + "public_key_path": "fixtures/testing_rsa.pub" + } + }"#; + fs::write(&config_path, config_json).unwrap(); + + let loader = ConfigLoader; + let result = loader.load_from_file(&config_path); + + assert!(result.is_ok(), "Should load valid configuration"); + let config = result.unwrap(); + assert_eq!(config.environment.name, "test-env"); + } + + #[test] + fn it_should_return_error_for_nonexistent_file() { + let temp_dir = TempDir::new().unwrap(); + let config_path = temp_dir.path().join("nonexistent.json"); + + let loader = ConfigLoader; + let result = loader.load_from_file(&config_path); + + assert!(result.is_err()); + match result.unwrap_err() { + CreateSubcommandError::ConfigFileNotFound { path } => { + assert_eq!(path, config_path); + } + _ => panic!("Expected ConfigFileNotFound error"), + } + } + + #[test] + fn it_should_return_error_for_invalid_json() { + let temp_dir = TempDir::new().unwrap(); + let config_path = temp_dir.path().join("invalid.json"); + + // Write invalid JSON (missing closing brace) + fs::write(&config_path, r#"{"environment": {"name": "test""#).unwrap(); + + let loader = ConfigLoader; + let result = loader.load_from_file(&config_path); + + assert!(result.is_err()); + match result.unwrap_err() { + CreateSubcommandError::ConfigParsingFailed { path, format, .. } => { + assert_eq!(path, config_path); + assert!(matches!(format, ConfigFormat::Json)); + } + _ => panic!("Expected ConfigParsingFailed error"), + } + } + + #[test] + fn it_should_return_error_for_missing_required_fields() { + let temp_dir = TempDir::new().unwrap(); + let config_path = temp_dir.path().join("incomplete.json"); + + // Write JSON with missing required fields + fs::write(&config_path, r#"{"environment": {}}"#).unwrap(); + + let loader = ConfigLoader; + let result = loader.load_from_file(&config_path); + + assert!(result.is_err()); + // Should fail at parsing or validation stage + } + + #[test] + fn it_should_return_error_for_invalid_environment_name() { + let temp_dir = TempDir::new().unwrap(); + let config_path = temp_dir.path().join("invalid_name.json"); + + // Write config with invalid environment name + let config_json = r#"{ + "environment": { + "name": "Invalid_Name_With_Underscore" + }, + "ssh_credentials": { + "private_key_path": "fixtures/testing_rsa", + "public_key_path": "fixtures/testing_rsa.pub" + } + }"#; + fs::write(&config_path, config_json).unwrap(); + + let loader = ConfigLoader; + let result = loader.load_from_file(&config_path); + + assert!(result.is_err()); + match result.unwrap_err() { + CreateSubcommandError::ConfigValidationFailed(_) => { + // Expected - validation should catch invalid environment name + } + other => panic!("Expected ConfigValidationFailed, got: {other:?}"), + } + } + + #[test] + fn it_should_return_error_for_missing_ssh_keys() { + let temp_dir = TempDir::new().unwrap(); + let config_path = temp_dir.path().join("missing_keys.json"); + + // Write config with non-existent SSH key files + let config_json = r#"{ + "environment": { + "name": "test-env" + }, + "ssh_credentials": { + "private_key_path": "/nonexistent/key", + "public_key_path": "/nonexistent/key.pub" + } + }"#; + fs::write(&config_path, config_json).unwrap(); + + let loader = ConfigLoader; + let result = loader.load_from_file(&config_path); + + assert!(result.is_err()); + match result.unwrap_err() { + CreateSubcommandError::ConfigValidationFailed(_) => { + // Expected - validation should catch missing SSH keys + } + other => panic!("Expected ConfigValidationFailed, got: {other:?}"), + } + } + + #[test] + fn it_should_handle_config_with_optional_fields_having_defaults() { + let temp_dir = TempDir::new().unwrap(); + let config_path = temp_dir.path().join("with_defaults.json"); + + // Write config without optional fields (they should use defaults) + let config_json = r#"{ + "environment": { + "name": "test-env" + }, + "ssh_credentials": { + "private_key_path": "fixtures/testing_rsa", + "public_key_path": "fixtures/testing_rsa.pub" + } + }"#; + fs::write(&config_path, config_json).unwrap(); + + let loader = ConfigLoader; + let result = loader.load_from_file(&config_path); + + assert!( + result.is_ok(), + "Should load config with default values for optional fields" + ); + } +} diff --git a/src/presentation/commands/create/errors.rs b/src/presentation/commands/create/errors.rs new file mode 100644 index 00000000..c5a56bcf --- /dev/null +++ b/src/presentation/commands/create/errors.rs @@ -0,0 +1,239 @@ +//! Error types for the Create Subcommand +//! +//! This module defines error types that can occur during CLI create command execution. +//! All errors follow the project's error handling principles by providing clear, +//! contextual, and actionable error messages with `.help()` methods. + +use std::path::PathBuf; +use thiserror::Error; + +use crate::application::command_handlers::create::CreateCommandHandlerError; + +/// Format of configuration file +#[derive(Debug, Clone, Copy)] +pub enum ConfigFormat { + /// JSON format + Json, +} + +impl std::fmt::Display for ConfigFormat { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Json => write!(f, "JSON"), + } + } +} + +/// Errors that can occur during create subcommand execution +/// +/// These errors represent failures in the CLI presentation layer when +/// handling the create command. They provide structured context for +/// troubleshooting and user feedback. +#[derive(Debug, Error)] +pub enum CreateSubcommandError { + /// Configuration file not found + #[error("Configuration file not found: {path}")] + ConfigFileNotFound { + /// Path to the missing configuration file + path: PathBuf, + }, + + /// Failed to parse configuration file + #[error("Failed to parse configuration file '{path}' as {format}")] + ConfigParsingFailed { + /// Path to the configuration file + path: PathBuf, + /// Expected format of the file + format: ConfigFormat, + /// Underlying parsing error + #[source] + source: Box, + }, + + /// Configuration validation failed + #[error("Configuration validation failed")] + ConfigValidationFailed( + /// Underlying validation error from domain layer + #[source] + crate::domain::config::CreateConfigError, + ), + + /// Command execution failed + #[error("Create command execution failed")] + CommandFailed( + /// Underlying command handler error + #[source] + CreateCommandHandlerError, + ), +} + +impl CreateSubcommandError { + /// Provides detailed troubleshooting guidance for this error + /// + /// Returns context-specific help text that guides users toward resolving + /// the issue. This implements the project's tiered help system pattern + /// for actionable error messages. + /// + /// # Examples + /// + /// ```rust + /// use torrust_tracker_deployer_lib::presentation::commands::create::CreateSubcommandError; + /// use std::path::PathBuf; + /// + /// let error = CreateSubcommandError::ConfigFileNotFound { + /// path: PathBuf::from("config.json"), + /// }; + /// + /// let help = error.help(); + /// assert!(help.contains("File Not Found")); + /// assert!(help.contains("Check that the file path")); + /// ``` + #[must_use] + pub fn help(&self) -> &'static str { + match self { + Self::ConfigFileNotFound { .. } => { + "Configuration File Not Found - Troubleshooting: + +1. Check that the file path is correct in your --env-file argument +2. Verify the file exists: ls -la +3. Ensure you have read permissions on the file +4. Use absolute paths or paths relative to current directory + +Example: + torrust-tracker-deployer create --env-file ./config/environment.json + +For more information about configuration format, see the documentation." + } + Self::ConfigParsingFailed { format, .. } => match format { + ConfigFormat::Json => { + "JSON Configuration Parsing Failed - Troubleshooting: + +1. Validate JSON syntax using a JSON validator: + - Online: jsonlint.com + - Command line: jq . < your-config.json + +2. Common JSON syntax errors: + - Missing or extra commas + - Missing quotes around strings + - Unclosed braces or brackets + - Invalid escape sequences + +3. Verify required fields are present: + - environment.name + - ssh_credentials.private_key_path + - ssh_credentials.public_key_path + +4. Check field types match expectations: + - Strings must be in quotes + - Numbers should not have quotes + - Booleans are true/false (lowercase) + +Example valid configuration: +{ + \"environment\": { + \"name\": \"dev\" + }, + \"ssh_credentials\": { + \"private_key_path\": \"fixtures/testing_rsa\", + \"public_key_path\": \"fixtures/testing_rsa.pub\" + } +} + +For more information, see the configuration documentation." + } + }, + Self::ConfigValidationFailed(inner) => inner.help(), + Self::CommandFailed(inner) => inner.help(), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn it_should_provide_help_for_config_file_not_found() { + let error = CreateSubcommandError::ConfigFileNotFound { + path: PathBuf::from("missing.json"), + }; + + let help = error.help(); + assert!(help.contains("File Not Found")); + assert!(help.contains("Check that the file path")); + assert!(help.contains("ls -la")); + } + + #[test] + fn it_should_provide_help_for_json_parsing_failed() { + let source = std::io::Error::new(std::io::ErrorKind::InvalidData, "invalid json"); + let error = CreateSubcommandError::ConfigParsingFailed { + path: PathBuf::from("config.json"), + format: ConfigFormat::Json, + source: Box::new(source), + }; + + let help = error.help(); + assert!(help.contains("JSON")); + assert!(help.contains("syntax")); + assert!(help.contains("jq")); + } + + #[test] + fn it_should_display_config_file_path_in_error() { + let error = CreateSubcommandError::ConfigFileNotFound { + path: PathBuf::from("/path/to/config.json"), + }; + + let message = error.to_string(); + assert!(message.contains("/path/to/config.json")); + assert!(message.contains("not found")); + } + + #[test] + fn it_should_display_format_in_parsing_error() { + let source = std::io::Error::new(std::io::ErrorKind::InvalidData, "test"); + let error = CreateSubcommandError::ConfigParsingFailed { + path: PathBuf::from("config.json"), + format: ConfigFormat::Json, + source: Box::new(source), + }; + + let message = error.to_string(); + assert!(message.contains("JSON")); + assert!(message.contains("config.json")); + } + + #[test] + fn it_should_have_help_for_all_error_variants() { + use crate::domain::config::CreateConfigError; + use crate::domain::EnvironmentNameError; + + let errors: Vec = vec![ + CreateSubcommandError::ConfigFileNotFound { + path: PathBuf::from("test.json"), + }, + CreateSubcommandError::ConfigParsingFailed { + path: PathBuf::from("test.json"), + format: ConfigFormat::Json, + source: Box::new(std::io::Error::new(std::io::ErrorKind::InvalidData, "test")), + }, + CreateSubcommandError::ConfigValidationFailed( + CreateConfigError::InvalidEnvironmentName(EnvironmentNameError::InvalidFormat { + attempted_name: "test".to_string(), + reason: "invalid".to_string(), + valid_examples: vec!["dev".to_string()], + }), + ), + ]; + + for error in errors { + let help = error.help(); + assert!(!help.is_empty(), "Help text should not be empty"); + assert!( + help.contains("Troubleshooting") || help.contains("Fix") || help.len() > 50, + "Help should contain actionable guidance" + ); + } + } +} diff --git a/src/presentation/commands/create/mod.rs b/src/presentation/commands/create/mod.rs new file mode 100644 index 00000000..dcb4a722 --- /dev/null +++ b/src/presentation/commands/create/mod.rs @@ -0,0 +1,45 @@ +//! Create Command Presentation Module +//! +//! This module implements the CLI presentation layer for the create command, +//! handling Figment integration for configuration file parsing, argument +//! processing, and user interaction. +//! +//! ## Architecture +//! +//! The create command presentation layer follows the existing patterns from +//! the destroy command and integrates with the application layer's +//! `CreateCommandHandler`. Figment is used as a delivery mechanism and stays +//! in the presentation layer following DDD boundaries. +//! +//! ## Components +//! +//! - `config_loader` - Figment integration for JSON configuration loading +//! - `errors` - Presentation layer error types with `.help()` methods +//! - `subcommand` - Main command handler orchestrating the workflow +//! +//! ## Usage Example +//! +//! ```rust,no_run +//! use std::path::Path; +//! use torrust_tracker_deployer_lib::presentation::commands::create; +//! +//! if let Err(e) = create::handle( +//! Path::new("config/environment.json"), +//! Path::new(".") +//! ) { +//! eprintln!("Create failed: {e}"); +//! eprintln!("\n{}", e.help()); +//! } +//! ``` + +pub mod config_loader; +pub mod errors; +pub mod subcommand; + +#[cfg(test)] +mod tests; + +// Re-export commonly used types for convenience +pub use config_loader::ConfigLoader; +pub use errors::{ConfigFormat, CreateSubcommandError}; +pub use subcommand::handle; diff --git a/src/presentation/commands/create/subcommand.rs b/src/presentation/commands/create/subcommand.rs new file mode 100644 index 00000000..cc6a426b --- /dev/null +++ b/src/presentation/commands/create/subcommand.rs @@ -0,0 +1,276 @@ +//! Create Subcommand Handler +//! +//! This module handles the create subcommand execution at the presentation layer, +//! including configuration file loading, argument processing, user interaction, +//! and command execution. + +use std::path::Path; +use std::sync::Arc; +use std::time::Duration; + +use crate::application::command_handlers::create::CreateCommandHandler; +use crate::domain::config::EnvironmentCreationConfig; +use crate::infrastructure::persistence::repository_factory::RepositoryFactory; +use crate::presentation::user_output::{UserOutput, VerbosityLevel}; +use crate::shared::{Clock, SystemClock}; + +use super::config_loader::ConfigLoader; +use super::errors::CreateSubcommandError; + +/// Handle the create subcommand +/// +/// This function orchestrates the environment creation workflow from the +/// presentation layer by: +/// 1. Loading and parsing the configuration file using Figment +/// 2. Validating the configuration using domain rules +/// 3. Setting up the repository with the working directory +/// 4. Creating the command handler with injected dependencies +/// 5. Executing the create command +/// 6. Providing user-friendly progress updates and error messages +/// +/// # Arguments +/// +/// * `env_file` - Path to the environment configuration file (JSON format) +/// * `working_dir` - Root directory for environment data storage +/// +/// # Returns +/// +/// Returns `Ok(())` on success, or a `CreateSubcommandError` if: +/// - Configuration file is not found +/// - Configuration parsing fails +/// - Configuration validation fails +/// - Command execution fails +/// +/// # Errors +/// +/// This function will return an error if the configuration file cannot be +/// loaded, parsed, validated, or if the create command execution fails. +/// All errors include detailed context and actionable troubleshooting guidance. +/// +/// # Example +/// +/// ```rust,no_run +/// use std::path::{Path, PathBuf}; +/// use torrust_tracker_deployer_lib::presentation::commands::create; +/// +/// let result = create::handle( +/// Path::new("config/environment.json"), +/// &PathBuf::from(".") +/// ); +/// +/// if let Err(e) = result { +/// eprintln!("Create failed: {e}"); +/// eprintln!("Help: {}", e.help()); +/// } +/// ``` +#[allow(clippy::result_large_err)] // Error contains detailed context for user guidance +pub fn handle(env_file: &Path, working_dir: &Path) -> Result<(), CreateSubcommandError> { + // Create user output with default stdout/stderr channels + let mut output = UserOutput::new(VerbosityLevel::Normal); + + // Display initial progress (to stderr) + output.progress(&format!( + "Loading configuration from '{}'...", + env_file.display() + )); + + // Step 1: Load and parse configuration file using Figment + let loader = ConfigLoader; + let config: EnvironmentCreationConfig = loader.load_from_file(env_file).inspect_err(|err| { + output.error(&err.to_string()); + })?; + + output.progress(&format!( + "Creating environment '{}'...", + config.environment.name + )); + + // Step 2: Create repository for environment persistence + // Use the working directory from CLI args (supports testing and custom locations) + let repository_factory = RepositoryFactory::new(Duration::from_secs(30)); + let repository = repository_factory.create(working_dir.to_path_buf()); + + // Step 3: Create clock for timing information + let clock: Arc = Arc::new(SystemClock); + + // Step 4: Create and execute command handler + let command_handler = CreateCommandHandler::new(repository, clock); + + output.progress("Validating configuration and creating environment..."); + + // Step 5: Execute create command + #[allow(clippy::manual_inspect)] + let environment = command_handler.execute(config.clone()).map_err(|err| { + let error = CreateSubcommandError::CommandFailed(err); + output.error(&error.to_string()); + error + })?; + + // Step 6: Display success message + output.success(&format!( + "Environment '{}' created successfully", + environment.name().as_str() + )); + + output.result(&format!( + "Instance name: {}", + environment.instance_name().as_str() + )); + output.result(&format!( + "Data directory: {}", + environment.data_dir().display() + )); + output.result(&format!( + "Build directory: {}", + environment.build_dir().display() + )); + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + use tempfile::TempDir; + + #[test] + fn it_should_create_environment_from_valid_config() { + let temp_dir = TempDir::new().unwrap(); + let config_path = temp_dir.path().join("config.json"); + + // Write a valid configuration file + let config_json = r#"{ + "environment": { + "name": "test-create-env" + }, + "ssh_credentials": { + "private_key_path": "fixtures/testing_rsa", + "public_key_path": "fixtures/testing_rsa.pub" + } + }"#; + fs::write(&config_path, config_json).unwrap(); + + let working_dir = temp_dir.path(); + let result = handle(&config_path, working_dir); + + assert!( + result.is_ok(), + "Should successfully create environment: {:?}", + result.err() + ); + + // Verify environment state file was created by repository + // Repository creates: /{env-name}/environment.json + let env_state_file = working_dir.join("test-create-env/environment.json"); + assert!( + env_state_file.exists(), + "Environment state file should be created at: {}", + env_state_file.display() + ); + } + + #[test] + fn it_should_return_error_for_missing_config_file() { + let temp_dir = TempDir::new().unwrap(); + let config_path = temp_dir.path().join("nonexistent.json"); + let working_dir = temp_dir.path(); + + let result = handle(&config_path, working_dir); + + assert!(result.is_err()); + match result.unwrap_err() { + CreateSubcommandError::ConfigFileNotFound { path } => { + assert_eq!(path, config_path); + } + other => panic!("Expected ConfigFileNotFound, got: {other:?}"), + } + } + + #[test] + fn it_should_return_error_for_invalid_json() { + let temp_dir = TempDir::new().unwrap(); + let config_path = temp_dir.path().join("invalid.json"); + + // Write invalid JSON + fs::write(&config_path, r#"{"invalid json"#).unwrap(); + + let working_dir = temp_dir.path(); + let result = handle(&config_path, working_dir); + + assert!(result.is_err()); + match result.unwrap_err() { + CreateSubcommandError::ConfigParsingFailed { .. } => { + // Expected + } + other => panic!("Expected ConfigParsingFailed, got: {other:?}"), + } + } + + #[test] + fn it_should_return_error_for_duplicate_environment() { + let temp_dir = TempDir::new().unwrap(); + let config_path = temp_dir.path().join("config.json"); + + let config_json = r#"{ + "environment": { + "name": "duplicate-env" + }, + "ssh_credentials": { + "private_key_path": "fixtures/testing_rsa", + "public_key_path": "fixtures/testing_rsa.pub" + } + }"#; + fs::write(&config_path, config_json).unwrap(); + + let working_dir = temp_dir.path(); + + // Create environment first time + let result1 = handle(&config_path, working_dir); + assert!(result1.is_ok(), "First create should succeed"); + + // Try to create same environment again + let result2 = handle(&config_path, working_dir); + assert!(result2.is_err(), "Second create should fail"); + + match result2.unwrap_err() { + CreateSubcommandError::CommandFailed(_) => { + // Expected - environment already exists + } + other => panic!("Expected CommandFailed, got: {other:?}"), + } + } + + #[test] + fn it_should_create_environment_in_custom_working_dir() { + let temp_dir = TempDir::new().unwrap(); + let custom_working_dir = temp_dir.path().join("custom"); + fs::create_dir(&custom_working_dir).unwrap(); + + let config_path = temp_dir.path().join("config.json"); + + let config_json = r#"{ + "environment": { + "name": "custom-location-env" + }, + "ssh_credentials": { + "private_key_path": "fixtures/testing_rsa", + "public_key_path": "fixtures/testing_rsa.pub" + } + }"#; + fs::write(&config_path, config_json).unwrap(); + + let result = handle(&config_path, &custom_working_dir); + + assert!(result.is_ok(), "Should create in custom working dir"); + + // Verify environment was created in custom location + // Repository creates: /{env-name}/environment.json + let env_state_file = custom_working_dir.join("custom-location-env/environment.json"); + assert!( + env_state_file.exists(), + "Environment state should be in custom working directory at: {}", + env_state_file.display() + ); + } +} diff --git a/src/presentation/commands/create/tests/fixtures.rs b/src/presentation/commands/create/tests/fixtures.rs new file mode 100644 index 00000000..962c36c4 --- /dev/null +++ b/src/presentation/commands/create/tests/fixtures.rs @@ -0,0 +1,149 @@ +//! Test Fixtures for Create Command Tests +//! +//! This module provides test fixtures and helper functions for testing +//! the create command presentation layer. + +use std::fs; +use std::path::{Path, PathBuf}; + +/// Create a valid environment configuration JSON file +/// +/// # Arguments +/// +/// * `path` - Path where the config file should be created +/// * `env_name` - Name of the environment +/// +/// # Returns +/// +/// Returns the path to the created configuration file +pub fn create_valid_config(path: &Path, env_name: &str) -> PathBuf { + let config_json = format!( + r#"{{ + "environment": {{ + "name": "{env_name}" + }}, + "ssh_credentials": {{ + "private_key_path": "fixtures/testing_rsa", + "public_key_path": "fixtures/testing_rsa.pub" + }} +}}"# + ); + + let config_path = path.join("config.json"); + fs::write(&config_path, config_json).unwrap(); + config_path +} + +/// Create an invalid JSON configuration file +/// +/// # Arguments +/// +/// * `path` - Path where the config file should be created +/// +/// # Returns +/// +/// Returns the path to the created configuration file +pub fn create_invalid_json_config(path: &Path) -> PathBuf { + let invalid_json = r#"{"environment": {"name": "test"#; // Missing closing braces + let config_path = path.join("invalid.json"); + fs::write(&config_path, invalid_json).unwrap(); + config_path +} + +/// Create a configuration with an invalid environment name +/// +/// # Arguments +/// +/// * `path` - Path where the config file should be created +/// +/// # Returns +/// +/// Returns the path to the created configuration file +pub fn create_config_with_invalid_name(path: &Path) -> PathBuf { + let config_json = r#"{ + "environment": { + "name": "Invalid_Name_With_Underscore" + }, + "ssh_credentials": { + "private_key_path": "fixtures/testing_rsa", + "public_key_path": "fixtures/testing_rsa.pub" + } +}"#; + + let config_path = path.join("invalid_name.json"); + fs::write(&config_path, config_json).unwrap(); + config_path +} + +/// Create a configuration with missing SSH key files +/// +/// # Arguments +/// +/// * `path` - Path where the config file should be created +/// +/// # Returns +/// +/// Returns the path to the created configuration file +pub fn create_config_with_missing_keys(path: &Path) -> PathBuf { + let config_json = r#"{ + "environment": { + "name": "test-env" + }, + "ssh_credentials": { + "private_key_path": "/nonexistent/private_key", + "public_key_path": "/nonexistent/public_key.pub" + } +}"#; + + let config_path = path.join("missing_keys.json"); + fs::write(&config_path, config_json).unwrap(); + config_path +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::TempDir; + + #[test] + fn it_should_create_valid_config_file() { + let temp_dir = TempDir::new().unwrap(); + let config_path = create_valid_config(temp_dir.path(), "test-env"); + + assert!(config_path.exists()); + let content = fs::read_to_string(&config_path).unwrap(); + assert!(content.contains("test-env")); + assert!(content.contains("fixtures/testing_rsa")); + } + + #[test] + fn it_should_create_invalid_json_config_file() { + let temp_dir = TempDir::new().unwrap(); + let config_path = create_invalid_json_config(temp_dir.path()); + + assert!(config_path.exists()); + let content = fs::read_to_string(&config_path).unwrap(); + // Verify it's actually invalid JSON + assert!(serde_json::from_str::(&content).is_err()); + } + + #[test] + fn it_should_create_config_with_invalid_environment_name() { + let temp_dir = TempDir::new().unwrap(); + let config_path = create_config_with_invalid_name(temp_dir.path()); + + assert!(config_path.exists()); + let content = fs::read_to_string(&config_path).unwrap(); + assert!(content.contains("Invalid_Name_With_Underscore")); + } + + #[test] + fn it_should_create_config_with_missing_keys() { + let temp_dir = TempDir::new().unwrap(); + let config_path = create_config_with_missing_keys(temp_dir.path()); + + assert!(config_path.exists()); + let content = fs::read_to_string(&config_path).unwrap(); + assert!(content.contains("/nonexistent/private_key")); + } +} diff --git a/src/presentation/commands/create/tests/integration.rs b/src/presentation/commands/create/tests/integration.rs new file mode 100644 index 00000000..83b24f61 --- /dev/null +++ b/src/presentation/commands/create/tests/integration.rs @@ -0,0 +1,171 @@ +//! Integration Tests for Create Command CLI +//! +//! This module tests the complete create command workflow including +//! configuration loading, validation, and command execution. + +use tempfile::TempDir; + +use crate::presentation::commands::create; + +use super::fixtures; + +#[test] +fn it_should_create_environment_from_valid_config() { + let temp_dir = TempDir::new().unwrap(); + let config_path = fixtures::create_valid_config(temp_dir.path(), "integration-test-env"); + + let result = create::handle(&config_path, temp_dir.path()); + + assert!( + result.is_ok(), + "Should create environment successfully: {:?}", + result.err() + ); + + // Verify environment state file was created by repository + // Repository creates: /{env-name}/environment.json + let env_state_file = temp_dir + .path() + .join("integration-test-env/environment.json"); + assert!( + env_state_file.exists(), + "Environment state file should be created at: {}", + env_state_file.display() + ); +} + +#[test] +fn it_should_reject_nonexistent_config_file() { + let temp_dir = TempDir::new().unwrap(); + let nonexistent_path = temp_dir.path().join("nonexistent.json"); + + let result = create::handle(&nonexistent_path, temp_dir.path()); + + assert!(result.is_err(), "Should fail for nonexistent file"); + match result.unwrap_err() { + create::CreateSubcommandError::ConfigFileNotFound { path } => { + assert_eq!(path, nonexistent_path); + } + other => panic!("Expected ConfigFileNotFound, got: {other:?}"), + } +} + +#[test] +fn it_should_reject_invalid_json() { + let temp_dir = TempDir::new().unwrap(); + let config_path = fixtures::create_invalid_json_config(temp_dir.path()); + + let result = create::handle(&config_path, temp_dir.path()); + + assert!(result.is_err(), "Should fail for invalid JSON"); + match result.unwrap_err() { + create::CreateSubcommandError::ConfigParsingFailed { path, .. } => { + assert_eq!(path, config_path); + } + other => panic!("Expected ConfigParsingFailed, got: {other:?}"), + } +} + +#[test] +fn it_should_reject_invalid_environment_name() { + let temp_dir = TempDir::new().unwrap(); + let config_path = fixtures::create_config_with_invalid_name(temp_dir.path()); + + let result = create::handle(&config_path, temp_dir.path()); + + assert!(result.is_err(), "Should fail for invalid environment name"); + match result.unwrap_err() { + create::CreateSubcommandError::ConfigValidationFailed(_) => { + // Expected + } + other => panic!("Expected ConfigValidationFailed, got: {other:?}"), + } +} + +#[test] +fn it_should_reject_missing_ssh_keys() { + let temp_dir = TempDir::new().unwrap(); + let config_path = fixtures::create_config_with_missing_keys(temp_dir.path()); + + let result = create::handle(&config_path, temp_dir.path()); + + assert!(result.is_err(), "Should fail for missing SSH keys"); + match result.unwrap_err() { + create::CreateSubcommandError::ConfigValidationFailed(_) => { + // Expected + } + other => panic!("Expected ConfigValidationFailed, got: {other:?}"), + } +} + +#[test] +fn it_should_reject_duplicate_environment() { + let temp_dir = TempDir::new().unwrap(); + let config_path = fixtures::create_valid_config(temp_dir.path(), "duplicate-test-env"); + + // Create environment first time + let result1 = create::handle(&config_path, temp_dir.path()); + assert!(result1.is_ok(), "First create should succeed"); + + // Try to create same environment again + let result2 = create::handle(&config_path, temp_dir.path()); + assert!(result2.is_err(), "Second create should fail"); + + match result2.unwrap_err() { + create::CreateSubcommandError::CommandFailed(_) => { + // Expected - environment already exists + } + other => panic!("Expected CommandFailed, got: {other:?}"), + } +} + +#[test] +fn it_should_create_environment_in_custom_working_dir() { + let temp_dir = TempDir::new().unwrap(); + let custom_working_dir = temp_dir.path().join("custom"); + std::fs::create_dir(&custom_working_dir).unwrap(); + + let config_path = fixtures::create_valid_config(temp_dir.path(), "custom-dir-env"); + + let result = create::handle(&config_path, &custom_working_dir); + + assert!(result.is_ok(), "Should create in custom working dir"); + + // Verify environment was created in custom location + // Repository creates: /{env-name}/environment.json + let env_state_file = custom_working_dir.join("custom-dir-env/environment.json"); + assert!( + env_state_file.exists(), + "Environment state should be in custom working directory: {}", + env_state_file.display() + ); +} + +#[test] +fn it_should_provide_help_for_all_error_types() { + let temp_dir = TempDir::new().unwrap(); + + // Test ConfigFileNotFound + let nonexistent = temp_dir.path().join("nonexistent.json"); + if let Err(e) = create::handle(&nonexistent, temp_dir.path()) { + let help = e.help(); + assert!(!help.is_empty()); + assert!(help.contains("File Not Found") || help.contains("Check that the file path")); + } + + // Test ConfigParsingFailed + let invalid_json = fixtures::create_invalid_json_config(temp_dir.path()); + if let Err(e) = create::handle(&invalid_json, temp_dir.path()) { + let help = e.help(); + assert!(!help.is_empty()); + assert!(help.contains("JSON") || help.contains("syntax")); + } + + // Test ConfigValidationFailed + let invalid_name = fixtures::create_config_with_invalid_name(temp_dir.path()); + if let Err(e) = create::handle(&invalid_name, temp_dir.path()) { + let help = e.help(); + assert!(!help.is_empty()); + // Should delegate to config error help + } +} diff --git a/src/presentation/commands/create/tests/mod.rs b/src/presentation/commands/create/tests/mod.rs new file mode 100644 index 00000000..7014c6ae --- /dev/null +++ b/src/presentation/commands/create/tests/mod.rs @@ -0,0 +1,7 @@ +//! Integration Tests for Create Command +//! +//! This module contains integration tests for the create command presentation +//! layer, including CLI integration, configuration loading, and error handling. + +pub mod fixtures; +pub mod integration; diff --git a/src/presentation/commands/mod.rs b/src/presentation/commands/mod.rs index 74fdad26..473cbf7d 100644 --- a/src/presentation/commands/mod.rs +++ b/src/presentation/commands/mod.rs @@ -8,6 +8,7 @@ use crate::presentation::cli::Commands; use crate::presentation::errors::CommandError; // Re-export command modules +pub mod create; pub mod destroy; // Future command modules will be added here: @@ -39,18 +40,24 @@ pub mod destroy; /// ```rust /// use clap::Parser; /// use torrust_tracker_deployer_lib::presentation::{cli, commands}; +/// use std::path::Path; /// /// let cli = cli::Cli::parse(); /// if let Some(command) = cli.command { -/// let result = commands::execute(command); +/// let working_dir = Path::new("."); +/// let result = commands::execute(command, working_dir); /// match result { /// Ok(_) => println!("Command executed successfully"), /// Err(e) => commands::handle_error(&e), /// } /// } /// ``` -pub fn execute(command: Commands) -> Result<(), CommandError> { +pub fn execute(command: Commands, working_dir: &std::path::Path) -> Result<(), CommandError> { match command { + Commands::Create { env_file } => { + create::handle(&env_file, working_dir)?; + Ok(()) + } Commands::Destroy { environment } => { destroy::handle(&environment)?; Ok(()) diff --git a/src/presentation/errors.rs b/src/presentation/errors.rs index e66d747e..9323fb4f 100644 --- a/src/presentation/errors.rs +++ b/src/presentation/errors.rs @@ -19,6 +19,7 @@ use thiserror::Error; +use crate::presentation::commands::create::CreateSubcommandError; use crate::presentation::commands::destroy::DestroyError; /// Errors that can occur during CLI command execution @@ -28,6 +29,13 @@ use crate::presentation::commands::destroy::DestroyError; /// types, source preservation, and tiered help system support. #[derive(Debug, Error)] pub enum CommandError { + /// Create command specific errors + /// + /// Encapsulates all errors that can occur during environment creation. + /// Use `.help()` for detailed troubleshooting steps. + #[error("Create command failed: {0}")] + Create(Box), + /// Destroy command specific errors /// /// Encapsulates all errors that can occur during environment destruction. @@ -36,6 +44,12 @@ pub enum CommandError { Destroy(Box), } +impl From for CommandError { + fn from(error: CreateSubcommandError) -> Self { + Self::Create(Box::new(error)) + } +} + impl From for CommandError { fn from(error: DestroyError) -> Self { Self::Destroy(Box::new(error)) @@ -78,6 +92,7 @@ impl CommandError { #[must_use] pub fn help(&self) -> &'static str { match self { + Self::Create(e) => e.help(), Self::Destroy(e) => e.help(), } } diff --git a/src/presentation/mod.rs b/src/presentation/mod.rs index c943760e..1919804f 100644 --- a/src/presentation/mod.rs +++ b/src/presentation/mod.rs @@ -45,6 +45,7 @@ pub mod user_output; // Re-export commonly used presentation types for convenience pub use cli::{Cli, Commands, GlobalArgs}; +pub use commands::create::CreateSubcommandError; pub use commands::destroy::DestroyError; pub use commands::{execute, handle_error}; pub use errors::CommandError;