diff --git a/docs/codebase-architecture.md b/docs/codebase-architecture.md index aa6efc72..df075de1 100644 --- a/docs/codebase-architecture.md +++ b/docs/codebase-architecture.md @@ -10,6 +10,14 @@ The project follows **Domain-Driven Design** principles with a layered architect ```text ┌─────────────────────────────────────┐ +│ Presentation Layer │ +│ (CLI, User Output, Command │ +│ Dispatch, Error Display) │ +│ src/presentation/ │ +└────────────┬────────────────────────┘ + │ depends on + ↓ +┌─────────────────────────────────────┐ │ Application Layer │ │ (Commands, Use Cases, Steps) │ │ src/application/ │ @@ -35,6 +43,18 @@ The project follows **Domain-Driven Design** principles with a layered architect ### Layer Responsibilities +**Presentation Layer** (`src/presentation/`): + +- **Purpose**: User interface and interaction handling +- **Contains**: CLI parsing, command dispatch, user output, error presentation, help systems +- **Rules**: Depends on application layer, handles all user-facing concerns +- **Example**: CLI subcommands calling application command handlers with user-friendly error messages +- **Module Structure**: + - `cli/` - Command-line argument parsing and validation + - `commands/` - Command execution handlers and dispatch logic + - `errors.rs` - Unified error types with tiered help system + - `user_output.rs` - User-facing output management and verbosity control + **Domain Layer** (`src/domain/`): - **Purpose**: Core business logic and domain entities @@ -58,14 +78,16 @@ The project follows **Domain-Driven Design** principles with a layered architect ### Dependency Rule -The fundamental rule is that **dependencies flow inward**: +The fundamental rule is that **dependencies flow inward toward the domain**: +- Presentation → Application → Domain (✅ Correct) - Infrastructure → Domain (✅ Correct) -- Application → Domain (✅ Correct) -- Domain → Infrastructure (❌ Forbidden) - Domain → Application (❌ Forbidden) +- Domain → Infrastructure (❌ Forbidden) +- Domain → Presentation (❌ Forbidden) +- Application → Presentation (❌ Forbidden) -This ensures the domain layer remains pure business logic, free from technical implementation details. +This ensures the domain layer remains pure business logic, free from technical implementation details and user interface concerns. The presentation layer orchestrates the application layer but never contains business logic. ## 🏗️ Three-Level Architecture Pattern @@ -181,6 +203,21 @@ All modules include comprehensive `//!` documentation with: - ✅ `src/bin/e2e-provision-and-destroy-tests.rs` - E2E provisioning and destruction tests - ✅ `src/bin/e2e-tests-full.rs` - Full E2E test suite +### Presentation Layer + +**CLI Interface and User Interaction:** + +- ✅ `src/presentation/mod.rs` - Presentation layer root module with exports +- ✅ `src/presentation/cli/` - Command-line interface parsing and structure + - `cli/mod.rs` - Main Cli struct and global argument definitions + - `cli/args.rs` - Global CLI arguments (logging configuration) + - `cli/commands.rs` - Subcommand definitions (destroy, future commands) +- ✅ `src/presentation/commands/` - Command execution handlers + - `commands/mod.rs` - Unified command dispatch and error handling + - `commands/destroy.rs` - Destroy command handler with error management +- ✅ `src/presentation/errors.rs` - Unified error types with tiered help system +- ✅ `src/presentation/user_output.rs` - User-facing output management and verbosity control + ### Domain Layer **Core Domain Entities:** @@ -381,11 +418,55 @@ The typical deployment flow follows this pattern: ## 📊 Module Statistics - **Total Modules**: ~100+ Rust files -- **Architecture Layers**: 3 (Domain, Application, Infrastructure) + Shared +- **Architecture Layers**: 4 (Presentation, Application, Domain, Infrastructure) + Shared - **External Tool Integrations**: 3 (OpenTofu, Ansible, LXD) - **Step Categories**: 7 (Infrastructure, System, Software, Validation, Connectivity, Application, Rendering) - **State Types**: 13+ environment states with type-state pattern +## 🏗️ Architectural Guidance for Development + +When working on this codebase, follow these guidelines to maintain architectural integrity: + +### Layer Selection Guide + +**When creating new functionality, choose the appropriate layer:** + +- **Presentation Layer** (`src/presentation/`): CLI commands, user output, error display, input validation +- **Application Layer** (`src/application/`): Use cases, command orchestration, workflow coordination +- **Domain Layer** (`src/domain/`): Business entities, value objects, domain rules, state machines +- **Infrastructure Layer** (`src/infrastructure/`): External tool integration, file operations, remote actions + +### Module Placement Rules + +1. **CLI and User Interface**: Always in `src/presentation/` +2. **Business Logic**: Always in `src/domain/` +3. **Use Case Orchestration**: Always in `src/application/` +4. **External Integration**: Always in `src/infrastructure/` +5. **Cross-Layer Utilities**: Only in `src/shared/` (use sparingly) + +### Dependency Guidelines + +- ✅ **Allowed**: Presentation → Application → Domain ← Infrastructure +- ❌ **Forbidden**: Domain depending on any other layer +- ❌ **Forbidden**: Application depending on Presentation or Infrastructure +- ❌ **Forbidden**: Circular dependencies between any layers + +### Implementation Patterns + +- **Error Handling**: Use structured enums with `thiserror`, implement tiered help systems +- **Module Organization**: Follow [docs/contributing/module-organization.md](../docs/contributing/module-organization.md) +- **Testing**: Layer-appropriate testing (unit tests per layer, integration tests across layers) + +### Quality Assurance + +Before implementing new features: + +1. **Identify the correct layer** based on the functionality's purpose +2. **Check architectural constraints** - ensure no forbidden dependencies +3. **Follow module organization** - public before private, important before secondary +4. **Implement proper error handling** - structured errors with actionable messages +5. **Add comprehensive tests** - appropriate for the layer and functionality + ## 💡 Key Design Principles - **Domain-Driven Design**: Pure domain logic independent of infrastructure diff --git a/docs/contributing/roadmap-issues.md b/docs/contributing/roadmap-issues.md index dfcd4c39..2374fc23 100644 --- a/docs/contributing/roadmap-issues.md +++ b/docs/contributing/roadmap-issues.md @@ -45,7 +45,7 @@ Create a detailed specification in `docs/issues/` with temporary name: ```bash # Copy the template to create your specification document -cp docs/issues/TEMPLATE.md docs/issues/setup-logging-for-production-cli.md +cp docs/issues/SPECIFICATION-TEMPLATE.md docs/issues/setup-logging-for-production-cli.md # Edit the document to fill in all sections vim docs/issues/setup-logging-for-production-cli.md @@ -53,7 +53,7 @@ vim docs/issues/setup-logging-for-production-cli.md #### Document Structure -Use the template at [`docs/issues/TEMPLATE.md`](../issues/TEMPLATE.md) as your starting point. The template includes: +Use the template at [`docs/issues/SPECIFICATION-TEMPLATE.md`](../issues/SPECIFICATION-TEMPLATE.md) as your starting point. The template includes: - **Header**: Issue number, parent epic, and related links - **Overview**: Clear description of what the task accomplishes @@ -71,6 +71,18 @@ Use the template at [`docs/issues/TEMPLATE.md`](../issues/TEMPLATE.md) as your s - **Provide Context**: Link to related documentation, ADRs, and examples - **Estimate Time**: Help others understand scope - **Define Success**: Clear acceptance criteria +- **Specify Architecture**: Include DDD layer, module path, and architectural constraints (see template guidance below) + +#### Architectural Requirements + +When creating issues that involve code changes, always specify: + +- **DDD Layer**: Which layer the change belongs to (Presentation, Application, Domain, Infrastructure) +- **Module Path**: Exact location in the codebase (`src/{layer}/{module}/`) +- **Architectural Constraints**: Dependencies, patterns, and anti-patterns to follow +- **Related Documentation**: Link to [docs/codebase-architecture.md](../docs/codebase-architecture.md) and other relevant architectural guidance + +This ensures AI assistants and contributors understand not just **what** to implement, but **where** and **how** to implement it within the project's architectural patterns. ### Step 2: Create GitHub Epic Issue (if needed) @@ -100,7 +112,7 @@ If this is the first task in a roadmap section, create an epic issue first: 2. **Click "New Issue"** -3. **Fill in Task Details**: Use the template at [`docs/issues/TASK-TEMPLATE.md`](../issues/TASK-TEMPLATE.md) and fill in: +3. **Fill in Task Details**: Use the template at [`docs/issues/GITHUB-ISSUE-TEMPLATE.md`](../issues/GITHUB-ISSUE-TEMPLATE.md) and fill in: - Title with the task name from roadmap - Overview with brief description @@ -223,7 +235,7 @@ Implementing roadmap task **1.1 Setup logging** ```bash # 1. Create initial specification document from template -cp docs/issues/TEMPLATE.md docs/issues/setup-logging-for-production-cli.md +cp docs/issues/SPECIFICATION-TEMPLATE.md docs/issues/setup-logging-for-production-cli.md vim docs/issues/setup-logging-for-production-cli.md # ... fill in all sections: overview, goals, specs, implementation plan ... diff --git a/docs/issues/23-add-clap-subcommand-configuration.md b/docs/issues/23-add-clap-subcommand-configuration.md index 41fe200d..7c33e2f7 100644 --- a/docs/issues/23-add-clap-subcommand-configuration.md +++ b/docs/issues/23-add-clap-subcommand-configuration.md @@ -57,7 +57,7 @@ pub enum Commands { ### VerbosityLevel Implementation -Create `src/shared/user_output.rs`: +Create `src/presentation/user_output.rs`: ```rust /// Verbosity levels for user output @@ -303,7 +303,7 @@ Update command execution in `src/app.rs`: ```rust async fn handle_destroy_command(environment: String) -> anyhow::Result<()> { use crate::application::command_handlers::destroy::DestroyCommandHandler; - use crate::shared::user_output::{UserOutput, VerbosityLevel}; + use crate::presentation::user_output::{UserOutput, VerbosityLevel}; // Create UserOutput with default stdout/stderr channels let mut output = UserOutput::new(VerbosityLevel::Normal); @@ -405,13 +405,13 @@ output.error("Failed to destroy environment 'my-env': OpenTofu destroy operation ### Subtask 1: Implement VerbosityLevel and UserOutput (1.5 hours) -- [ ] Create `src/shared/user_output.rs` -- [ ] Implement `VerbosityLevel` enum with 5 levels -- [ ] Implement `UserOutput` struct with dual writers (stdout/stderr) -- [ ] Add methods: `progress`, `success`, `warn`, `error` (stderr), `result`, `data` (stdout) -- [ ] Add constructor for default channels and testing with custom writers -- [ ] Document channel usage strategy based on console patterns research -- [ ] Update `src/shared/mod.rs` to export new module +- [x] Create `src/presentation/user_output.rs` (moved from shared to presentation layer) +- [x] Implement `VerbosityLevel` enum with 5 levels +- [x] Implement `UserOutput` struct with dual writers (stdout/stderr) +- [x] Add methods: `progress`, `success`, `warn`, `error` (stderr), `result`, `data` (stdout) +- [x] Add constructor for default channels and testing with custom writers +- [x] Document channel usage strategy based on console patterns research +- [x] Update `src/presentation/mod.rs` to export new module ### Subtask 2: Add Destroy Subcommand to Clap (45 minutes) diff --git a/docs/issues/GITHUB-ISSUE-TEMPLATE.md b/docs/issues/GITHUB-ISSUE-TEMPLATE.md new file mode 100644 index 00000000..a52fcd30 --- /dev/null +++ b/docs/issues/GITHUB-ISSUE-TEMPLATE.md @@ -0,0 +1,57 @@ +Title: [Task Name from Roadmap] + +## Overview + +[Brief description of what this task accomplishes] + +## Specification + +See detailed specification: [docs/issues/{number}-{name}.md](../docs/issues/{number}-{name}.md) + +(Link will be updated after file rename) + +## 🏗️ Architecture Requirements + +**DDD Layer**: [Domain | Application | Infrastructure | Presentation] +**Module Path**: `src/{layer}/{module}/` +**Pattern**: [Command | Step | Action | CLI Subcommand | Entity | Value Object | Repository] + +### Module Structure Requirements + +- [ ] Follow DDD layer separation (see [docs/codebase-architecture.md](../docs/codebase-architecture.md)) +- [ ] Respect dependency flow rules (dependencies flow toward domain) +- [ ] Use appropriate module organization (see [docs/contributing/module-organization.md](../docs/contributing/module-organization.md)) + +### Architectural Constraints + +- [ ] No business logic in presentation layer +- [ ] Error handling follows project conventions (see [docs/contributing/error-handling.md](../docs/contributing/error-handling.md)) +- [ ] Testing strategy aligns with layer responsibilities + +### Anti-Patterns to Avoid + +- ❌ Mixing concerns across layers +- ❌ Domain layer depending on infrastructure +- ❌ Monolithic modules with multiple responsibilities + +## Implementation Plan + +### Phase 1: [Phase Name] + +- [ ] Task 1.1 +- [ ] Task 1.2 + +### Phase 2: [Phase Name] + +- [ ] Task 2.1 + +## Acceptance Criteria + +- [ ] Criterion 1 +- [ ] Criterion 2 + +## Related + +- Parent: #X (Epic: [Epic Name]) +- Roadmap: #1 (Project Roadmap) +- Specification: docs/issues/{number}-{name}.md diff --git a/docs/issues/TEMPLATE.md b/docs/issues/SPECIFICATION-TEMPLATE.md similarity index 51% rename from docs/issues/TEMPLATE.md rename to docs/issues/SPECIFICATION-TEMPLATE.md index 6bb069ab..e737c54c 100644 --- a/docs/issues/TEMPLATE.md +++ b/docs/issues/SPECIFICATION-TEMPLATE.md @@ -14,6 +14,30 @@ - [ ] Goal 2 - [ ] Goal 3 +## 🏗️ Architecture Requirements + +**DDD Layer**: [Domain | Application | Infrastructure | Presentation] +**Module Path**: `src/{layer}/{module}/` +**Pattern**: [Command | Step | Action | CLI Subcommand | Entity | Value Object | Repository] + +### Module Structure Requirements + +- [ ] Follow DDD layer separation (see [docs/codebase-architecture.md](../docs/codebase-architecture.md)) +- [ ] Respect dependency flow rules (dependencies flow toward domain) +- [ ] Use appropriate module organization (see [docs/contributing/module-organization.md](../docs/contributing/module-organization.md)) + +### Architectural Constraints + +- [ ] No business logic in presentation layer +- [ ] Error handling follows project conventions (see [docs/contributing/error-handling.md](../docs/contributing/error-handling.md)) +- [ ] Testing strategy aligns with layer responsibilities + +### Anti-Patterns to Avoid + +- ❌ Mixing concerns across layers +- ❌ Domain layer depending on infrastructure +- ❌ Monolithic modules with multiple responsibilities + ## Specifications ### [Specification Section 1] diff --git a/docs/issues/TASK-TEMPLATE.md b/docs/issues/TASK-TEMPLATE.md deleted file mode 100644 index 0865c4bb..00000000 --- a/docs/issues/TASK-TEMPLATE.md +++ /dev/null @@ -1,33 +0,0 @@ -Title: [Task Name from Roadmap] - -## Overview - -[Brief description of what this task accomplishes] - -## Specification - -See detailed specification: [docs/issues/{number}-{name}.md](../docs/issues/{number}-{name}.md) - -(Link will be updated after file rename) - -## Implementation Plan - -### Phase 1: [Phase Name] - -- [ ] Task 1.1 -- [ ] Task 1.2 - -### Phase 2: [Phase Name] - -- [ ] Task 2.1 - -## Acceptance Criteria - -- [ ] Criterion 1 -- [ ] Criterion 2 - -## Related - -- Parent: #X (Epic: [Epic Name]) -- Roadmap: #1 (Project Roadmap) -- Specification: docs/issues/{number}-{name}.md diff --git a/project-words.txt b/project-words.txt index af33c73b..e8dc1bbe 100644 --- a/project-words.txt +++ b/project-words.txt @@ -106,6 +106,7 @@ Pulumi pytest RAII Repomix +reprovisioning reqwest resolv rgba diff --git a/src/app.rs b/src/app.rs index 98f0f02c..5edffee1 100644 --- a/src/app.rs +++ b/src/app.rs @@ -1,67 +1,34 @@ -//! Main application module for the Torrust Tracker Deployer CLI +//! Main Application Bootstrap //! -//! This module contains the CLI structure and main application logic. -//! It initializes logging and handles the application lifecycle. +//! This module provides a thin bootstrap layer for the Torrust Tracker Deployer CLI. +//! It handles application initialization, logging setup, and command dispatch while +//! delegating all CLI parsing and business logic to the presentation layer. +//! +//! ## Responsibilities +//! +//! - **Application Lifecycle**: Initialize and shutdown the application +//! - **Logging Setup**: Configure logging based on CLI arguments +//! - **Command Dispatch**: Route commands to the presentation layer for execution +//! - **Exit Handling**: Manage application exit codes and cleanup +//! +//! ## Design Principles +//! +//! - **Thin Layer**: Minimal logic, maximum delegation to appropriate layers +//! - **Single Responsibility**: Focus only on application bootstrap concerns +//! - **Clean Separation**: No CLI parsing or business logic in this module use clap::Parser; -use std::path::PathBuf; use tracing::info; -use torrust_tracker_deployer_lib::logging::{LogFormat, LogOutput, LoggingBuilder}; - -/// Command-line interface for Torrust Tracker Deployer -#[derive(Parser)] -#[command(name = "torrust-tracker-deployer")] -#[command(about = "Automated deployment infrastructure for Torrust Tracker")] -#[command(version)] -#[allow(clippy::struct_field_names)] // CLI arguments intentionally share 'log_' prefix for clarity -pub struct Cli { - /// Format for file logging (default: compact, without ANSI codes) - /// - /// - pretty: Pretty-printed output for development (no ANSI in files) - /// - json: JSON output for production environments (no ANSI) - /// - compact: Compact output for minimal verbosity (no ANSI in files) - /// - /// Note: ANSI color codes are automatically disabled for file output - /// to ensure logs are easily parsed with standard text tools (grep, awk, sed). - #[arg(long, value_enum, default_value = "compact", global = true)] - pub log_file_format: LogFormat, - - /// Format for stderr logging (default: pretty, with ANSI codes) - /// - /// - pretty: Pretty-printed output with colors for development - /// - json: JSON output for machine processing - /// - compact: Compact output with colors for minimal verbosity - /// - /// Note: ANSI color codes are automatically enabled for stderr output - /// to provide colored terminal output for better readability. - #[arg(long, value_enum, default_value = "pretty", global = true)] - pub log_stderr_format: LogFormat, - - /// Log output mode (default: file-only for production) - /// - /// - file-only: Write logs to file only (production mode) - /// - file-and-stderr: Write logs to both file and stderr (development/testing mode) - #[arg(long, value_enum, default_value = "file-only", global = true)] - pub log_output: LogOutput, - - /// Log directory (default: ./data/logs) - /// - /// Directory where log files will be written. The log file will be - /// named 'log.txt' inside this directory. Parent directories will be - /// created automatically if they don't exist. - /// - /// Note: If the directory cannot be created due to filesystem permissions, - /// the application will exit with an error. Logging is critical for - /// observability and the application cannot function without it. - #[arg(long, default_value = "./data/logs", global = true)] - pub log_dir: PathBuf, -} +use torrust_tracker_deployer_lib::{help, logging, presentation}; /// Main application entry point /// -/// This function initializes logging, displays information to the user, -/// and prepares the application for future command processing. +/// This function serves as the application bootstrap, handling: +/// 1. CLI argument parsing (delegated to presentation layer) +/// 2. Logging initialization using `LoggingConfig` +/// 3. Command execution (delegated to presentation layer) +/// 4. Error handling and exit code management /// /// # Panics /// @@ -71,51 +38,33 @@ pub struct Cli { /// /// Both panics are intentional as logging is critical for observability. pub fn run() { - let cli = Cli::parse(); + let cli = presentation::Cli::parse(); - // Clone values for logging before moving them - let log_file_format = cli.log_file_format.clone(); - let log_stderr_format = cli.log_stderr_format.clone(); - let log_output = cli.log_output; - let log_dir = cli.log_dir.clone(); + let logging_config = cli.global.logging_config(); - // Initialize logging FIRST before any other logic - LoggingBuilder::new(&cli.log_dir) - .with_file_format(cli.log_file_format) - .with_stderr_format(cli.log_stderr_format) - .with_output(cli.log_output) - .init(); + logging::init_subscriber(logging_config); - // Log startup event with configuration details info!( app = "torrust-tracker-deployer", version = env!("CARGO_PKG_VERSION"), - log_dir = %log_dir.display(), - log_file_format = ?log_file_format, - log_stderr_format = ?log_stderr_format, - log_output = ?log_output, + log_dir = %cli.global.log_dir.display(), + log_file_format = ?cli.global.log_file_format, + log_stderr_format = ?cli.global.log_stderr_format, + log_output = ?cli.global.log_output, "Application started" ); - // Display info to user (keep existing behavior for now) - println!("🏗️ Torrust Tracker Deployer"); - println!("========================="); - println!(); - println!("This repository provides automated deployment infrastructure for Torrust tracker projects."); - println!("The infrastructure includes VM provisioning with OpenTofu and configuration"); - println!("management with Ansible playbooks."); - println!(); - println!("📋 Getting Started:"); - println!(" Please follow the instructions in the README.md file to:"); - println!(" 1. Set up the required dependencies (OpenTofu, Ansible, LXD)"); - println!(" 2. Provision the deployment infrastructure"); - println!(" 3. Deploy and configure the services"); - println!(); - println!("🧪 Running E2E Tests:"); - println!(" Use the e2e tests binaries to run end-to-end tests:"); - println!(" cargo e2e-provision && cargo e2e-config"); - println!(); - println!("📖 For detailed instructions, see: README.md"); + match cli.command { + Some(command) => { + if let Err(e) = presentation::execute(command) { + presentation::handle_error(&e); + std::process::exit(1); + } + } + None => { + help::display_getting_started(); + } + } info!("Application finished"); } diff --git a/src/application/command_handlers/destroy.rs b/src/application/command_handlers/destroy.rs index 34ffa8c2..f1bdd628 100644 --- a/src/application/command_handlers/destroy.rs +++ b/src/application/command_handlers/destroy.rs @@ -17,6 +17,7 @@ use tracing::{info, instrument}; use crate::adapters::tofu::client::OpenTofuError; use crate::application::steps::DestroyInfrastructureStep; use crate::domain::environment::repository::EnvironmentRepository; +use crate::domain::environment::state::StateTypeError; use crate::domain::environment::{Destroyed, Environment}; use crate::shared::command::CommandError; @@ -32,6 +33,9 @@ pub enum DestroyCommandHandlerError { #[error("Failed to persist environment state: {0}")] StatePersistence(#[from] crate::domain::environment::repository::RepositoryError), + #[error("Invalid state transition: {0}")] + StateTransition(#[from] StateTypeError), + #[error("Failed to clean up state files at '{path}': {source}")] StateCleanupFailed { path: PathBuf, @@ -52,6 +56,9 @@ impl crate::shared::Traceable for DestroyCommandHandlerError { Self::StatePersistence(e) => { format!("DestroyCommandHandlerError: Failed to persist environment state - {e}") } + Self::StateTransition(e) => { + format!("DestroyCommandHandlerError: Invalid state transition - {e}") + } Self::StateCleanupFailed { path, source } => { format!( "DestroyCommandHandlerError: Failed to clean up state files at '{}' - {source}", @@ -65,7 +72,9 @@ impl crate::shared::Traceable for DestroyCommandHandlerError { match self { Self::OpenTofu(e) => Some(e), Self::Command(e) => Some(e), - Self::StatePersistence(_) | Self::StateCleanupFailed { .. } => None, + Self::StatePersistence(_) + | Self::StateTransition(_) + | Self::StateCleanupFailed { .. } => None, } } @@ -73,6 +82,7 @@ impl crate::shared::Traceable for DestroyCommandHandlerError { match self { Self::OpenTofu(_) => crate::shared::ErrorKind::InfrastructureOperation, Self::Command(_) => crate::shared::ErrorKind::CommandExecution, + Self::StateTransition(_) => crate::shared::ErrorKind::Configuration, Self::StatePersistence(_) | Self::StateCleanupFailed { .. } => { crate::shared::ErrorKind::StatePersistence } @@ -104,28 +114,21 @@ impl crate::shared::Traceable for DestroyCommandHandlerError { /// - Report appropriate status to the user /// - Not fail due to missing resources pub struct DestroyCommandHandler { - opentofu_client: Arc, repository: Arc, } impl DestroyCommandHandler { /// Create a new `DestroyCommandHandler` #[must_use] - pub fn new( - opentofu_client: Arc, - repository: Arc, - ) -> Self { - Self { - opentofu_client, - repository, - } + pub fn new(repository: Arc) -> Self { + Self { repository } } /// Execute the complete destruction workflow /// /// # Arguments /// - /// * `environment` - The environment to destroy (can be in any state) + /// * `env_name` - The name of the environment to destroy /// /// # Returns /// @@ -134,6 +137,8 @@ impl DestroyCommandHandler { /// # Errors /// /// Returns an error if any step in the destruction workflow fails: + /// * Environment not found or cannot be loaded + /// * Environment is in an invalid state for destruction /// * `OpenTofu` destroy fails /// * Unable to persist the destroyed state /// @@ -144,30 +149,64 @@ impl DestroyCommandHandler { skip_all, fields( command_type = "destroy", - environment = %environment.name() + environment = %env_name ) )] - pub fn execute( + pub fn execute( &self, - environment: Environment, - ) -> Result, DestroyCommandHandlerError> { + env_name: &crate::domain::environment::name::EnvironmentName, + ) -> Result, DestroyCommandHandlerError> + { + use crate::domain::environment::state::AnyEnvironmentState; + info!( command = "destroy", - environment = %environment.name(), + environment = %env_name, "Starting complete infrastructure destruction workflow" ); - // Execute infrastructure destruction + // 1. Load the environment from storage + let environment = self + .repository + .load(env_name) + .map_err(DestroyCommandHandlerError::StatePersistence)?; + + // 2. Check if environment exists + let environment = environment.ok_or_else(|| { + DestroyCommandHandlerError::StatePersistence( + crate::domain::environment::repository::RepositoryError::NotFound, + ) + })?; + + // 3. Check if environment is already destroyed + if let AnyEnvironmentState::Destroyed(env) = environment { + info!( + command = "destroy", + environment = %env_name, + "Environment is already destroyed" + ); + return Ok(env); + } + + // 4. Get the build directory from the environment context + let opentofu_build_dir = environment.tofu_build_dir(); + + // 5. Create OpenTofu client with correct build directory + let opentofu_client = Arc::new(crate::adapters::tofu::client::OpenTofuClient::new( + opentofu_build_dir, + )); + + // 6. Execute infrastructure destruction // OpenTofu destroy is idempotent - it will succeed even if infrastructure doesn't exist - self.destroy_infrastructure()?; + Self::destroy_infrastructure(&opentofu_client)?; - // Transition to Destroyed state - let destroyed = environment.destroy(); + // 7. Transition to Destroyed state based on current state + let destroyed = environment.destroy()?; - // Clean up state files only after successful infrastructure destruction + // 8. Clean up state files only after successful infrastructure destruction Self::cleanup_state_files(&destroyed)?; - // Persist final state + // 9. Persist final state self.repository.save(&destroyed.clone().into_any())?; info!( @@ -185,11 +224,17 @@ impl DestroyCommandHandler { /// /// Executes the `OpenTofu` destroy workflow to remove all managed infrastructure. /// + /// # Arguments + /// + /// * `opentofu_client` - The `OpenTofu` client configured with the correct build directory + /// /// # Errors /// /// Returns an error if `OpenTofu` destroy fails - fn destroy_infrastructure(&self) -> Result<(), DestroyCommandHandlerError> { - DestroyInfrastructureStep::new(Arc::clone(&self.opentofu_client)).execute()?; + fn destroy_infrastructure( + opentofu_client: &Arc, + ) -> Result<(), DestroyCommandHandlerError> { + DestroyInfrastructureStep::new(Arc::clone(opentofu_client)).execute()?; Ok(()) } @@ -271,17 +316,13 @@ mod tests { /// Returns: (`command_handler`, `temp_dir`) /// The `temp_dir` must be kept alive for the duration of the test. pub fn build(self) -> (DestroyCommandHandler, TempDir) { - let opentofu_client = Arc::new(crate::adapters::tofu::client::OpenTofuClient::new( - self.temp_dir.path(), - )); - let repository_factory = crate::infrastructure::persistence::repository_factory::RepositoryFactory::new( std::time::Duration::from_secs(30), ); let repository = repository_factory.create(self.temp_dir.path().to_path_buf()); - let command_handler = DestroyCommandHandler::new(opentofu_client, repository); + let command_handler = DestroyCommandHandler::new(repository); (command_handler, self.temp_dir) } @@ -293,7 +334,7 @@ mod tests { // Verify the command handler was created (basic structure test) // This test just verifies that the command handler can be created with the dependencies - assert_eq!(Arc::strong_count(&command_handler.opentofu_client), 1); + assert_eq!(Arc::strong_count(&command_handler.repository), 1); } #[test] diff --git a/src/domain/environment/internal_config.rs b/src/domain/environment/internal_config.rs index 98c8f292..5db05d9c 100644 --- a/src/domain/environment/internal_config.rs +++ b/src/domain/environment/internal_config.rs @@ -111,12 +111,14 @@ impl InternalConfig { self.build_dir.join(super::ANSIBLE_DIR_NAME) } - /// Returns the tofu build directory + /// Returns the `OpenTofu` build directory for the LXD provider /// - /// Path: `build/{env_name}/tofu` + /// Path: `build/{env_name}/tofu/lxd` #[must_use] pub fn tofu_build_dir(&self) -> PathBuf { - self.build_dir.join(super::TOFU_DIR_NAME) + self.build_dir + .join(super::TOFU_DIR_NAME) + .join(super::LXD_PROVIDER_NAME) } /// Returns the ansible templates directory diff --git a/src/domain/environment/mod.rs b/src/domain/environment/mod.rs index 9cad504d..51f36056 100644 --- a/src/domain/environment/mod.rs +++ b/src/domain/environment/mod.rs @@ -139,6 +139,9 @@ pub const ANSIBLE_DIR_NAME: &str = "ansible"; /// Directory name for OpenTofu-related files pub const TOFU_DIR_NAME: &str = "tofu"; +/// Provider name for LXD infrastructure +pub const LXD_PROVIDER_NAME: &str = "lxd"; + /// Environment configuration encapsulating all environment-specific settings /// /// This entity represents a complete environment configuration including naming, @@ -602,7 +605,7 @@ impl Environment { /// /// assert_eq!( /// environment.tofu_build_dir(), - /// PathBuf::from("build/test/tofu") + /// PathBuf::from("build/test/tofu/lxd") /// ); /// /// # Ok::<(), Box>(()) @@ -978,7 +981,7 @@ mod tests { // Assert assert_eq!(ansible_dir, build_dir.join("ansible")); - assert_eq!(tofu_dir, build_dir.join("tofu")); + assert_eq!(tofu_dir, build_dir.join("tofu").join("lxd")); } #[test] diff --git a/src/domain/environment/state/mod.rs b/src/domain/environment/state/mod.rs index 8bdfc3a2..b7789986 100644 --- a/src/domain/environment/state/mod.rs +++ b/src/domain/environment/state/mod.rs @@ -90,7 +90,7 @@ pub use running::Running; /// // let result = any_env.try_into_created(); /// // assert!(result.is_err()); /// ``` -#[derive(Debug, Error)] +#[derive(Debug, Clone, Error)] pub enum StateTypeError { /// The environment is in a different state than expected #[error("Expected state '{expected}', but found '{actual}'")] @@ -410,6 +410,60 @@ impl AnyEnvironmentState { pub fn instance_ip(&self) -> Option { self.context().runtime_outputs.instance_ip } + + /// Destroy the environment, transitioning it to the Destroyed state + /// + /// This method provides a unified interface to destroy an environment + /// regardless of its current state. It encapsulates the repetitive match + /// pattern that would otherwise be needed in calling code. + /// + /// # Returns + /// + /// - `Ok(Environment)` if the environment was successfully destroyed + /// - `Err(StateTypeError)` if the environment is already in the `Destroyed` state + /// + /// # Errors + /// + /// Returns `StateTypeError::UnexpectedState` if called on an environment + /// already in the `Destroyed` state. + pub fn destroy(self) -> Result, StateTypeError> { + match self { + Self::Created(env) => Ok(env.destroy()), + Self::Provisioning(env) => Ok(env.destroy()), + Self::Provisioned(env) => Ok(env.destroy()), + Self::Configuring(env) => Ok(env.destroy()), + Self::Configured(env) => Ok(env.destroy()), + Self::Releasing(env) => Ok(env.destroy()), + Self::Released(env) => Ok(env.destroy()), + Self::Running(env) => Ok(env.destroy()), + Self::ProvisionFailed(env) => Ok(env.destroy()), + Self::ConfigureFailed(env) => Ok(env.destroy()), + Self::ReleaseFailed(env) => Ok(env.destroy()), + Self::RunFailed(env) => Ok(env.destroy()), + Self::Destroyed(_) => Err(StateTypeError::UnexpectedState { + expected: "any state except destroyed", + actual: "destroyed".to_string(), + }), + } + } + + /// Get the `OpenTofu` build directory path regardless of current state + /// + /// This method provides a unified interface to access the build directory + /// for `OpenTofu` operations without needing to pattern match on the + /// specific state variant. + /// + /// The path is returned consistently regardless of the environment's state. + /// The caller is responsible for determining how to use the path based on + /// their specific needs and the environment's current state. + /// + /// # Returns + /// + /// The path to the `OpenTofu` build directory for the LXD provider. + #[must_use] + pub fn tofu_build_dir(&self) -> std::path::PathBuf { + self.context().tofu_build_dir() + } } /// Display implementation for user-friendly state representation @@ -1028,6 +1082,53 @@ mod tests { error_message ); } + + // Tests for new utility methods + + #[test] + fn it_should_destroy_environment_from_any_state() { + let env = super::create_test_environment_created(); + let any_env = AnyEnvironmentState::Created(env); + + let destroyed = any_env.destroy().unwrap(); + // Convert back to any state to check state name + let destroyed_any = destroyed.into_any(); + assert_eq!(destroyed_any.state_name(), "destroyed"); + } + + #[test] + fn it_should_get_tofu_build_dir_from_any_state() { + let env = super::create_test_environment_created(); + let any_env = AnyEnvironmentState::Created(env); + + let build_dir = any_env.tofu_build_dir(); + assert!(build_dir.ends_with("lxd")); + } + + #[test] + fn it_should_error_when_destroying_already_destroyed_environment() { + let env = super::create_test_environment_created().destroy(); + let any_env = AnyEnvironmentState::Destroyed(env); + + // This should return an error + let result = any_env.destroy(); + assert!(result.is_err()); + let error = result.unwrap_err(); + assert_eq!( + error.to_string(), + "Expected state 'any state except destroyed', but found 'destroyed'" + ); + } + + #[test] + fn it_should_get_tofu_build_dir_from_destroyed_environment() { + let env = super::create_test_environment_created().destroy(); + let any_env = AnyEnvironmentState::Destroyed(env); + + // This should now always return the path, even for destroyed environments + let build_dir = any_env.tofu_build_dir(); + assert!(build_dir.ends_with("lxd")); + } } mod introspection_tests { diff --git a/src/help.rs b/src/help.rs new file mode 100644 index 00000000..29627242 --- /dev/null +++ b/src/help.rs @@ -0,0 +1,130 @@ +//! Help and Usage Information Module +//! +//! This module provides help and usage information for the Torrust Tracker Deployer +//! application. It contains functions to display helpful information to users +//! when they need guidance on how to use the application. +//! +//! ## Design Principles +//! +//! - **Independent**: No dependencies on presentation layer or CLI structures +//! - **User-Focused**: Clear, actionable guidance for users +//! - **Comprehensive**: Covers getting started, examples, and next steps + +/// Display helpful information to the user when no command is provided +/// +/// This function shows getting started information, usage examples, and +/// helpful links when the user runs the application without any subcommands. +/// It provides a friendly introduction to the application and guides users +/// toward productive next steps. +/// +/// # Output +/// +/// Prints directly to stdout with formatted, user-friendly content including: +/// - Application overview and purpose +/// - Getting started instructions +/// - Testing guidance +/// - Documentation references +/// - Next steps for users +/// +/// # Example +/// +/// ```rust +/// use torrust_tracker_deployer_lib::help; +/// +/// // Display help when user runs app without arguments +/// help::display_getting_started(); +/// ``` +pub fn display_getting_started() { + println!("🏗️ Torrust Tracker Deployer"); + println!("========================="); + println!(); + println!("This repository provides automated deployment infrastructure for Torrust tracker projects."); + println!("The infrastructure includes VM provisioning with OpenTofu and configuration"); + println!("management with Ansible playbooks."); + println!(); + println!("📋 Getting Started:"); + println!(" Please follow the instructions in the README.md file to:"); + println!(" 1. Set up the required dependencies (OpenTofu, Ansible, LXD)"); + println!(" 2. Provision the deployment infrastructure"); + println!(" 3. Deploy and configure the services"); + println!(); + println!("🧪 Running E2E Tests:"); + println!(" Use the e2e tests binaries to run end-to-end tests:"); + println!(" cargo e2e-provision && cargo e2e-config"); + println!(); + println!("📖 For detailed instructions, see: README.md"); + println!(); + println!("💡 To see available commands, run: torrust-tracker-deployer --help"); +} + +/// Display troubleshooting information for common issues +/// +/// This function provides guidance for common problems users might encounter +/// when setting up or using the Torrust Tracker Deployer. +/// +/// # Output +/// +/// Prints troubleshooting guidance to stdout including: +/// - Common setup issues and solutions +/// - Dependency verification steps +/// - Configuration validation tips +/// - Where to get additional help +/// +/// # Example +/// +/// ```rust +/// use torrust_tracker_deployer_lib::help; +/// +/// // Display troubleshooting info when user encounters issues +/// help::display_troubleshooting(); +/// ``` +pub fn display_troubleshooting() { + println!("🔧 Troubleshooting Guide"); + println!("======================="); + println!(); + println!("Common issues and solutions:"); + println!(); + println!("1. Dependencies not found:"); + println!(" - Ensure OpenTofu is installed and in PATH"); + println!(" - Verify Ansible is installed and accessible"); + println!(" - Check that LXD is properly configured"); + println!(); + println!("2. Permission errors:"); + println!(" - Add your user to the lxd group: sudo usermod -aG lxd $USER"); + println!(" - Log out and log back in to apply group changes"); + println!(" - Verify permissions with: groups"); + println!(); + println!("3. Network connectivity issues:"); + println!(" - Check internet connectivity for image downloads"); + println!(" - Verify LXD daemon is running: lxd --version"); + println!(" - Test basic LXD functionality: lxc list"); + println!(); + println!("4. Configuration problems:"); + println!(" - Validate YAML/JSON syntax in configuration files"); + println!(" - Check that environment names follow naming conventions"); + println!(" - Ensure SSH keys are properly configured"); + println!(); + println!("📖 For more help:"); + println!(" - Check the docs/ directory for detailed guides"); + println!(" - Review the README.md for setup instructions"); + println!(" - Open an issue on GitHub for additional support"); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn it_should_display_getting_started_without_panicking() { + // Test that the function runs without panicking + // We can't easily test stdout content in unit tests, + // but we can ensure the function doesn't crash + display_getting_started(); + } + + #[test] + fn it_should_display_troubleshooting_without_panicking() { + // Test that the function runs without panicking + display_troubleshooting(); + } +} diff --git a/src/lib.rs b/src/lib.rs index 14e3812b..0b992b1b 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -16,11 +16,13 @@ //! - `ansible` - Ansible delivery mechanism and implementation details //! - `tofu` - `OpenTofu` delivery mechanism and implementation details //! - `template` - Template rendering delivery mechanisms (wrappers) +//! - `presentation` - Presentation Layer: User-facing output and presentation concerns //! //! ## Other Modules //! - `adapters` - External tool adapters (thin CLI wrappers) //! - `config` - Configuration management for deployment environments //! - `container` - Service container for dependency injection +//! - `help` - Help and usage information display functions //! - `logging` - Logging configuration and utilities //! - `shared` - Shared modules used across different layers //! - `testing` - Testing utilities (unit, integration, and end-to-end) @@ -30,7 +32,9 @@ pub mod application; pub mod config; pub mod container; pub mod domain; +pub mod help; pub mod infrastructure; pub mod logging; +pub mod presentation; pub mod shared; pub mod testing; diff --git a/src/logging.rs b/src/logging.rs index 97b6bd14..0970db06 100644 --- a/src/logging.rs +++ b/src/logging.rs @@ -79,6 +79,61 @@ pub enum LogFormat { Compact, } +// ============================================================================ +// LOGGING CONFIGURATION - Domain Type +// ============================================================================ + +/// Configuration for logging system initialization +/// +/// This struct represents the domain-specific logging configuration that is +/// independent of CLI parsing concerns. It can be constructed from CLI arguments +/// or other configuration sources without creating circular dependencies. +/// +/// # Design Principles +/// +/// - **Independence**: No dependency on presentation layer types +/// - **Reusability**: Can be constructed from various sources (CLI, config files, tests) +/// - **Clarity**: Clear field names and comprehensive documentation +#[derive(Debug, Clone)] +pub struct LoggingConfig { + /// Directory where log files will be written + pub log_dir: std::path::PathBuf, + + /// Format for file logging output + pub file_format: LogFormat, + + /// Format for stderr logging output + pub stderr_format: LogFormat, + + /// Output target (file-only vs file-and-stderr) + pub output: LogOutput, +} + +impl LoggingConfig { + /// Create a new logging configuration + /// + /// # Arguments + /// + /// * `log_dir` - Directory for log files + /// * `file_format` - Format for file output + /// * `stderr_format` - Format for stderr output + /// * `output` - Output target configuration + #[must_use] + pub fn new( + log_dir: std::path::PathBuf, + file_format: LogFormat, + stderr_format: LogFormat, + output: LogOutput, + ) -> Self { + Self { + log_dir, + file_format, + stderr_format, + output, + } + } +} + // ============================================================================ // BUILDER PATTERN - Core Implementation // ============================================================================ @@ -210,23 +265,28 @@ impl LoggingBuilder { /// /// Both panics are intentional as logging is critical for observability. pub fn init(self) { - init_subscriber( - &self.log_dir, + let config = LoggingConfig::new( + self.log_dir, + self.file_format, + self.stderr_format, self.output, - &self.file_format, - &self.stderr_format, ); + init_subscriber(config); } } // ============================================================================ -// INTERNAL INITIALIZATION - Single Source of Truth +// PUBLIC INITIALIZATION FUNCTIONS // ============================================================================ -/// Internal initialization function that handles all subscriber setup +/// Initialize logging with the provided configuration +/// +/// This function takes a `LoggingConfig` and sets up the global logging infrastructure. +/// After calling this, all logging macros (`tracing::info!`, etc.) will use +/// this configuration. /// /// This is the single source of truth for subscriber initialization. -/// All public init functions delegate to this to eliminate duplication. +/// All other init functions delegate to this to eliminate duplication. /// /// Automatically configures ANSI codes: /// - File output: ANSI codes disabled (clean text for parsing) @@ -237,20 +297,43 @@ impl LoggingBuilder { /// concrete type, and Rust's type system requires all match arms to return /// the same type. Type erasure with boxed layers would work but adds runtime /// overhead for a one-time initialization cost. +/// +/// # Arguments +/// +/// * `config` - The logging configuration containing all settings +/// +/// # Panics +/// +/// Panics if: +/// - Log directory cannot be created (filesystem permissions issue) +/// - Subscriber initialization fails (usually means it was already initialized) +/// +/// Both panics are intentional as logging is critical for observability. +/// +/// # Example +/// +/// ```rust,no_run +/// use std::path::PathBuf; +/// use torrust_tracker_deployer_lib::logging::{LogFormat, LogOutput, LoggingConfig, init_subscriber}; +/// +/// let config = LoggingConfig::new( +/// PathBuf::from("./data/logs"), +/// LogFormat::Compact, +/// LogFormat::Pretty, +/// LogOutput::FileAndStderr, +/// ); +/// +/// init_subscriber(config); +/// ``` #[allow(clippy::too_many_lines)] -fn init_subscriber( - log_dir: &Path, - output: LogOutput, - file_format: &LogFormat, - stderr_format: &LogFormat, -) { - let file_appender = create_log_file_appender(log_dir); +pub fn init_subscriber(config: LoggingConfig) { + let file_appender = create_log_file_appender(&config.log_dir); let env_filter = create_env_filter(); - match output { + match config.output { LogOutput::FileOnly => { // File-only mode: single layer with ANSI disabled - match file_format { + match config.file_format { LogFormat::Pretty => { tracing_subscriber::registry() .with( @@ -288,7 +371,7 @@ fn init_subscriber( } LogOutput::FileAndStderr => { // Dual output mode: file layer (no ANSI) + stderr layer (with ANSI) - match (file_format, stderr_format) { + match (config.file_format, config.stderr_format) { // Pretty file format combinations (LogFormat::Pretty, LogFormat::Pretty) => { tracing_subscriber::registry() diff --git a/src/presentation/cli/args.rs b/src/presentation/cli/args.rs new file mode 100644 index 00000000..c7043e52 --- /dev/null +++ b/src/presentation/cli/args.rs @@ -0,0 +1,97 @@ +//! CLI Argument Definitions +//! +//! This module contains the global CLI arguments that are shared across all commands, +//! primarily logging configuration options. These arguments follow clap conventions +//! and provide comprehensive documentation for users. + +use std::path::PathBuf; + +use crate::logging::{LogFormat, LogOutput, LoggingConfig}; + +/// Global CLI arguments for logging configuration +/// +/// These arguments are available for all commands and control how logging +/// is handled throughout the application. They provide fine-grained control +/// over log output, formatting, and destinations. +#[derive(clap::Args, Debug)] +pub struct GlobalArgs { + /// Format for file logging (default: compact, without ANSI codes) + /// + /// - pretty: Pretty-printed output for development (no ANSI in files) + /// - json: JSON output for production environments (no ANSI) + /// - compact: Compact output for minimal verbosity (no ANSI in files) + /// + /// Note: ANSI color codes are automatically disabled for file output + /// to ensure logs are easily parsed with standard text tools (grep, awk, sed). + #[arg(long, value_enum, default_value = "compact", global = true)] + pub log_file_format: LogFormat, + + /// Format for stderr logging (default: pretty, with ANSI codes) + /// + /// - pretty: Pretty-printed output with colors for development + /// - json: JSON output for machine processing + /// - compact: Compact output with colors for minimal verbosity + /// + /// Note: ANSI color codes are automatically enabled for stderr output + /// to provide colored terminal output for better readability. + #[arg(long, value_enum, default_value = "pretty", global = true)] + pub log_stderr_format: LogFormat, + + /// Log output mode (default: file-only for production) + /// + /// - file-only: Write logs to file only (production mode) + /// - file-and-stderr: Write logs to both file and stderr (development/testing mode) + #[arg(long, value_enum, default_value = "file-only", global = true)] + pub log_output: LogOutput, + + /// Log directory (default: ./data/logs) + /// + /// Directory where log files will be written. The log file will be + /// named 'log.txt' inside this directory. Parent directories will be + /// created automatically if they don't exist. + /// + /// Note: If the directory cannot be created due to filesystem permissions, + /// the application will exit with an error. Logging is critical for + /// observability and the application cannot function without it. + #[arg(long, default_value = "./data/logs", global = true)] + pub log_dir: PathBuf, +} + +impl GlobalArgs { + /// Create a logging configuration from these global arguments + /// + /// This method extracts the logging-specific configuration from CLI arguments + /// and creates a domain-appropriate `LoggingConfig` struct. This encapsulates + /// the conversion logic and avoids spreading logging configuration details + /// throughout the application bootstrap code. + /// + /// # Returns + /// + /// A `LoggingConfig` that can be used to initialize the logging system + /// + /// # Example + /// + /// ```rust + /// # use torrust_tracker_deployer_lib::presentation::cli::args::GlobalArgs; + /// # use torrust_tracker_deployer_lib::logging::{LogFormat, LogOutput, LoggingConfig}; + /// # use std::path::PathBuf; + /// // Create args with log configuration + /// let args = GlobalArgs { + /// log_file_format: LogFormat::Compact, + /// log_stderr_format: LogFormat::Pretty, + /// log_output: LogOutput::FileAndStderr, + /// log_dir: PathBuf::from("/tmp/logs"), + /// }; + /// let config = args.logging_config(); + /// // config will have specified log formats and directory + /// ``` + #[must_use] + pub fn logging_config(&self) -> LoggingConfig { + LoggingConfig::new( + self.log_dir.clone(), + self.log_file_format.clone(), + self.log_stderr_format.clone(), + self.log_output, + ) + } +} diff --git a/src/presentation/cli/commands.rs b/src/presentation/cli/commands.rs new file mode 100644 index 00000000..8a6eb5dc --- /dev/null +++ b/src/presentation/cli/commands.rs @@ -0,0 +1,51 @@ +//! CLI Command Definitions +//! +//! This module defines the command-line interface structure and available commands +//! for the Torrust Tracker Deployer CLI application. + +use clap::Subcommand; + +/// 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 { + /// Destroy an existing deployment environment + /// + /// This command will tear down all infrastructure associated with the + /// specified environment, including virtual machines, networks, and + /// persistent data. This operation is irreversible. + Destroy { + /// Name of the environment to destroy + /// + /// The environment name must be a valid identifier that was previously + /// created through the provision command. Use 'list' command to see + /// available environments. + environment: String, + }, + // Future commands will be added here: + // + // /// Provision a new deployment environment + // Provision { + // /// Name of the environment to create + // environment: String, + // /// Infrastructure provider to use (lxd, multipass, etc.) + // #[arg(long, default_value = "lxd")] + // provider: String, + // }, + // + // /// Configure an existing deployment environment + // Configure { + // /// Name of the environment to configure + // environment: String, + // }, + // + // /// Create a new release of the deployed application + // Release { + // /// Name of the environment for the release + // environment: String, + // /// Version tag for the release + // version: String, + // }, +} diff --git a/src/presentation/cli/mod.rs b/src/presentation/cli/mod.rs new file mode 100644 index 00000000..2e634f07 --- /dev/null +++ b/src/presentation/cli/mod.rs @@ -0,0 +1,166 @@ +//! CLI Module +//! +//! This module provides the command-line interface structure and functionality +//! for the Torrust Tracker Deployer application. It handles CLI argument parsing +//! and provides the CLI data structures. + +use clap::Parser; + +// Re-export submodules for convenient access +pub mod args; +pub mod commands; + +pub use args::GlobalArgs; +pub use commands::Commands; + +/// Command-line interface for Torrust Tracker Deployer +/// +/// This struct defines the top-level CLI structure including global arguments +/// and available subcommands. It uses clap for argument parsing and provides +/// comprehensive help documentation. +#[derive(Parser, Debug)] +#[command(name = "torrust-tracker-deployer")] +#[command(about = "Automated deployment infrastructure for Torrust Tracker")] +#[command(version)] +#[allow(clippy::struct_field_names)] // CLI arguments intentionally share 'log_' prefix for clarity +pub struct Cli { + /// Global arguments (logging configuration) + #[command(flatten)] + pub global: GlobalArgs, + + /// Subcommand to execute + #[command(subcommand)] + pub command: Option, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn it_should_parse_destroy_subcommand() { + let args = vec!["torrust-tracker-deployer", "destroy", "test-env"]; + let cli = Cli::try_parse_from(args).unwrap(); + + assert!(cli.command.is_some()); + match cli.command.unwrap() { + Commands::Destroy { environment } => { + assert_eq!(environment, "test-env"); + } + } + } + + #[test] + fn it_should_parse_destroy_with_different_environment_names() { + let test_cases = vec!["e2e-provision", "production", "test-123", "dev-environment"]; + + for env_name in test_cases { + let args = vec!["torrust-tracker-deployer", "destroy", env_name]; + let cli = Cli::try_parse_from(args).unwrap(); + + match cli.command.unwrap() { + Commands::Destroy { environment } => { + assert_eq!(environment, env_name); + } + } + } + } + + #[test] + fn it_should_require_environment_parameter() { + let args = vec!["torrust-tracker-deployer", "destroy"]; + 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("argument"), + "Error message should indicate missing required argument: {error_message}" + ); + } + + #[test] + fn it_should_parse_global_log_options_with_destroy_command() { + let args = vec![ + "torrust-tracker-deployer", + "--log-file-format", + "json", + "--log-stderr-format", + "compact", + "--log-output", + "file-and-stderr", + "--log-dir", + "/tmp/logs", + "destroy", + "test-env", + ]; + let cli = Cli::try_parse_from(args).unwrap(); + + // Verify the destroy command was parsed correctly + match cli.command.unwrap() { + Commands::Destroy { environment } => { + assert_eq!(environment, "test-env"); + } + } + + // Log options are set but we don't compare them as they don't implement PartialEq + assert_eq!(cli.global.log_dir, std::path::PathBuf::from("/tmp/logs")); + } + + #[test] + fn it_should_use_default_log_dir_when_not_specified() { + let args = vec!["torrust-tracker-deployer", "destroy", "test-env"]; + let cli = Cli::try_parse_from(args).unwrap(); + + assert_eq!(cli.global.log_dir, std::path::PathBuf::from("./data/logs")); + } + + #[test] + fn it_should_handle_no_command() { + let args = vec!["torrust-tracker-deployer"]; + let cli = Cli::try_parse_from(args).unwrap(); + + assert!(cli.command.is_none()); + } + + #[test] + fn it_should_show_help_with_help_flag() { + let args = vec!["torrust-tracker-deployer", "--help"]; + let result = Cli::try_parse_from(args); + + // Help flag causes a "display help" error + assert!(result.is_err()); + let error = result.unwrap_err(); + assert_eq!(error.kind(), clap::error::ErrorKind::DisplayHelp); + } + + #[test] + fn it_should_show_version_with_version_flag() { + let args = vec!["torrust-tracker-deployer", "--version"]; + let result = Cli::try_parse_from(args); + + // Version flag causes a "display version" error + assert!(result.is_err()); + let error = result.unwrap_err(); + assert_eq!(error.kind(), clap::error::ErrorKind::DisplayVersion); + } + + #[test] + fn it_should_show_destroy_help() { + let args = vec!["torrust-tracker-deployer", "destroy", "--help"]; + let result = Cli::try_parse_from(args); + + // Help flag causes a "display help" error + assert!(result.is_err()); + let error = result.unwrap_err(); + assert_eq!(error.kind(), clap::error::ErrorKind::DisplayHelp); + + // Verify the help text mentions the environment parameter + let help_text = error.to_string(); + assert!( + help_text.contains("environment") || help_text.contains(""), + "Help text should mention environment parameter" + ); + } +} diff --git a/src/presentation/commands/destroy.rs b/src/presentation/commands/destroy.rs new file mode 100644 index 00000000..55c705f8 --- /dev/null +++ b/src/presentation/commands/destroy.rs @@ -0,0 +1,284 @@ +//! Destroy Command Handler +//! +//! This module handles the destroy command execution, including environment validation, +//! repository access, and infrastructure destruction. It provides user-friendly +//! progress updates and comprehensive error handling. + +use std::time::Duration; + +use thiserror::Error; + +use crate::application::command_handlers::{ + destroy::DestroyCommandHandlerError, DestroyCommandHandler, +}; +use crate::domain::environment::name::{EnvironmentName, EnvironmentNameError}; +use crate::infrastructure::persistence::repository_factory::RepositoryFactory; +use crate::presentation::user_output::{UserOutput, VerbosityLevel}; + +/// Handle the destroy command +/// +/// This function orchestrates the environment destruction workflow by: +/// 1. Validating the environment name +/// 2. Loading the environment from persistent storage +/// 3. Executing the destroy command handler +/// 4. Providing user-friendly progress updates +/// +/// # Arguments +/// +/// * `environment_name` - The name of the environment to destroy +/// +/// # Returns +/// +/// Returns `Ok(())` on success, or a `DestroyError` if: +/// - Environment name is invalid +/// - Environment cannot be loaded +/// - Destruction fails +/// +/// # Errors +/// +/// This function will return an error if the environment name is invalid, +/// the environment cannot be loaded, or the destruction process fails. +/// All errors include detailed context and actionable troubleshooting guidance. +/// +/// # Example +/// +/// ```rust +/// use torrust_tracker_deployer_lib::presentation::commands::destroy; +/// +/// if let Err(e) = destroy::handle("test-env") { +/// eprintln!("Destroy failed: {e}"); +/// eprintln!("Help: {}", e.help()); +/// } +/// ``` +#[allow(clippy::result_large_err)] // Error contains detailed context for user guidance +pub fn handle(environment_name: &str) -> Result<(), DestroyError> { + // Create user output with default stdout/stderr channels + let mut output = UserOutput::new(VerbosityLevel::Normal); + + // Display initial progress (to stderr) + output.progress(&format!("Destroying environment '{environment_name}'...")); + + // Validate environment name + let env_name = EnvironmentName::new(environment_name.to_string()).map_err(|source| { + let error = DestroyError::InvalidEnvironmentName { + name: environment_name.to_string(), + source, + }; + output.error(&error.to_string()); + error + })?; + + // Create repository for loading environment state + let repository_factory = RepositoryFactory::new(Duration::from_secs(30)); + let repository = repository_factory.create(std::path::PathBuf::from("data")); + + // Create and execute destroy command handler + output.progress("Tearing down infrastructure..."); + + let command_handler = DestroyCommandHandler::new(repository); + + // Execute destroy - the handler will load the environment and handle all states internally + let _destroyed_env = command_handler.execute(&env_name).map_err(|source| { + let error = DestroyError::DestroyOperationFailed { + name: environment_name.to_string(), + source, + }; + output.error(&error.to_string()); + error + })?; + + output.progress("Cleaning up resources..."); + output.success(&format!( + "Environment '{environment_name}' destroyed successfully" + )); + + Ok(()) +} + +// ============================================================================ +// ERROR TYPES - Secondary Concerns +// ============================================================================ + +/// Destroy command specific errors +/// +/// This enum contains all error variants specific to the destroy command, +/// including environment validation, repository access, and destruction failures. +/// Each variant includes relevant context and actionable error messages. +#[derive(Debug, Error)] +pub enum DestroyError { + /// 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 or inaccessible from data directory '{data_dir}' +Tip: Check if environment exists: ls -la {data_dir}/" + )] + EnvironmentNotAccessible { name: String, data_dir: String }, + + /// Destroy operation failed + /// + /// The destruction process encountered an error during execution. + /// Use `.help()` for detailed troubleshooting steps. + #[error( + "Failed to destroy environment '{name}': {source} +Tip: Check logs and try running with --log-output file-and-stderr for more details" + )] + DestroyOperationFailed { + name: String, + #[source] + source: DestroyCommandHandlerError, + }, + + /// Repository operation failed + /// + /// Failed to create or access the environment repository. + /// Use `.help()` for detailed troubleshooting steps. + #[error( + "Failed to access environment repository at '{data_dir}': {reason} +Tip: Check directory permissions and disk space" + )] + RepositoryAccessFailed { data_dir: String, reason: String }, +} + +impl DestroyError { + /// 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 + /// + /// ```rust + /// use torrust_tracker_deployer_lib::presentation::commands::destroy; + /// + /// # fn main() -> Result<(), Box> { + /// if let Err(e) = destroy::handle("test-env") { + /// eprintln!("Error: {e}"); + /// eprintln!("\nTroubleshooting:\n{}", e.help()); + /// } + /// # Ok(()) + /// # } + /// ``` + #[must_use] + 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 letter (a-z, A-Z) or digit (0-9) + - Characters: Only letters, digits, and hyphens allowed + - End: Must not end with a hyphen + +2. Common valid examples: + - 'production' + - 'test-env' + - 'e2e-provision' + - 'dev123' + +3. Common invalid examples: + - 'test_env' (underscores not allowed) + - '-test' (starts with hyphen) + - 'test-' (ends with hyphen) + - '' (empty string) + +For more information, see the environment naming conventions in the documentation." + } + + Self::EnvironmentNotAccessible { .. } => { + "Environment Not Accessible - Detailed Troubleshooting: + +1. Check if environment exists: + - List environments: ls -la data/ + - Look for directory with your environment name + +2. Verify file permissions: + - Check directory permissions: ls -ld data/ + - Ensure read/write access: chmod 755 data/ + +3. Check if environment was provisioned: + - Look for environment.json file: ls -la data/{env_name}/ + - Verify it's a valid deployment environment + +4. Common causes: + - Environment was never created (run provision first) + - Wrong data directory path + - Permission issues + - Corrupted environment state + +If the environment should exist, check the logs for more details." + } + + Self::DestroyOperationFailed { .. } => { + "Destroy Operation Failed - Detailed Troubleshooting: + +1. Check system resources: + - Ensure sufficient disk space + - Check network connectivity + - Verify system permissions + +2. Review the operation logs: + - Run with verbose logging: --log-output file-and-stderr + - Check log files in data/logs/ + - Look for specific error details + +3. Check infrastructure state: + - Verify LXD/OpenTofu are accessible + - Check if VMs/containers are running + - Ensure cleanup tools are available + +4. Manual intervention may be needed: + - Some resources might need manual cleanup + - Check provider-specific tools (lxc list, tofu state list) + - Remove stale infrastructure manually if needed + +5. Recovery options: + - Retry the destroy operation + - Force cleanup with provider tools + - Contact administrator if permissions are needed + +For persistent issues, check the infrastructure documentation." + } + + Self::RepositoryAccessFailed { .. } => { + "Repository Access Failed - Detailed Troubleshooting: + +1. Check directory permissions: + - Verify data directory exists and is accessible + - Ensure write permissions: chmod 755 data/ + - Check parent directory permissions + +2. Verify disk space: + - Check available space: df -h . + - Ensure sufficient space for operations + - Clean up if disk is full + +3. Check file system issues: + - Test file creation: touch data/test.tmp && rm data/test.tmp + - Look for file system errors in system logs + - Check if directory is on a read-only mount + +4. Common solutions: + - Create data directory: mkdir -p data + - Fix permissions: sudo chown -R $USER:$USER data/ + - Move to directory with sufficient space + +If the problem persists, check system logs and contact administrator." + } + } + } +} diff --git a/src/presentation/commands/mod.rs b/src/presentation/commands/mod.rs new file mode 100644 index 00000000..74fdad26 --- /dev/null +++ b/src/presentation/commands/mod.rs @@ -0,0 +1,115 @@ +//! Command Handlers Module +//! +//! This module provides unified command execution and error handling for all CLI commands. +//! It serves as the central dispatch point for command execution and provides consistent +//! error handling across all commands. + +use crate::presentation::cli::Commands; +use crate::presentation::errors::CommandError; + +// Re-export command modules +pub mod destroy; + +// Future command modules will be added here: +// pub mod provision; +// pub mod configure; +// pub mod release; + +/// Execute the given command +/// +/// This function serves as the central dispatcher for all CLI commands. +/// It matches the command type and delegates execution to the appropriate +/// command handler module. +/// +/// # Arguments +/// +/// * `command` - The parsed CLI command to execute +/// +/// # Returns +/// +/// Returns `Ok(())` on successful execution, or a `CommandError` if execution fails. +/// The error contains detailed context and actionable troubleshooting information. +/// +/// # Errors +/// +/// Returns an error if command execution fails. +/// +/// # Example +/// +/// ```rust +/// use clap::Parser; +/// use torrust_tracker_deployer_lib::presentation::{cli, commands}; +/// +/// let cli = cli::Cli::parse(); +/// if let Some(command) = cli.command { +/// let result = commands::execute(command); +/// match result { +/// Ok(_) => println!("Command executed successfully"), +/// Err(e) => commands::handle_error(&e), +/// } +/// } +/// ``` +pub fn execute(command: Commands) -> Result<(), CommandError> { + match command { + Commands::Destroy { environment } => { + destroy::handle(&environment)?; + Ok(()) + } // Future commands will be added here: + // + // Commands::Provision { environment, provider } => { + // provision::handle(&environment, &provider)?; + // Ok(()) + // } + // + // Commands::Configure { environment } => { + // configure::handle(&environment)?; + // Ok(()) + // } + // + // Commands::Release { environment, version } => { + // release::handle(&environment, &version)?; + // Ok(()) + // } + } +} + +/// Handle command errors with consistent user output +/// +/// This function provides standardized error output for all command failures. +/// It displays the error message and detailed troubleshooting information +/// to help users resolve issues. +/// +/// # Arguments +/// +/// * `error` - The command error to handle and display +/// +/// # Example +/// +/// ```rust +/// use clap::Parser; +/// use torrust_tracker_deployer_lib::presentation::{commands, cli, errors}; +/// use torrust_tracker_deployer_lib::presentation::commands::destroy::DestroyError; +/// use torrust_tracker_deployer_lib::domain::environment::name::EnvironmentNameError; +/// +/// # fn main() -> Result<(), Box> { +/// // Example of handling a command error (simulated for testing) +/// let name_error = EnvironmentNameError::InvalidFormat { +/// attempted_name: "invalid_name".to_string(), +/// reason: "contains invalid characters: _".to_string(), +/// valid_examples: vec!["dev".to_string(), "staging".to_string()], +/// }; +/// let sample_error = errors::CommandError::Destroy( +/// Box::new(DestroyError::InvalidEnvironmentName { +/// name: "invalid_name".to_string(), +/// source: name_error, +/// }) +/// ); +/// commands::handle_error(&sample_error); +/// # Ok(()) +/// # } +/// ``` +pub fn handle_error(error: &CommandError) { + eprintln!("Error: {error}"); + eprintln!("\nFor detailed troubleshooting:"); + eprintln!("{}", error.help()); +} diff --git a/src/presentation/errors.rs b/src/presentation/errors.rs new file mode 100644 index 00000000..e66d747e --- /dev/null +++ b/src/presentation/errors.rs @@ -0,0 +1,84 @@ +//! Presentation Layer Error Types +//! +//! This module defines unified error handling for CLI commands following the error +//! handling conventions documented in docs/contributing/error-handling.md. +//! +//! ## Design Principles +//! +//! - **Clarity**: Unambiguous error messages with specific context +//! - **Traceability**: Full error chains preserved for debugging +//! - **Actionability**: Clear instructions for resolution +//! - **Unified Structure**: Single `CommandError` enum containing all command-specific errors +//! +//! ## Error Hierarchy +//! +//! ```text +//! CommandError +//! └── Destroy(DestroyError) # Destroy command errors +//! ``` + +use thiserror::Error; + +use crate::presentation::commands::destroy::DestroyError; + +/// Errors that can occur during CLI command execution +/// +/// This enum provides a unified interface for all command-specific errors, +/// following the project's error handling conventions with structured error +/// types, source preservation, and tiered help system support. +#[derive(Debug, Error)] +pub enum CommandError { + /// Destroy command specific errors + /// + /// Encapsulates all errors that can occur during environment destruction. + /// Use `.help()` for detailed troubleshooting steps. + #[error("Destroy command failed: {0}")] + Destroy(Box), +} + +impl From for CommandError { + fn from(error: DestroyError) -> Self { + Self::Destroy(Box::new(error)) + } +} + +impl CommandError { + /// 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. + /// It delegates to the specific command error's help method. + /// + /// # Example + /// + /// ```rust + /// use clap::Parser; + /// use torrust_tracker_deployer_lib::presentation::{cli, errors}; + /// use torrust_tracker_deployer_lib::presentation::commands::destroy::DestroyError; + /// use torrust_tracker_deployer_lib::application::command_handlers::destroy::DestroyCommandHandlerError; + /// use std::path::PathBuf; + /// + /// # fn main() -> Result<(), Box> { + /// // Create error for demonstration + /// let destroy_error = DestroyError::DestroyOperationFailed { + /// name: "test-env".to_string(), + /// source: DestroyCommandHandlerError::StateCleanupFailed { + /// path: PathBuf::from("/tmp/test"), + /// source: std::io::Error::new(std::io::ErrorKind::PermissionDenied, "access denied"), + /// }, + /// }; + /// let error = errors::CommandError::Destroy(Box::new(destroy_error)); + /// + /// // Get help text + /// let help_text = error.help(); + /// println!("{}", help_text); + /// # Ok(()) + /// # } + /// ``` + #[must_use] + pub fn help(&self) -> &'static str { + match self { + Self::Destroy(e) => e.help(), + } + } +} diff --git a/src/presentation/mod.rs b/src/presentation/mod.rs new file mode 100644 index 00000000..c943760e --- /dev/null +++ b/src/presentation/mod.rs @@ -0,0 +1,51 @@ +//! Presentation Layer +//! +//! This layer handles user-facing output and presentation concerns following DDD architecture. +//! It manages how information is presented to users, separate from internal logging and +//! application logic. +//! +//! ## Responsibilities +//! +//! - **User Output**: Managing user-facing messages, progress updates, and result presentation +//! - **CLI Interface**: Command-line argument parsing and subcommand definitions +//! - **Command Execution**: Coordinating command handlers and providing unified error handling +//! - **Output Channels**: Implementing proper stdout/stderr separation for CLI applications +//! - **Verbosity Control**: Handling different levels of output detail based on user preferences +//! - **Output Formatting**: Structuring output for both human consumption and automation/piping +//! +//! ## Design Principles +//! +//! - **Channel Separation**: Following Unix conventions with stdout for results and stderr for operational messages +//! - **Automation Friendly**: Supporting clean piping and redirection for scripting +//! - **User Experience**: Providing clear, actionable feedback without interfering with result data +//! - **Verbosity Levels**: Respecting user preferences for output detail +//! - **Error Conventions**: Following project error handling guidelines with structured errors and tiered help +//! +//! ## Module Structure +//! +//! ```text +//! presentation/ +//! ├── cli/ # CLI argument parsing and structure +//! │ ├── args.rs # Global CLI arguments (logging config) +//! │ ├── commands.rs # Subcommand definitions +//! │ └── mod.rs # Main Cli struct and parsing logic +//! ├── commands/ # Command execution handlers +//! │ ├── destroy.rs # Destroy command handler +//! │ └── mod.rs # Unified command dispatch and error handling +//! ├── errors.rs # Unified error types for all commands +//! ├── user_output.rs # User-facing output management +//! └── mod.rs # This file - layer exports and documentation +//! ``` + +// Core presentation modules +pub mod cli; +pub mod commands; +pub mod errors; +pub mod user_output; + +// Re-export commonly used presentation types for convenience +pub use cli::{Cli, Commands, GlobalArgs}; +pub use commands::destroy::DestroyError; +pub use commands::{execute, handle_error}; +pub use errors::CommandError; +pub use user_output::{UserOutput, VerbosityLevel}; diff --git a/src/presentation/user_output.rs b/src/presentation/user_output.rs new file mode 100644 index 00000000..0a055524 --- /dev/null +++ b/src/presentation/user_output.rs @@ -0,0 +1,475 @@ +//! User-facing output handling +//! +//! This module provides user-facing output functionality separate from internal logging. +//! It implements a dual-channel strategy following Unix conventions and modern CLI best practices +//! (similar to cargo, docker, npm): +//! +//! - **stdout (Results Channel)**: Final results, structured data, output for piping/redirection +//! - **stderr (Progress/Operational Channel)**: Progress updates, status messages, warnings, errors +//! +//! This separation enables: +//! - Clean piping: `torrust-tracker-deployer destroy env | jq .status` works correctly +//! - Automation friendly: Scripts can redirect progress to /dev/null while capturing results +//! - Unix convention compliance: Follows established patterns from modern CLI tools +//! - Better UX: Progress feedback doesn't interfere with result data +//! +//! ## Example Usage +//! +//! ```rust +//! use torrust_tracker_deployer_lib::presentation::user_output::{UserOutput, VerbosityLevel}; +//! +//! let mut output = UserOutput::new(VerbosityLevel::Normal); +//! +//! // Progress messages go to stderr +//! output.progress("Destroying environment..."); +//! +//! // Success status goes to stderr +//! output.success("Environment destroyed successfully"); +//! +//! // Results go to stdout for piping +//! output.result(r#"{"status": "destroyed"}"#); +//! ``` +//! +//! ## Channel Strategy +//! +//! Based on research from [`docs/research/UX/console-app-output-patterns.md`](../../docs/research/UX/console-app-output-patterns.md): +//! +//! - **stdout**: Deployment results, configuration summaries, structured data (JSON) +//! - **stderr**: Step progress, status updates, warnings, error messages with actionable guidance +//! +//! See also: [`docs/research/UX/user-output-vs-logging-separation.md`](../../docs/research/UX/user-output-vs-logging-separation.md) + +use std::io::Write; + +/// Verbosity levels for user output +/// +/// Controls the amount of detail shown to users. Higher verbosity levels include +/// all output from lower levels. +/// +/// # Examples +/// +/// ```rust +/// use torrust_tracker_deployer_lib::presentation::user_output::VerbosityLevel; +/// +/// let level = VerbosityLevel::Normal; +/// assert!(level >= VerbosityLevel::Quiet); +/// assert!(level < VerbosityLevel::Verbose); +/// ``` +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Default)] +pub enum VerbosityLevel { + /// Minimal output - only errors and final results + Quiet, + /// Default level - essential progress and results + #[default] + Normal, + /// Detailed progress including intermediate steps + Verbose, + /// Very detailed including decisions and retries + VeryVerbose, + /// Maximum detail for troubleshooting + Debug, +} + +/// Handles user-facing output separate from internal logging +/// +/// Uses dual channels following Unix conventions and modern CLI best practices: +/// - **stdout**: Final results and data for piping/redirection +/// - **stderr**: Progress updates, status messages, operational info, errors +/// +/// This separation allows scripts to cleanly capture results while seeing progress: +/// +/// ```bash +/// # Suppress progress, capture results only +/// torrust-tracker-deployer destroy env 2>/dev/null > result.json +/// +/// # Suppress results, see progress only +/// torrust-tracker-deployer destroy env > /dev/null +/// ``` +/// +/// # Examples +/// +/// ```rust +/// use torrust_tracker_deployer_lib::presentation::user_output::{UserOutput, VerbosityLevel}; +/// +/// let mut output = UserOutput::new(VerbosityLevel::Normal); +/// +/// // Progress to stderr (visible during execution, doesn't interfere with piping) +/// output.progress("Processing data..."); +/// +/// // Results to stdout (can be piped to other commands) +/// output.result("Processing complete"); +/// ``` +pub struct UserOutput { + verbosity: VerbosityLevel, + stdout_writer: Box, + stderr_writer: Box, +} + +impl UserOutput { + /// Create new `UserOutput` with default stdout/stderr channels + /// + /// # Examples + /// + /// ```rust + /// use torrust_tracker_deployer_lib::presentation::user_output::{UserOutput, VerbosityLevel}; + /// + /// let output = UserOutput::new(VerbosityLevel::Normal); + /// ``` + #[must_use] + pub fn new(verbosity: VerbosityLevel) -> Self { + Self { + verbosity, + stdout_writer: Box::new(std::io::stdout()), + stderr_writer: Box::new(std::io::stderr()), + } + } + + /// Create `UserOutput` for testing with custom writers + /// + /// This constructor allows injecting custom writers for testing, + /// enabling output capture and assertion. + /// + /// # Examples + /// + /// ```rust + /// use torrust_tracker_deployer_lib::presentation::user_output::{UserOutput, VerbosityLevel}; + /// use std::io::Cursor; + /// + /// let stdout_buf = Vec::new(); + /// let stderr_buf = Vec::new(); + /// + /// let output = UserOutput::with_writers( + /// VerbosityLevel::Normal, + /// Box::new(Cursor::new(stdout_buf)), + /// Box::new(Cursor::new(stderr_buf)), + /// ); + /// ``` + #[must_use] + pub fn with_writers( + verbosity: VerbosityLevel, + stdout_writer: Box, + stderr_writer: Box, + ) -> Self { + Self { + verbosity, + stdout_writer, + stderr_writer, + } + } + + /// Display progress message to stderr (Normal level and above) + /// + /// Progress messages go to stderr following cargo/docker patterns. + /// This keeps stdout clean for result data that may be piped. + /// + /// # Examples + /// + /// ```rust + /// use torrust_tracker_deployer_lib::presentation::user_output::{UserOutput, VerbosityLevel}; + /// + /// let mut output = UserOutput::new(VerbosityLevel::Normal); + /// output.progress("Destroying environment..."); + /// // Output to stderr: ⏳ Destroying environment... + /// ``` + pub fn progress(&mut self, message: &str) { + if self.verbosity >= VerbosityLevel::Normal { + writeln!(self.stderr_writer, "⏳ {message}").ok(); + } + } + + /// Display success message to stderr (Normal level and above) + /// + /// Success status goes to stderr to allow clean result piping. + /// + /// # Examples + /// + /// ```rust + /// use torrust_tracker_deployer_lib::presentation::user_output::{UserOutput, VerbosityLevel}; + /// + /// let mut output = UserOutput::new(VerbosityLevel::Normal); + /// output.success("Environment destroyed successfully"); + /// // Output to stderr: ✅ Environment destroyed successfully + /// ``` + pub fn success(&mut self, message: &str) { + if self.verbosity >= VerbosityLevel::Normal { + writeln!(self.stderr_writer, "✅ {message}").ok(); + } + } + + /// Display warning message to stderr (Normal level and above) + /// + /// # Examples + /// + /// ```rust + /// use torrust_tracker_deployer_lib::presentation::user_output::{UserOutput, VerbosityLevel}; + /// + /// let mut output = UserOutput::new(VerbosityLevel::Normal); + /// output.warn("Infrastructure may already be destroyed"); + /// // Output to stderr: ⚠️ Infrastructure may already be destroyed + /// ``` + pub fn warn(&mut self, message: &str) { + if self.verbosity >= VerbosityLevel::Normal { + writeln!(self.stderr_writer, "⚠️ {message}").ok(); + } + } + + /// Display error message to stderr (all levels) + /// + /// Errors are always shown regardless of verbosity level. + /// + /// # Examples + /// + /// ```rust + /// use torrust_tracker_deployer_lib::presentation::user_output::{UserOutput, VerbosityLevel}; + /// + /// let mut output = UserOutput::new(VerbosityLevel::Quiet); + /// output.error("Failed to destroy environment"); + /// // Output to stderr: ❌ Failed to destroy environment + /// ``` + pub fn error(&mut self, message: &str) { + writeln!(self.stderr_writer, "❌ {message}").ok(); + } + + /// Output final results to stdout for piping/redirection + /// + /// This is where deployment results, configuration summaries, etc. go. + /// Since this goes to stdout, it can be cleanly piped to other commands. + /// + /// # Examples + /// + /// ```rust + /// use torrust_tracker_deployer_lib::presentation::user_output::{UserOutput, VerbosityLevel}; + /// + /// let mut output = UserOutput::new(VerbosityLevel::Normal); + /// output.result("Deployment complete"); + /// // Output to stdout: Deployment complete + /// ``` + pub fn result(&mut self, message: &str) { + writeln!(self.stdout_writer, "{message}").ok(); + } + + /// Output structured data to stdout (JSON, etc.) + /// + /// For machine-readable output that should be piped or processed. + /// + /// # Examples + /// + /// ```rust + /// use torrust_tracker_deployer_lib::presentation::user_output::{UserOutput, VerbosityLevel}; + /// + /// let mut output = UserOutput::new(VerbosityLevel::Normal); + /// output.data(r#"{"status": "destroyed", "environment": "test"}"#); + /// // Output to stdout: {"status": "destroyed", "environment": "test"} + /// ``` + pub fn data(&mut self, data: &str) { + writeln!(self.stdout_writer, "{data}").ok(); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Helper to create test `UserOutput` with buffer writers + /// + /// Returns: (`UserOutput`, Arc to stdout buffer, Arc to stderr buffer) + #[allow(clippy::type_complexity)] + fn create_test_user_output( + verbosity: VerbosityLevel, + ) -> ( + UserOutput, + std::sync::Arc>>, + std::sync::Arc>>, + ) { + use std::sync::{Arc, Mutex}; + + let stdout_buffer = Arc::new(Mutex::new(Vec::new())); + let stderr_buffer = Arc::new(Mutex::new(Vec::new())); + + let stdout_clone = Arc::clone(&stdout_buffer); + let stderr_clone = Arc::clone(&stderr_buffer); + + // Create thread-safe writers that share the buffer + let stdout_writer = Box::new(SharedWriter(Arc::clone(&stdout_buffer))); + let stderr_writer = Box::new(SharedWriter(Arc::clone(&stderr_buffer))); + + let output = UserOutput::with_writers(verbosity, stdout_writer, stderr_writer); + + (output, stdout_clone, stderr_clone) + } + + /// A writer that shares a buffer through an Arc>> + struct SharedWriter(std::sync::Arc>>); + + impl Write for SharedWriter { + fn write(&mut self, buf: &[u8]) -> std::io::Result { + self.0.lock().unwrap().write(buf) + } + + fn flush(&mut self) -> std::io::Result<()> { + self.0.lock().unwrap().flush() + } + } + + #[test] + fn it_should_write_progress_messages_to_stderr() { + let (mut output, stdout_buf, stderr_buf) = create_test_user_output(VerbosityLevel::Normal); + + output.progress("Testing progress message"); + + // Verify message went to stderr + let stderr_content = String::from_utf8(stderr_buf.lock().unwrap().clone()).unwrap(); + assert_eq!(stderr_content, "⏳ Testing progress message\n"); + + // Verify stdout is empty + let stdout_content = String::from_utf8(stdout_buf.lock().unwrap().clone()).unwrap(); + assert_eq!(stdout_content, ""); + } + + #[test] + fn it_should_write_success_messages_to_stderr() { + let (mut output, stdout_buf, stderr_buf) = create_test_user_output(VerbosityLevel::Normal); + + output.success("Testing success message"); + + // Verify message went to stderr + let stderr_content = String::from_utf8(stderr_buf.lock().unwrap().clone()).unwrap(); + assert_eq!(stderr_content, "✅ Testing success message\n"); + + // Verify stdout is empty + let stdout_content = String::from_utf8(stdout_buf.lock().unwrap().clone()).unwrap(); + assert_eq!(stdout_content, ""); + } + + #[test] + fn it_should_write_warning_messages_to_stderr() { + let (mut output, stdout_buf, stderr_buf) = create_test_user_output(VerbosityLevel::Normal); + + output.warn("Testing warning message"); + + // Verify message went to stderr + let stderr_content = String::from_utf8(stderr_buf.lock().unwrap().clone()).unwrap(); + assert_eq!(stderr_content, "⚠️ Testing warning message\n"); + + // Verify stdout is empty + let stdout_content = String::from_utf8(stdout_buf.lock().unwrap().clone()).unwrap(); + assert_eq!(stdout_content, ""); + } + + #[test] + fn it_should_write_error_messages_to_stderr() { + let (mut output, stdout_buf, stderr_buf) = create_test_user_output(VerbosityLevel::Normal); + + output.error("Testing error message"); + + // Verify message went to stderr + let stderr_content = String::from_utf8(stderr_buf.lock().unwrap().clone()).unwrap(); + assert_eq!(stderr_content, "❌ Testing error message\n"); + + // Verify stdout is empty + let stdout_content = String::from_utf8(stdout_buf.lock().unwrap().clone()).unwrap(); + assert_eq!(stdout_content, ""); + } + + #[test] + fn it_should_write_results_to_stdout() { + let (mut output, stdout_buf, stderr_buf) = create_test_user_output(VerbosityLevel::Normal); + + output.result("Test result data"); + + // Verify message went to stdout + let stdout_content = String::from_utf8(stdout_buf.lock().unwrap().clone()).unwrap(); + assert_eq!(stdout_content, "Test result data\n"); + + // Verify stderr is empty + let stderr_content = String::from_utf8(stderr_buf.lock().unwrap().clone()).unwrap(); + assert_eq!(stderr_content, ""); + } + + #[test] + fn it_should_write_data_to_stdout() { + let (mut output, stdout_buf, stderr_buf) = create_test_user_output(VerbosityLevel::Normal); + + output.data(r#"{"status": "destroyed"}"#); + + // Verify message went to stdout + let stdout_content = String::from_utf8(stdout_buf.lock().unwrap().clone()).unwrap(); + assert_eq!(stdout_content, "{\"status\": \"destroyed\"}\n"); + + // Verify stderr is empty + let stderr_content = String::from_utf8(stderr_buf.lock().unwrap().clone()).unwrap(); + assert_eq!(stderr_content, ""); + } + + #[test] + fn it_should_respect_verbosity_levels_for_progress() { + let (mut output, _stdout_buf, stderr_buf) = create_test_user_output(VerbosityLevel::Quiet); + + output.progress("This should not appear"); + + // Verify no output at Quiet level + let stderr_content = String::from_utf8(stderr_buf.lock().unwrap().clone()).unwrap(); + assert_eq!(stderr_content, ""); + } + + #[test] + fn it_should_respect_verbosity_levels_for_success() { + let (mut output, _stdout_buf, stderr_buf) = create_test_user_output(VerbosityLevel::Quiet); + + output.success("This should not appear"); + + // Verify no output at Quiet level + let stderr_content = String::from_utf8(stderr_buf.lock().unwrap().clone()).unwrap(); + assert_eq!(stderr_content, ""); + } + + #[test] + fn it_should_respect_verbosity_levels_for_warn() { + let (mut output, _stdout_buf, stderr_buf) = create_test_user_output(VerbosityLevel::Quiet); + + output.warn("This should not appear"); + + // Verify no output at Quiet level + let stderr_content = String::from_utf8(stderr_buf.lock().unwrap().clone()).unwrap(); + assert_eq!(stderr_content, ""); + } + + #[test] + fn it_should_always_show_errors_regardless_of_verbosity() { + let (mut output, _stdout_buf, stderr_buf) = create_test_user_output(VerbosityLevel::Quiet); + + output.error("Critical error message"); + + // Verify error appears even at Quiet level + let stderr_content = String::from_utf8(stderr_buf.lock().unwrap().clone()).unwrap(); + assert_eq!(stderr_content, "❌ Critical error message\n"); + } + + #[test] + fn it_should_use_normal_as_default_verbosity() { + let default = VerbosityLevel::default(); + assert_eq!(default, VerbosityLevel::Normal); + } + + #[test] + fn it_should_order_verbosity_levels_correctly() { + assert!(VerbosityLevel::Quiet < VerbosityLevel::Normal); + assert!(VerbosityLevel::Normal < VerbosityLevel::Verbose); + assert!(VerbosityLevel::Verbose < VerbosityLevel::VeryVerbose); + assert!(VerbosityLevel::VeryVerbose < VerbosityLevel::Debug); + } + + #[test] + fn it_should_support_equality_comparison() { + assert_eq!(VerbosityLevel::Normal, VerbosityLevel::Normal); + assert_ne!(VerbosityLevel::Normal, VerbosityLevel::Verbose); + } + + #[test] + fn it_should_support_ordering_comparison() { + let normal = VerbosityLevel::Normal; + assert!(normal >= VerbosityLevel::Quiet); + assert!(normal >= VerbosityLevel::Normal); + assert!(normal < VerbosityLevel::Verbose); + } +} diff --git a/src/testing/e2e/tasks/virtual_machine/run_destroy_command.rs b/src/testing/e2e/tasks/virtual_machine/run_destroy_command.rs index e6cd5ed8..c4db65a3 100644 --- a/src/testing/e2e/tasks/virtual_machine/run_destroy_command.rs +++ b/src/testing/e2e/tasks/virtual_machine/run_destroy_command.rs @@ -16,7 +16,6 @@ //! This task is typically the final step in E2E testing workflows, cleaning up //! all provisioned infrastructure after tests complete. -use std::sync::Arc; use thiserror::Error; use tracing::info; @@ -105,8 +104,6 @@ For more information, see docs/e2e-testing.md and docs/vm-providers.md." /// - Infrastructure destruction fails /// - `OpenTofu` destroy operations fail pub fn run_destroy_command(test_context: &mut TestContext) -> Result<(), DestroyTaskError> { - use crate::domain::environment::state::AnyEnvironmentState; - // If keep_env is set, skip destruction and preserve the environment if test_context.keep_env { let instance_name = &test_context.environment.instance_name(); @@ -126,36 +123,14 @@ pub fn run_destroy_command(test_context: &mut TestContext) -> Result<(), Destroy let repository = test_context.create_repository(); // Use the new DestroyCommandHandler to handle all infrastructure destruction steps - let destroy_command_handler = DestroyCommandHandler::new( - Arc::clone(&test_context.services.opentofu_client), - repository, - ); - - // Execute destruction with environment (can be in any state) - // The DestroyCommandHandler accepts Environment generically, so we need to extract - // the environment from AnyEnvironmentState. Since destroy works on any state, - // we handle the special case of already-destroyed environments. - - let destroyed_env = match test_context.environment.clone() { - AnyEnvironmentState::Destroyed(env) => { - // Already destroyed, just return it - info!("Environment is already in Destroyed state"); - Ok(env) - } - AnyEnvironmentState::Created(env) => destroy_command_handler.execute(env), - AnyEnvironmentState::Provisioning(env) => destroy_command_handler.execute(env), - AnyEnvironmentState::Provisioned(env) => destroy_command_handler.execute(env), - AnyEnvironmentState::Configuring(env) => destroy_command_handler.execute(env), - AnyEnvironmentState::Configured(env) => destroy_command_handler.execute(env), - AnyEnvironmentState::Releasing(env) => destroy_command_handler.execute(env), - AnyEnvironmentState::Released(env) => destroy_command_handler.execute(env), - AnyEnvironmentState::Running(env) => destroy_command_handler.execute(env), - AnyEnvironmentState::ProvisionFailed(env) => destroy_command_handler.execute(env), - AnyEnvironmentState::ConfigureFailed(env) => destroy_command_handler.execute(env), - AnyEnvironmentState::ReleaseFailed(env) => destroy_command_handler.execute(env), - AnyEnvironmentState::RunFailed(env) => destroy_command_handler.execute(env), - } - .map_err(|source| DestroyTaskError::DestructionFailed { source })?; + let destroy_command_handler = DestroyCommandHandler::new(repository); + + // Execute destruction using environment name + // The DestroyCommandHandler now loads the environment internally and handles all states + let env_name = test_context.environment.name(); + let destroyed_env = destroy_command_handler + .execute(env_name) + .map_err(|source| DestroyTaskError::DestructionFailed { source })?; info!( status = "complete",