From cbc4a470e2148c5b948581b13f3ef4691f7700e0 Mon Sep 17 00:00:00 2001 From: Jose Celano Date: Wed, 19 Nov 2025 11:52:58 +0000 Subject: [PATCH] feat: [#188] implement test CLI command - Add Test subcommand to CLI commands enum - Implement TestCommandController in presentation layer - Create TestSubcommandError with actionable help messages - Integrate test command with dispatch router - Add error conversions for CommandError - Update all CLI tests to handle Test command variant The test command validates deployment infrastructure by: - Verifying cloud-init completion - Checking Docker installation - Verifying Docker Compose installation Follows the same architectural pattern as provision and configure commands with ExecutionContext pattern and direct dependency injection. --- src/presentation/controllers/mod.rs | 1 + src/presentation/controllers/test/errors.rs | 320 ++++++++++++++++ src/presentation/controllers/test/handler.rs | 349 ++++++++++++++++++ src/presentation/controllers/test/mod.rs | 73 ++++ .../controllers/test/tests/mod.rs | 1 + src/presentation/dispatch/router.rs | 6 +- src/presentation/errors.rs | 15 + src/presentation/input/cli/commands.rs | 17 + src/presentation/input/cli/mod.rs | 38 +- 9 files changed, 811 insertions(+), 9 deletions(-) create mode 100644 src/presentation/controllers/test/errors.rs create mode 100644 src/presentation/controllers/test/handler.rs create mode 100644 src/presentation/controllers/test/mod.rs create mode 100644 src/presentation/controllers/test/tests/mod.rs diff --git a/src/presentation/controllers/mod.rs b/src/presentation/controllers/mod.rs index 86fb36ed..7e69de89 100644 --- a/src/presentation/controllers/mod.rs +++ b/src/presentation/controllers/mod.rs @@ -241,6 +241,7 @@ pub mod constants; pub mod create; pub mod destroy; pub mod provision; +pub mod test; // Shared test utilities #[cfg(test)] diff --git a/src/presentation/controllers/test/errors.rs b/src/presentation/controllers/test/errors.rs new file mode 100644 index 00000000..6cc809a2 --- /dev/null +++ b/src/presentation/controllers/test/errors.rs @@ -0,0 +1,320 @@ +//! Error types for the Test Subcommand +//! +//! This module defines error types that can occur during CLI test command execution. +//! All errors follow the project's error handling principles by providing clear, +//! contextual, and actionable error messages with `.help()` methods. + +use thiserror::Error; + +use crate::application::command_handlers::test::errors::TestCommandHandlerError; +use crate::domain::environment::name::EnvironmentNameError; +use crate::presentation::views::progress::ProgressReporterError; + +/// Test command specific errors +/// +/// This enum contains all error variants specific to the test command, +/// including environment validation, repository access, and validation failures. +/// Each variant includes relevant context and actionable error messages. +#[derive(Debug, Error)] +pub enum TestSubcommandError { + // ===== Environment Validation Errors ===== + /// Environment name validation failed + /// + /// The provided environment name doesn't meet the validation requirements. + /// Use `.help()` for detailed troubleshooting steps. + #[error("Invalid environment name '{name}': {source} +Tip: Environment names must be 1-63 characters, start with letter/digit, contain only letters/digits/hyphens")] + InvalidEnvironmentName { + name: String, + #[source] + source: EnvironmentNameError, + }, + + /// Environment not found or inaccessible + /// + /// The environment couldn't be loaded from persistent storage. + /// Use `.help()` for detailed troubleshooting steps. + #[error( + "Environment '{name}' not found in data directory '{data_dir}' +Tip: Check if environment exists: ls -la {data_dir}/" + )] + EnvironmentNotFound { name: String, data_dir: String }, + + /// Environment does not have instance IP set + /// + /// The environment is missing the instance IP, which means it hasn't been provisioned yet. + /// Use `.help()` for detailed troubleshooting steps. + #[error( + "Environment '{name}' does not have instance IP set +Tip: Environment must be provisioned before testing" + )] + MissingInstanceIp { name: String }, + + // ===== Validation Operation Errors ===== + /// Validation operation failed + /// + /// The validation process encountered an error during execution. + /// Use `.help()` for detailed troubleshooting steps. + #[error( + "Validation failed for environment '{name}': {source} +Tip: Check logs and try running with --log-output file-and-stderr for more details" + )] + ValidationFailed { + name: String, + #[source] + source: Box, + }, + + // ===== Internal Errors ===== + /// Progress reporting failed + /// + /// Failed to report progress to the user due to an internal error. + /// This indicates a critical internal error. + #[error( + "Failed to report progress: {source} +Tip: This is a critical bug - please report it with full logs using --log-output file-and-stderr" + )] + ProgressReportingFailed { + #[source] + source: ProgressReporterError, + }, +} + +// ============================================================================ +// ERROR CONVERSIONS +// ============================================================================ + +impl From for TestSubcommandError { + fn from(source: ProgressReporterError) -> Self { + Self::ProgressReportingFailed { source } + } +} + +impl TestSubcommandError { + /// Get detailed troubleshooting guidance for this error + /// + /// This method provides comprehensive troubleshooting steps that can be + /// displayed to users when they need more help resolving the error. + /// + /// # Example + /// + /// Using with Container and `ExecutionContext` (recommended): + /// + /// ```rust + /// use std::path::Path; + /// use std::sync::Arc; + /// use torrust_tracker_deployer_lib::bootstrap::Container; + /// use torrust_tracker_deployer_lib::presentation::dispatch::ExecutionContext; + /// use torrust_tracker_deployer_lib::presentation::controllers::test; + /// use torrust_tracker_deployer_lib::presentation::views::VerbosityLevel; + /// + /// # #[tokio::main] + /// # async fn main() { + /// let container = Container::new(VerbosityLevel::Normal); + /// let context = ExecutionContext::new(Arc::new(container)); + /// + /// if let Err(e) = test::handle("test-env", Path::new("."), &context).await { + /// eprintln!("Error: {e}"); + /// eprintln!("\nTroubleshooting:\n{}", e.help()); + /// } + /// # } + /// ``` + /// + /// Direct usage (for testing): + /// + /// ```rust + /// use std::path::Path; + /// use std::sync::Arc; + /// use parking_lot::ReentrantMutex; + /// use std::cell::RefCell; + /// use torrust_tracker_deployer_lib::presentation::controllers::test; + /// use torrust_tracker_deployer_lib::presentation::views::{UserOutput, VerbosityLevel}; + /// use torrust_tracker_deployer_lib::infrastructure::persistence::repository_factory::RepositoryFactory; + /// use torrust_tracker_deployer_lib::presentation::controllers::constants::DEFAULT_LOCK_TIMEOUT; + /// + /// # #[tokio::main] + /// # async fn main() { + /// let output = Arc::new(ReentrantMutex::new(RefCell::new(UserOutput::new(VerbosityLevel::Normal)))); + /// let repository_factory = Arc::new(RepositoryFactory::new(DEFAULT_LOCK_TIMEOUT)); + /// if let Err(e) = test::handle_test_command("test-env", Path::new("."), repository_factory, &output).await { + /// eprintln!("Error: {e}"); + /// eprintln!("\nTroubleshooting:\n{}", e.help()); + /// } + /// # } + /// ``` + #[must_use] + #[allow(clippy::too_many_lines)] // Help text is comprehensive for user guidance + pub fn help(&self) -> &'static str { + match self { + Self::InvalidEnvironmentName { .. } => { + "Invalid Environment Name - Detailed Troubleshooting: + +1. Check environment name format: + - Length: Must be 1-63 characters + - Start: Must begin with a letter or digit + - Characters: Only letters, digits, and hyphens allowed + - No special characters: Avoid spaces, underscores, dots + +2. Valid examples: + - 'production' + - 'staging-01' + - 'dev-environment' + +3. Invalid examples: + - 'prod_01' (underscore not allowed) + - '-production' (cannot start with hyphen) + - 'prod.env' (dots not allowed) + +For more information, see environment naming documentation." + } + + Self::EnvironmentNotFound { .. } => { + "Environment Not Found - Detailed Troubleshooting: + +1. Verify environment exists: + - List environments: ls -la data/ + - Check for environment.json file in data// + +2. Check file permissions: + - Read permission: chmod +r data//environment.json + - Directory access: chmod +rx data// + +3. Create environment first: + - Run: torrust-tracker-deployer create + +4. Verify data directory: + - Ensure data/ directory exists + - Check disk space: df -h" + } + + Self::MissingInstanceIp { .. } => { + "Missing Instance IP - Detailed Troubleshooting: + +1. Environment must be provisioned before testing: + - Run: torrust-tracker-deployer provision + +2. Verify provisioning status: + - Check environment state in data//environment.json + - Look for 'instance_ip' field + +3. Common causes: + - Environment was created but never provisioned + - Previous provision operation failed + - Manual modification of environment.json + +4. Next steps: + - Provision the environment: torrust-tracker-deployer provision + - Or destroy and recreate: torrust-tracker-deployer destroy " + } + + Self::ValidationFailed { .. } => { + "Validation Failed - Detailed Troubleshooting: + +1. Check validation logs for specific failure: + - Re-run with verbose logging: + torrust-tracker-deployer test --log-output file-and-stderr + +2. Common validation failures: + - Cloud-init not completed: Wait for instance initialization + - Docker not installed: Run configure command + - Docker Compose not installed: Run configure command + +3. Remediation steps: + - If cloud-init failed: Destroy and re-provision + - If Docker/Compose missing: Run configure command + torrust-tracker-deployer configure + +4. Check instance status: + - Verify instance is running + - Check SSH connectivity + - Review system logs on the instance" + } + + Self::ProgressReportingFailed { .. } => { + "Progress Reporting Failed - Critical Internal Error: + +This is a critical bug that should be reported to the development team. + +1. Gather diagnostic information: + - Re-run command with full logging: + torrust-tracker-deployer test --log-output file-and-stderr + - Capture all error output + +2. Report the issue: + - Include full error message + - Include command that triggered the error + - Include environment information (OS, version) + - Attach log files from data/logs/ + +3. Temporary workaround: + - None available - this indicates a serious internal error + - Try restarting the application + +For bug reports, visit: +https://github.com/torrust/torrust-tracker-deployer/issues" + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_invalid_environment_name_help_message() { + let error = TestSubcommandError::InvalidEnvironmentName { + name: "invalid_name".to_string(), + source: EnvironmentNameError::InvalidFormat { + attempted_name: "invalid_name".to_string(), + reason: "contains underscore".to_string(), + valid_examples: vec!["dev".to_string(), "staging".to_string()], + }, + }; + + let help = error.help(); + assert!(help.contains("Invalid Environment Name")); + assert!(help.contains("1-63 characters")); + assert!(help.contains("Valid examples")); + } + + #[test] + fn test_environment_not_found_help_message() { + let error = TestSubcommandError::EnvironmentNotFound { + name: "test-env".to_string(), + data_dir: "/path/to/data".to_string(), + }; + + let help = error.help(); + assert!(help.contains("Environment Not Found")); + assert!(help.contains("ls -la data/")); + assert!(help.contains("torrust-tracker-deployer create")); + } + + #[test] + fn test_missing_instance_ip_help_message() { + let error = TestSubcommandError::MissingInstanceIp { + name: "test-env".to_string(), + }; + + let help = error.help(); + assert!(help.contains("Missing Instance IP")); + assert!(help.contains("torrust-tracker-deployer provision")); + } + + #[test] + fn test_validation_failed_help_message() { + let error = TestSubcommandError::ValidationFailed { + name: "test-env".to_string(), + source: Box::new(TestCommandHandlerError::MissingInstanceIp { + environment_name: "test-env".to_string(), + }), + }; + + let help = error.help(); + assert!(help.contains("Validation Failed")); + assert!(help.contains("--log-output file-and-stderr")); + assert!(help.contains("Cloud-init")); + assert!(help.contains("Docker")); + } +} diff --git a/src/presentation/controllers/test/handler.rs b/src/presentation/controllers/test/handler.rs new file mode 100644 index 00000000..fdd94a90 --- /dev/null +++ b/src/presentation/controllers/test/handler.rs @@ -0,0 +1,349 @@ +//! Test Command Handler +//! +//! This module handles the test command execution at the presentation layer, +//! including environment validation, repository initialization, and user interaction. + +use std::cell::RefCell; +use std::path::PathBuf; +use std::sync::Arc; + +use parking_lot::ReentrantMutex; + +use crate::application::command_handlers::TestCommandHandler; +use crate::domain::environment::name::EnvironmentName; +use crate::domain::environment::repository::EnvironmentRepository; +use crate::infrastructure::persistence::repository_factory::RepositoryFactory; +use crate::presentation::dispatch::context::ExecutionContext; +use crate::presentation::views::progress::ProgressReporter; +use crate::presentation::views::UserOutput; + +use super::errors::TestSubcommandError; + +// ============================================================================ +// CONSTANTS +// ============================================================================ + +/// Number of main steps in the test workflow +const TEST_WORKFLOW_STEPS: usize = 4; + +// ============================================================================ +// HIGH-LEVEL API (EXECUTION CONTEXT PATTERN) +// ============================================================================ + +/// Handle test command using `ExecutionContext` pattern +/// +/// This function provides a clean interface for testing deployment environments, +/// integrating with the `ExecutionContext` pattern for dependency injection. +/// +/// # Arguments +/// +/// * `environment_name` - Name of the environment to test +/// * `working_dir` - Working directory path for operations +/// * `context` - Execution context providing access to services +/// +/// # Returns +/// +/// * `Ok(())` - Environment tested successfully +/// * `Err(TestSubcommandError)` - Test operation failed +/// +/// # Errors +/// +/// Returns `TestSubcommandError` when: +/// * Environment name is invalid or contains special characters +/// * Working directory is not accessible or doesn't exist +/// * Environment is not found or doesn't have instance IP set +/// * Infrastructure validation fails (cloud-init, Docker, Docker Compose) +/// * SSH connectivity cannot be established +/// +/// # Examples +/// +/// ```rust +/// use std::path::Path; +/// use std::sync::Arc; +/// use torrust_tracker_deployer_lib::presentation::controllers::test; +/// use torrust_tracker_deployer_lib::presentation::dispatch::context::ExecutionContext; +/// use torrust_tracker_deployer_lib::bootstrap::container::Container; +/// use torrust_tracker_deployer_lib::presentation::views::VerbosityLevel; +/// +/// # async fn example() -> Result<(), Box> { +/// let container = Arc::new(Container::new(VerbosityLevel::Normal)); +/// let context = ExecutionContext::new(container); +/// let working_dir = Path::new("./test"); +/// +/// test::handle("my-env", working_dir, &context).await?; +/// # Ok(()) +/// # } +/// ``` +pub async fn handle( + environment_name: &str, + working_dir: &std::path::Path, + context: &ExecutionContext, +) -> Result<(), TestSubcommandError> { + handle_test_command( + environment_name, + working_dir, + context.repository_factory(), + &context.user_output(), + ) + .await +} + +// ============================================================================ +// INTERMEDIATE API (DIRECT DEPENDENCY INJECTION) +// ============================================================================ + +/// Handle the test command +/// +/// This is a thin wrapper over `TestCommandController` that serves as +/// the public entry point for the test command. +/// +/// # Arguments +/// +/// * `environment_name` - The name of the environment to test +/// * `working_dir` - Root directory for environment data storage +/// * `repository_factory` - Factory for creating environment repositories +/// * `user_output` - Shared user output service for consistent output formatting +/// +/// # Errors +/// +/// Returns an error if: +/// - Environment name is invalid (format validation fails) +/// - Environment cannot be loaded from repository +/// - Environment doesn't have instance IP set (not provisioned) +/// - Infrastructure validation fails +/// - Progress reporting encounters a poisoned mutex +/// +/// All errors include detailed context and actionable troubleshooting guidance. +/// +/// # Returns +/// +/// Returns `Ok(())` on success, or a `TestSubcommandError` on failure. +/// +/// # Example +/// +/// Using with Container and `ExecutionContext` (recommended): +/// +/// ```rust +/// use std::path::Path; +/// use std::sync::Arc; +/// use torrust_tracker_deployer_lib::bootstrap::Container; +/// use torrust_tracker_deployer_lib::presentation::dispatch::ExecutionContext; +/// use torrust_tracker_deployer_lib::presentation::controllers::test; +/// use torrust_tracker_deployer_lib::presentation::views::VerbosityLevel; +/// +/// # #[tokio::main] +/// # async fn main() { +/// let container = Container::new(VerbosityLevel::Normal); +/// let context = ExecutionContext::new(Arc::new(container)); +/// +/// if let Err(e) = test::handle("test-env", Path::new("."), &context).await { +/// eprintln!("Test failed: {e}"); +/// eprintln!("Help: {}", e.help()); +/// } +/// # } +/// ``` +/// +/// Direct usage (for testing or specialized scenarios): +/// +/// ```rust +/// use std::path::Path; +/// use std::sync::Arc; +/// use parking_lot::ReentrantMutex; +/// use std::cell::RefCell; +/// use torrust_tracker_deployer_lib::presentation::controllers::test; +/// use torrust_tracker_deployer_lib::presentation::views::{UserOutput, VerbosityLevel}; +/// use torrust_tracker_deployer_lib::infrastructure::persistence::repository_factory::RepositoryFactory; +/// use torrust_tracker_deployer_lib::presentation::controllers::constants::DEFAULT_LOCK_TIMEOUT; +/// +/// # #[tokio::main] +/// # async fn main() { +/// let user_output = Arc::new(ReentrantMutex::new(RefCell::new(UserOutput::new(VerbosityLevel::Normal)))); +/// let repository_factory = Arc::new(RepositoryFactory::new(DEFAULT_LOCK_TIMEOUT)); +/// if let Err(e) = test::handle_test_command("test-env", Path::new("."), repository_factory, &user_output).await { +/// eprintln!("Test failed: {e}"); +/// eprintln!("Help: {}", e.help()); +/// } +/// # } +/// ``` +#[allow(clippy::needless_pass_by_value)] // Arc parameters are moved to constructor for ownership +pub async fn handle_test_command( + environment_name: &str, + working_dir: &std::path::Path, + repository_factory: Arc, + user_output: &Arc>>, +) -> Result<(), TestSubcommandError> { + TestCommandController::new( + working_dir.to_path_buf(), + repository_factory, + user_output.clone(), + ) + .execute(environment_name) + .await +} + +// ============================================================================ +// PRESENTATION LAYER CONTROLLER (IMPLEMENTATION DETAILS) +// ============================================================================ + +/// Presentation layer controller for test command workflow +/// +/// Coordinates user interaction, progress reporting, and input validation +/// while delegating business logic to the application layer's `TestCommandHandler`. +/// +/// ## Responsibilities +/// +/// - Validate environment name format +/// - Create and invoke the application layer `TestCommandHandler` +/// - Report progress to the user (4 steps) +/// - Format success/error messages with actionable guidance +/// +/// ## Architecture +/// +/// This controller **only orchestrates the workflow** - all validation logic +/// is implemented in the `TestCommandHandler` at the application layer. +/// +/// The `TestCommandHandler.execute()` method performs all infrastructure validation: +/// - Cloud-init completion check +/// - Docker installation verification +/// - Docker Compose installation verification +struct TestCommandController { + repository: Arc, + progress: ProgressReporter, +} + +impl TestCommandController { + /// Create a new `TestCommandController` with dependencies + /// + /// # Arguments + /// + /// * `working_dir` - Working directory containing the data folder + /// * `repository_factory` - Factory for creating environment repositories + /// * `user_output` - Shared output service for user feedback + #[allow(clippy::needless_pass_by_value)] // Arc parameters are moved to constructor for ownership + fn new( + working_dir: PathBuf, + repository_factory: Arc, + user_output: Arc>>, + ) -> Self { + let data_dir = working_dir.join("data"); + let repository = repository_factory.create(data_dir); + let progress = ProgressReporter::new(user_output, TEST_WORKFLOW_STEPS); + + Self { + repository, + progress, + } + } + + /// Execute the complete test workflow + /// + /// This method orchestrates the four-step workflow: + /// 1. Validate environment name + /// 2. Create command handler + /// 3. Execute validation workflow via application layer + /// 4. Complete workflow and display success message + /// + /// # Arguments + /// + /// * `environment_name` - Name of the environment to test + /// + /// # Errors + /// + /// Returns `TestSubcommandError` if any step fails + async fn execute(&mut self, environment_name: &str) -> Result<(), TestSubcommandError> { + // 1. Validate environment name + let env_name = self.validate_environment_name(environment_name)?; + + // 2. Create command handler + let handler = self.create_command_handler()?; + + // 3. Execute validation workflow via application layer + self.test_infrastructure(&handler, &env_name).await?; + + // 4. Complete workflow + self.complete_workflow(environment_name)?; + + Ok(()) + } + + /// Step 1: Validate environment name format + /// + /// # Errors + /// + /// Returns `TestSubcommandError::InvalidEnvironmentName` if validation fails + fn validate_environment_name( + &mut self, + name: &str, + ) -> Result { + self.progress.start_step("Validating environment")?; + + let env_name = EnvironmentName::new(name.to_string()).map_err(|source| { + TestSubcommandError::InvalidEnvironmentName { + name: name.to_string(), + source, + } + })?; + + self.progress + .complete_step(Some(&format!("Environment name validated: {name}")))?; + + Ok(env_name) + } + + /// Step 2: Create the application layer command handler + /// + /// # Errors + /// + /// Returns `TestSubcommandError::ProgressReportingFailed` if progress reporting fails + fn create_command_handler(&mut self) -> Result { + self.progress.start_step("Creating command handler")?; + + let handler = TestCommandHandler::new(self.repository.clone()); + self.progress.complete_step(None)?; + + Ok(handler) + } + + /// Step 3: Execute infrastructure validation tests + /// + /// Delegates all validation logic to the application layer `TestCommandHandler`. + /// The handler performs: + /// - Cloud-init completion check + /// - Docker installation verification + /// - Docker Compose installation verification + /// + /// # Errors + /// + /// Returns `TestSubcommandError::ValidationFailed` if any validation check fails + async fn test_infrastructure( + &mut self, + handler: &TestCommandHandler, + env_name: &EnvironmentName, + ) -> Result<(), TestSubcommandError> { + self.progress.start_step("Testing infrastructure")?; + + handler.execute(env_name).await.map_err(|source| { + TestSubcommandError::ValidationFailed { + name: env_name.to_string(), + source: Box::new(source), + } + })?; + + self.progress + .complete_step(Some("Infrastructure tests passed"))?; + + Ok(()) + } + + /// Step 4: Complete workflow and display success message + /// + /// # Errors + /// + /// Returns `TestSubcommandError::ProgressReportingFailed` if progress reporting fails + fn complete_workflow(&mut self, environment_name: &str) -> Result<(), TestSubcommandError> { + self.progress.complete(&format!( + "Infrastructure validation completed successfully for '{environment_name}'" + ))?; + Ok(()) + } +} diff --git a/src/presentation/controllers/test/mod.rs b/src/presentation/controllers/test/mod.rs new file mode 100644 index 00000000..d73899b4 --- /dev/null +++ b/src/presentation/controllers/test/mod.rs @@ -0,0 +1,73 @@ +//! Test Command Presentation Module +//! +//! This module implements the CLI presentation layer for the test command, +//! handling argument processing and user interaction. +//! +//! ## Architecture +//! +//! The test command presentation layer follows the DDD pattern, orchestrating +//! the application layer's `TestCommandHandler` while providing user-friendly +//! output and error handling. +//! +//! ## Components +//! +//! - `errors` - Presentation layer error types with `.help()` methods +//! - `handler` - Main command handler orchestrating the workflow +//! +//! ## Usage Example +//! +//! ### Basic Usage +//! +//! ```rust +//! use std::path::Path; +//! use std::sync::Arc; +//! use torrust_tracker_deployer_lib::bootstrap::Container; +//! use torrust_tracker_deployer_lib::presentation::dispatch::ExecutionContext; +//! use torrust_tracker_deployer_lib::presentation::controllers::test; +//! use torrust_tracker_deployer_lib::presentation::views::VerbosityLevel; +//! +//! # #[tokio::main] +//! # async fn main() { +//! let container = Container::new(VerbosityLevel::Normal); +//! let context = ExecutionContext::new(Arc::new(container)); +//! +//! // Call the test handler +//! if let Err(e) = test::handle("my-environment", Path::new("."), &context).await { +//! eprintln!("Test failed: {e}"); +//! eprintln!("\n{}", e.help()); +//! } +//! # } +//! ``` +//! +//! ## Direct Usage (For Testing) +//! +//! ```rust +//! use std::path::Path; +//! use std::sync::Arc; +//! use parking_lot::ReentrantMutex; +//! use std::cell::RefCell; +//! use torrust_tracker_deployer_lib::presentation::controllers::test; +//! use torrust_tracker_deployer_lib::presentation::views::{UserOutput, VerbosityLevel}; +//! use torrust_tracker_deployer_lib::infrastructure::persistence::repository_factory::RepositoryFactory; +//! use torrust_tracker_deployer_lib::presentation::controllers::constants::DEFAULT_LOCK_TIMEOUT; +//! +//! # #[tokio::main] +//! # async fn main() { +//! let output = Arc::new(ReentrantMutex::new(RefCell::new(UserOutput::new(VerbosityLevel::Normal)))); +//! let repository_factory = Arc::new(RepositoryFactory::new(DEFAULT_LOCK_TIMEOUT)); +//! if let Err(e) = test::handle_test_command("test-env", Path::new("."), repository_factory, &output).await { +//! eprintln!("Test failed: {e}"); +//! eprintln!("\n{}", e.help()); +//! } +//! # } +//! ``` + +pub mod errors; +pub mod handler; + +#[cfg(test)] +mod tests; + +// Re-export commonly used types for convenience +pub use errors::TestSubcommandError; +pub use handler::{handle, handle_test_command}; diff --git a/src/presentation/controllers/test/tests/mod.rs b/src/presentation/controllers/test/tests/mod.rs new file mode 100644 index 00000000..29702c4a --- /dev/null +++ b/src/presentation/controllers/test/tests/mod.rs @@ -0,0 +1 @@ +//! Tests for the test command controller diff --git a/src/presentation/dispatch/router.rs b/src/presentation/dispatch/router.rs index 3111f4b5..e62c0ad2 100644 --- a/src/presentation/dispatch/router.rs +++ b/src/presentation/dispatch/router.rs @@ -52,7 +52,7 @@ use std::path::Path; -use crate::presentation::controllers::{configure, create, destroy, provision}; +use crate::presentation::controllers::{configure, create, destroy, provision, test}; use crate::presentation::errors::CommandError; use crate::presentation::input::Commands; @@ -124,6 +124,10 @@ pub async fn route_command( Commands::Configure { environment } => { configure::handle(&environment, working_dir, context).await?; Ok(()) + } + Commands::Test { environment } => { + test::handle(&environment, working_dir, context).await?; + Ok(()) } // Future commands will be added here as the Controller Layer expands: // // Commands::Release { environment, version } => { diff --git a/src/presentation/errors.rs b/src/presentation/errors.rs index 6b6fc55c..fb6dad81 100644 --- a/src/presentation/errors.rs +++ b/src/presentation/errors.rs @@ -22,6 +22,7 @@ use thiserror::Error; use crate::presentation::controllers::{ configure::ConfigureSubcommandError, create::CreateCommandError, destroy::DestroySubcommandError, provision::ProvisionSubcommandError, + test::TestSubcommandError, }; /// Errors that can occur during CLI command execution @@ -59,6 +60,13 @@ pub enum CommandError { #[error("Configure command failed: {0}")] Configure(Box), + /// Test command specific errors + /// + /// Encapsulates all errors that can occur during infrastructure validation. + /// Use `.help()` for detailed troubleshooting steps. + #[error("Test command failed: {0}")] + Test(Box), + /// User output lock acquisition failed /// /// Failed to acquire the mutex lock for user output. This typically indicates @@ -91,6 +99,12 @@ impl From for CommandError { } } +impl From for CommandError { + fn from(error: TestSubcommandError) -> Self { + Self::Test(Box::new(error)) + } +} + impl CommandError { /// Get detailed troubleshooting guidance for this error /// @@ -131,6 +145,7 @@ impl CommandError { Self::Destroy(e) => e.help(), Self::Provision(e) => e.help(), Self::Configure(e) => e.help(), + Self::Test(e) => e.help(), Self::UserOutputLockFailed => { "User Output Lock Failed - Detailed Troubleshooting: diff --git a/src/presentation/input/cli/commands.rs b/src/presentation/input/cli/commands.rs index e205bf4e..fe8ca37d 100644 --- a/src/presentation/input/cli/commands.rs +++ b/src/presentation/input/cli/commands.rs @@ -70,6 +70,23 @@ pub enum Commands { /// previously provisioned and is in "Provisioned" state. environment: String, }, + + /// Verify deployment infrastructure + /// + /// This command validates that a deployed environment's infrastructure is + /// properly configured and ready. It will: + /// - Verify cloud-init completion + /// - Verify Docker installation + /// - Verify Docker Compose installation + /// + /// The environment must have an instance IP set (use 'provision' command first). + Test { + /// Name of the environment to test + /// + /// The environment name must match an existing environment that was + /// previously provisioned and has an instance IP assigned. + environment: String, + }, // Future commands will be added here: // // /// Create a new release of the deployed application diff --git a/src/presentation/input/cli/mod.rs b/src/presentation/input/cli/mod.rs index 19591ad0..66567937 100644 --- a/src/presentation/input/cli/mod.rs +++ b/src/presentation/input/cli/mod.rs @@ -47,7 +47,10 @@ mod tests { Commands::Destroy { environment } => { assert_eq!(environment, "test-env"); } - Commands::Create { .. } | Commands::Provision { .. } | Commands::Configure { .. } => { + Commands::Create { .. } + | Commands::Provision { .. } + | Commands::Configure { .. } + | Commands::Test { .. } => { panic!("Expected Destroy command") } } @@ -67,7 +70,8 @@ mod tests { } Commands::Create { .. } | Commands::Provision { .. } - | Commands::Configure { .. } => { + | Commands::Configure { .. } + | Commands::Test { .. } => { panic!("Expected Destroy command") } } @@ -110,7 +114,10 @@ mod tests { Commands::Destroy { environment } => { assert_eq!(environment, "test-env"); } - Commands::Create { .. } | Commands::Provision { .. } | Commands::Configure { .. } => { + Commands::Create { .. } + | Commands::Provision { .. } + | Commands::Configure { .. } + | Commands::Test { .. } => { panic!("Expected Destroy command") } } @@ -196,7 +203,10 @@ mod tests { panic!("Expected Environment action") } }, - Commands::Destroy { .. } | Commands::Provision { .. } | Commands::Configure { .. } => { + Commands::Destroy { .. } + | Commands::Provision { .. } + | Commands::Configure { .. } + | Commands::Test { .. } => { panic!("Expected Create command") } } @@ -222,7 +232,10 @@ mod tests { panic!("Expected Environment action") } }, - Commands::Destroy { .. } | Commands::Provision { .. } | Commands::Configure { .. } => { + Commands::Destroy { .. } + | Commands::Provision { .. } + | Commands::Configure { .. } + | Commands::Test { .. } => { panic!("Expected Create command") } } @@ -269,7 +282,10 @@ mod tests { panic!("Expected Environment action") } }, - Commands::Destroy { .. } | Commands::Provision { .. } | Commands::Configure { .. } => { + Commands::Destroy { .. } + | Commands::Provision { .. } + | Commands::Configure { .. } + | Commands::Test { .. } => { panic!("Expected Create command") } } @@ -319,7 +335,10 @@ mod tests { panic!("Expected Template action") } }, - Commands::Destroy { .. } | Commands::Provision { .. } | Commands::Configure { .. } => { + Commands::Destroy { .. } + | Commands::Provision { .. } + | Commands::Configure { .. } + | Commands::Test { .. } => { panic!("Expected Create command") } } @@ -347,7 +366,10 @@ mod tests { panic!("Expected Template action") } }, - Commands::Destroy { .. } | Commands::Provision { .. } | Commands::Configure { .. } => { + Commands::Destroy { .. } + | Commands::Provision { .. } + | Commands::Configure { .. } + | Commands::Test { .. } => { panic!("Expected Create command") } }