diff --git a/src/application/command_handlers/release/errors.rs b/src/application/command_handlers/release/errors.rs index c39719ff..14381898 100644 --- a/src/application/command_handlers/release/errors.rs +++ b/src/application/command_handlers/release/errors.rs @@ -1,9 +1,35 @@ //! Error types for the Release command handler +//! +//! # Design Decision: Boxed Error Sources +//! +//! All step-related error variants use `Box` +//! as the source type rather than concrete step error types. This design choice +//! was made because: +//! +//! 1. **Many heterogeneous step types**: The release workflow involves 15+ steps, +//! each with its own error type (`CommandError`, `TrackerProjectGeneratorError`, +//! `DeployComposeFilesStepError`, etc.). Using concrete types would require +//! either a massive wrapper enum or many separate error variants. +//! +//! 2. **Extensibility**: New steps can be added without modifying the error enum. +//! +//! 3. **Uniform error handling**: All step errors can be handled uniformly with +//! the `with_step` helper pattern. +//! +//! **Trade-off**: We lose the ability to pattern match on the concrete source type +//! and `Traceable::trace_source()` returns `None` for boxed errors. However, the +//! error message is preserved via `to_string()`, and the trace file captures full +//! context for debugging. +//! +//! **Preferred pattern**: In cases where there are fewer, well-defined error sources, +//! prefer using concrete types with `#[source]` for better type safety and traceability. -use crate::application::steps::application::DeployComposeFilesStepError; use crate::domain::environment::state::StateTypeError; use crate::shared::error::{ErrorKind, Traceable}; +/// Type alias for boxed step errors to reduce verbosity +pub type BoxedStepError = Box; + /// Comprehensive error type for the `ReleaseCommandHandler` /// /// This error type captures all possible failures that can occur during @@ -37,51 +63,113 @@ pub enum ReleaseCommandHandlerError { StatePersistence(#[from] crate::domain::environment::repository::RepositoryError), /// Template rendering failed - #[error("Template rendering failed: {0}")] - TemplateRendering(String), + #[error("Template rendering failed: {message}")] + TemplateRendering { + /// Description of the rendering failure + message: String, + /// The underlying error from the template step + #[source] + source: BoxedStepError, + }, /// Tracker storage directory creation failed - #[error("Tracker storage creation failed: {0}")] - TrackerStorageCreation(String), + #[error("Tracker storage creation failed: {message}")] + TrackerStorageCreation { + /// Description of the failure + message: String, + /// The underlying error from the storage creation step + #[source] + source: BoxedStepError, + }, /// Tracker database initialization failed - #[error("Tracker database initialization failed: {0}")] - TrackerDatabaseInit(String), + #[error("Tracker database initialization failed: {message}")] + TrackerDatabaseInit { + /// Description of the failure + message: String, + /// The underlying error from the database init step + #[source] + source: BoxedStepError, + }, /// Prometheus storage directory creation failed - #[error("Prometheus storage creation failed: {0}")] - PrometheusStorageCreation(String), + #[error("Prometheus storage creation failed: {message}")] + PrometheusStorageCreation { + /// Description of the failure + message: String, + /// The underlying error from the storage creation step + #[source] + source: BoxedStepError, + }, /// Grafana storage directory creation failed - #[error("Grafana storage creation failed: {0}")] - GrafanaStorageCreation(String), + #[error("Grafana storage creation failed: {message}")] + GrafanaStorageCreation { + /// Description of the failure + message: String, + /// The underlying error from the storage creation step + #[source] + source: BoxedStepError, + }, /// `MySQL` storage directory creation failed - #[error("MySQL storage creation failed: {0}")] - MysqlStorageCreation(String), + #[error("MySQL storage creation failed: {message}")] + MysqlStorageCreation { + /// Description of the failure + message: String, + /// The underlying error from the storage creation step + #[source] + source: BoxedStepError, + }, /// Caddy configuration deployment failed - #[error("Caddy configuration deployment failed: {0}")] - CaddyConfigDeployment(String), + #[error("Caddy configuration deployment failed: {message}")] + CaddyConfigDeployment { + /// Description of the failure + message: String, + /// The underlying error from the deployment step + #[source] + source: BoxedStepError, + }, + + /// Tracker configuration deployment failed + #[error("Tracker configuration deployment failed: {message}")] + TrackerConfigDeployment { + /// Description of the failure + message: String, + /// The underlying error from the deployment step + #[source] + source: BoxedStepError, + }, - /// General deployment operation failed - #[error("Deployment failed: {message}")] - Deployment { - /// The error message + /// Grafana provisioning deployment failed + #[error("Grafana provisioning deployment failed: {message}")] + GrafanaProvisioningDeployment { + /// Description of the failure message: String, - /// The underlying error source + /// The underlying error from the deployment step #[source] - source: Box, + source: BoxedStepError, }, - /// Deployment to remote host failed - #[error("Deployment to remote host failed: {message}")] - DeploymentFailed { + /// Prometheus configuration deployment failed + #[error("Prometheus configuration deployment failed: {message}")] + PrometheusConfigDeployment { /// Description of the failure message: String, - /// The underlying deployment step error + /// The underlying error from the deployment step #[source] - source: DeployComposeFilesStepError, + source: BoxedStepError, + }, + + /// Docker Compose files deployment failed + #[error("Docker Compose deployment failed: {message}")] + ComposeFilesDeployment { + /// Description of the failure + message: String, + /// The underlying error from the deployment step + #[source] + source: BoxedStepError, }, /// Release operation failed @@ -109,33 +197,48 @@ impl Traceable for ReleaseCommandHandlerError { Self::StatePersistence(e) => { format!("ReleaseCommandHandlerError: Failed to persist environment state - {e}") } - Self::TemplateRendering(message) => { + Self::TemplateRendering { message, .. } => { format!("ReleaseCommandHandlerError: Template rendering failed - {message}") } - Self::TrackerStorageCreation(message) => { + Self::TrackerStorageCreation { message, .. } => { format!("ReleaseCommandHandlerError: Tracker storage creation failed - {message}") } - Self::TrackerDatabaseInit(message) => { + Self::TrackerDatabaseInit { message, .. } => { format!("ReleaseCommandHandlerError: Tracker database initialization failed - {message}") } - Self::PrometheusStorageCreation(message) => { + Self::PrometheusStorageCreation { message, .. } => { format!( "ReleaseCommandHandlerError: Prometheus storage creation failed - {message}" ) } - Self::GrafanaStorageCreation(message) => { + Self::GrafanaStorageCreation { message, .. } => { format!("ReleaseCommandHandlerError: Grafana storage creation failed - {message}") } - Self::MysqlStorageCreation(message) => { + Self::MysqlStorageCreation { message, .. } => { format!("ReleaseCommandHandlerError: MySQL storage creation failed - {message}") } - Self::CaddyConfigDeployment(message) => { + Self::CaddyConfigDeployment { message, .. } => { format!( "ReleaseCommandHandlerError: Caddy configuration deployment failed - {message}" ) } - Self::Deployment { message, .. } | Self::DeploymentFailed { message, .. } => { - format!("ReleaseCommandHandlerError: Deployment failed - {message}") + Self::TrackerConfigDeployment { message, .. } => { + format!( + "ReleaseCommandHandlerError: Tracker configuration deployment failed - {message}" + ) + } + Self::GrafanaProvisioningDeployment { message, .. } => { + format!( + "ReleaseCommandHandlerError: Grafana provisioning deployment failed - {message}" + ) + } + Self::PrometheusConfigDeployment { message, .. } => { + format!( + "ReleaseCommandHandlerError: Prometheus configuration deployment failed - {message}" + ) + } + Self::ComposeFilesDeployment { message, .. } => { + format!("ReleaseCommandHandlerError: Docker Compose deployment failed - {message}") } Self::ReleaseOperationFailed { name, message } => { format!( @@ -146,21 +249,25 @@ impl Traceable for ReleaseCommandHandlerError { } fn trace_source(&self) -> Option<&dyn Traceable> { + // Box doesn't implement Traceable, so we return None for all + // step-related errors. The error message is preserved via `to_string()` + // and the trace file captures full context for debugging. match self { - // Box doesn't implement Traceable - Self::DeploymentFailed { source, .. } => Some(source), - Self::Deployment { .. } - | Self::StatePersistence(_) - | Self::EnvironmentNotFound { .. } + Self::EnvironmentNotFound { .. } | Self::MissingInstanceIp { .. } | Self::InvalidState(_) - | Self::TemplateRendering(_) - | Self::TrackerStorageCreation(_) - | Self::TrackerDatabaseInit(_) - | Self::PrometheusStorageCreation(_) - | Self::GrafanaStorageCreation(_) - | Self::MysqlStorageCreation(_) - | Self::CaddyConfigDeployment(_) + | Self::StatePersistence(_) + | Self::TemplateRendering { .. } + | Self::TrackerStorageCreation { .. } + | Self::TrackerDatabaseInit { .. } + | Self::PrometheusStorageCreation { .. } + | Self::GrafanaStorageCreation { .. } + | Self::MysqlStorageCreation { .. } + | Self::CaddyConfigDeployment { .. } + | Self::TrackerConfigDeployment { .. } + | Self::GrafanaProvisioningDeployment { .. } + | Self::PrometheusConfigDeployment { .. } + | Self::ComposeFilesDeployment { .. } | Self::ReleaseOperationFailed { .. } => None, } } @@ -171,17 +278,18 @@ impl Traceable for ReleaseCommandHandlerError { | Self::MissingInstanceIp { .. } | Self::InvalidState(_) => ErrorKind::Configuration, Self::StatePersistence(_) => ErrorKind::StatePersistence, - Self::TemplateRendering(_) - | Self::TrackerStorageCreation(_) - | Self::TrackerDatabaseInit(_) - | Self::PrometheusStorageCreation(_) - | Self::GrafanaStorageCreation(_) - | Self::MysqlStorageCreation(_) - | Self::CaddyConfigDeployment(_) => ErrorKind::TemplateRendering, - Self::Deployment { .. } | Self::ReleaseOperationFailed { .. } => { - ErrorKind::InfrastructureOperation - } - Self::DeploymentFailed { source, .. } => source.error_kind(), + Self::TemplateRendering { .. } => ErrorKind::TemplateRendering, + Self::TrackerStorageCreation { .. } + | Self::TrackerDatabaseInit { .. } + | Self::PrometheusStorageCreation { .. } + | Self::GrafanaStorageCreation { .. } + | Self::MysqlStorageCreation { .. } + | Self::CaddyConfigDeployment { .. } + | Self::TrackerConfigDeployment { .. } + | Self::GrafanaProvisioningDeployment { .. } + | Self::PrometheusConfigDeployment { .. } + | Self::ComposeFilesDeployment { .. } + | Self::ReleaseOperationFailed { .. } => ErrorKind::InfrastructureOperation, } } } @@ -285,7 +393,7 @@ State files are stored in: data// If the problem persists, report it with full system details." } - Self::TemplateRendering(_) => { + Self::TemplateRendering { .. } => { "Template Rendering Failed - Troubleshooting: 1. Check that template files exist in the templates directory @@ -301,7 +409,7 @@ Common causes: For more information, see docs/user-guide/commands.md" } - Self::TrackerStorageCreation(_) => { + Self::TrackerStorageCreation { .. } => { "Tracker Storage Creation Failed - Troubleshooting: 1. Verify the target instance is reachable: @@ -325,7 +433,7 @@ Common causes: For more information, see docs/user-guide/commands.md" } - Self::TrackerDatabaseInit(_) => { + Self::TrackerDatabaseInit { .. } => { "Tracker Database Initialization Failed - Troubleshooting: 1. Verify the tracker storage directories were created: @@ -350,7 +458,7 @@ Common causes: For more information, see docs/user-guide/commands.md" } - Self::PrometheusStorageCreation(_) => { + Self::PrometheusStorageCreation { .. } => { "Prometheus Storage Creation Failed - Troubleshooting: 1. Verify the target instance is reachable: @@ -374,7 +482,7 @@ Common causes: For more information, see docs/user-guide/commands.md" } - Self::GrafanaStorageCreation(_) => { + Self::GrafanaStorageCreation { .. } => { "Grafana Storage Creation Failed - Troubleshooting: 1. Verify the target instance is reachable: @@ -398,7 +506,7 @@ Common causes: For more information, see docs/user-guide/commands.md" } - Self::MysqlStorageCreation(_) => { + Self::MysqlStorageCreation { .. } => { "MySQL Storage Creation Failed - Troubleshooting: 1. Verify the target instance is reachable: @@ -422,7 +530,7 @@ Common causes: For more information, see docs/user-guide/commands.md" } - Self::CaddyConfigDeployment(_) => { + Self::CaddyConfigDeployment { .. } => { "Caddy Configuration Deployment Failed - Troubleshooting: 1. Verify the target instance is reachable: @@ -448,10 +556,89 @@ Common causes: For more information, see docs/user-guide/commands.md" } - Self::Deployment { .. } => { - "Deployment Failed - Troubleshooting: + Self::TrackerConfigDeployment { .. } => { + "Tracker Configuration Deployment Failed - Troubleshooting: + +1. Verify the target instance is reachable: + ssh @ + +2. Check that the tracker configuration was generated in the build directory: + ls build//tracker/ + +3. Verify the Ansible playbook exists: + ls templates/ansible/deploy-tracker-config.yml + +4. Check that the instance has sufficient disk space: + df -h + +5. Review the error message above for specific details + +Common causes: +- Configuration files not generated +- Insufficient disk space on target instance +- Permission denied on target directories +- Ansible playbook not found +- Network connectivity issues + +For more information, see docs/user-guide/commands.md" + } + Self::GrafanaProvisioningDeployment { .. } => { + "Grafana Provisioning Deployment Failed - Troubleshooting: + +1. Verify the target instance is reachable: + ssh @ + +2. Check that the Grafana provisioning files were generated: + ls build//grafana/ + +3. Verify the Ansible playbook exists: + ls templates/ansible/deploy-grafana-provisioning.yml + +4. Check that the instance has sufficient disk space: + df -h + +5. Review the error message above for specific details + +Common causes: +- Provisioning files not generated +- Insufficient disk space on target instance +- Permission denied on target directories +- Ansible playbook not found +- Network connectivity issues + +For more information, see docs/user-guide/commands.md" + } + Self::PrometheusConfigDeployment { .. } => { + "Prometheus Configuration Deployment Failed - Troubleshooting: -1. Verify the build directory exists and contains expected files +1. Verify the target instance is reachable: + ssh @ + +2. Check that the Prometheus configuration was generated: + ls build//prometheus/ + +3. Verify the Ansible playbook exists: + ls templates/ansible/deploy-prometheus-config.yml + +4. Check that the instance has sufficient disk space: + df -h + +5. Review the error message above for specific details + +Common causes: +- Configuration files not generated +- Insufficient disk space on target instance +- Permission denied on target directories +- Ansible playbook not found +- Network connectivity issues + +For more information, see docs/user-guide/commands.md" + } + Self::ComposeFilesDeployment { .. } => { + "Docker Compose Deployment Failed - Troubleshooting: + +1. Verify the build directory exists and contains expected files: + ls build//docker-compose/ 2. Check that the target instance is reachable: ssh @ @@ -471,7 +658,6 @@ Common causes: For more information, see docs/user-guide/commands.md" } - Self::DeploymentFailed { source, .. } => source.help(), Self::ReleaseOperationFailed { .. } => { "Release Operation Failed - Troubleshooting: @@ -503,6 +689,12 @@ mod tests { use super::*; use crate::domain::environment::repository::RepositoryError; use crate::domain::environment::state::StateTypeError; + use std::io; + + /// Helper function to create a boxed error for testing + fn make_boxed_error(msg: &str) -> BoxedStepError { + Box::new(io::Error::other(msg)) + } #[test] fn it_should_provide_help_for_environment_not_found() { @@ -538,7 +730,10 @@ mod tests { #[test] fn it_should_provide_help_for_template_rendering() { - let error = ReleaseCommandHandlerError::TemplateRendering("Test error".to_string()); + let error = ReleaseCommandHandlerError::TemplateRendering { + message: "Test error".to_string(), + source: make_boxed_error("underlying error"), + }; let help = error.help(); assert!(help.contains("Template Rendering")); @@ -582,18 +777,49 @@ mod tests { actual: "created".to_string(), }), ReleaseCommandHandlerError::StatePersistence(RepositoryError::NotFound), - ReleaseCommandHandlerError::TemplateRendering("test".to_string()), - ReleaseCommandHandlerError::TrackerStorageCreation("test".to_string()), - ReleaseCommandHandlerError::TrackerDatabaseInit("test".to_string()), - ReleaseCommandHandlerError::PrometheusStorageCreation("test".to_string()), - ReleaseCommandHandlerError::GrafanaStorageCreation("test".to_string()), - ReleaseCommandHandlerError::MysqlStorageCreation("test".to_string()), - ReleaseCommandHandlerError::CaddyConfigDeployment("test".to_string()), - ReleaseCommandHandlerError::DeploymentFailed { + ReleaseCommandHandlerError::TemplateRendering { + message: "test".to_string(), + source: make_boxed_error("test"), + }, + ReleaseCommandHandlerError::TrackerStorageCreation { + message: "test".to_string(), + source: make_boxed_error("test"), + }, + ReleaseCommandHandlerError::TrackerDatabaseInit { + message: "test".to_string(), + source: make_boxed_error("test"), + }, + ReleaseCommandHandlerError::PrometheusStorageCreation { + message: "test".to_string(), + source: make_boxed_error("test"), + }, + ReleaseCommandHandlerError::GrafanaStorageCreation { + message: "test".to_string(), + source: make_boxed_error("test"), + }, + ReleaseCommandHandlerError::MysqlStorageCreation { + message: "test".to_string(), + source: make_boxed_error("test"), + }, + ReleaseCommandHandlerError::CaddyConfigDeployment { + message: "test".to_string(), + source: make_boxed_error("test"), + }, + ReleaseCommandHandlerError::TrackerConfigDeployment { + message: "test".to_string(), + source: make_boxed_error("test"), + }, + ReleaseCommandHandlerError::GrafanaProvisioningDeployment { + message: "test".to_string(), + source: make_boxed_error("test"), + }, + ReleaseCommandHandlerError::PrometheusConfigDeployment { + message: "test".to_string(), + source: make_boxed_error("test"), + }, + ReleaseCommandHandlerError::ComposeFilesDeployment { message: "test".to_string(), - source: DeployComposeFilesStepError::ComposeBuildDirNotFound { - path: "/tmp/test".to_string(), - }, + source: make_boxed_error("test"), }, ReleaseCommandHandlerError::ReleaseOperationFailed { name: "test".to_string(), diff --git a/src/application/command_handlers/release/handler.rs b/src/application/command_handlers/release/handler.rs index 1b25b649..9d671c5a 100644 --- a/src/application/command_handlers/release/handler.rs +++ b/src/application/command_handlers/release/handler.rs @@ -1,30 +1,14 @@ //! Release command handler implementation -use std::net::IpAddr; -use std::path::{Path, PathBuf}; use std::sync::Arc; use tracing::{error, info, instrument}; use super::errors::ReleaseCommandHandlerError; -use crate::adapters::ansible::AnsibleClient; -use crate::application::command_handlers::common::StepResult; -use crate::application::steps::{ - application::{ - CreateGrafanaStorageStep, CreateMysqlStorageStep, CreatePrometheusStorageStep, - CreateTrackerStorageStep, DeployCaddyConfigStep, DeployGrafanaProvisioningStep, - DeployPrometheusConfigStep, DeployTrackerConfigStep, InitTrackerDatabaseStep, - }, - rendering::{ - RenderCaddyTemplatesStep, RenderGrafanaTemplatesStep, RenderPrometheusTemplatesStep, - RenderTrackerTemplatesStep, - }, - DeployComposeFilesStep, RenderDockerComposeTemplatesStep, -}; +use super::workflow; use crate::domain::environment::repository::{EnvironmentRepository, TypedEnvironmentRepository}; use crate::domain::environment::state::{ReleaseFailureContext, ReleaseStep}; use crate::domain::environment::{Configured, Environment, Released, Releasing}; -use crate::domain::template::TemplateManager; use crate::domain::EnvironmentName; use crate::shared::error::Traceable; @@ -106,6 +90,7 @@ impl ReleaseCommandHandler { ) -> Result, ReleaseCommandHandlerError> { let environment = self.load_configured_environment(env_name)?; + // Validate instance IP exists before proceeding (fail early) let instance_ip = environment.instance_ip().ok_or_else(|| { ReleaseCommandHandlerError::MissingInstanceIp { name: env_name.to_string(), @@ -134,10 +119,7 @@ impl ReleaseCommandHandler { "Releasing state persisted. Executing release steps." ); - match self - .execute_release_workflow(&releasing_env, instance_ip) - .await - { + match workflow::execute(&releasing_env).await { Ok(released) => { info!( command = "release", @@ -170,747 +152,9 @@ impl ReleaseCommandHandler { } } - /// Execute the release workflow with step tracking - /// - /// This method orchestrates the complete release workflow: - /// 1. Create tracker storage directories - /// 2. Initialize tracker `SQLite` database - /// 3. Render Docker Compose templates to the build directory - /// 4. Deploy compose files to the remote host via Ansible - /// - /// If an error occurs, it returns both the error and the step that was being - /// executed, enabling accurate failure context generation. - /// - /// # Arguments - /// - /// * `environment` - The environment in Releasing state - /// * `instance_ip` - The validated instance IP address (precondition checked by caller) - /// - /// # Errors - /// - /// Returns a tuple of (error, `current_step`) if any release step fails - async fn execute_release_workflow( - &self, - environment: &Environment, - instance_ip: IpAddr, - ) -> StepResult, ReleaseCommandHandlerError, ReleaseStep> { - // Step 1: Create tracker storage directories - Self::create_tracker_storage(environment, instance_ip)?; - - // Step 2: Initialize tracker database - Self::init_tracker_database(environment, instance_ip)?; - - // Step 3: Render tracker configuration templates - let tracker_build_dir = Self::render_tracker_templates(environment)?; - - // Step 4: Deploy tracker configuration to remote - self.deploy_tracker_config_to_remote(environment, &tracker_build_dir, instance_ip)?; - - // Step 5: Create Prometheus storage directories (if enabled) - Self::create_prometheus_storage(environment, instance_ip)?; - - // Step 6: Render Prometheus configuration templates (if enabled) - Self::render_prometheus_templates(environment)?; - - // Step 7: Deploy Prometheus configuration to remote (if enabled) - self.deploy_prometheus_config_to_remote(environment, instance_ip)?; - - // Step 8: Create Grafana storage directories (if enabled) - Self::create_grafana_storage(environment, instance_ip)?; - - // Step 9: Render Grafana provisioning templates (if enabled) - Self::render_grafana_templates(environment)?; - - // Step 10: Deploy Grafana provisioning to remote (if enabled) - self.deploy_grafana_provisioning_to_remote(environment, instance_ip)?; - - // Step 11: Create MySQL storage directories (if enabled) - Self::create_mysql_storage(environment, instance_ip)?; - - // Step 12: Render Caddy configuration templates (if HTTPS enabled) - Self::render_caddy_templates(environment)?; - - // Step 13: Deploy Caddy configuration to remote (if HTTPS enabled) - self.deploy_caddy_config_to_remote(environment, instance_ip)?; - - // Step 14: Render Docker Compose templates - let compose_build_dir = self.render_docker_compose_templates(environment).await?; - - // Step 15: Deploy compose files to remote - self.deploy_compose_files_to_remote(environment, &compose_build_dir, instance_ip)?; - - let released = environment.clone().released(); - - Ok(released) - } - - /// Create tracker storage directories on the remote host - /// - /// # Errors - /// - /// Returns a tuple of (error, `ReleaseStep::CreateTrackerStorage`) if creation fails - #[allow(clippy::result_large_err)] - fn create_tracker_storage( - environment: &Environment, - _instance_ip: IpAddr, - ) -> StepResult<(), ReleaseCommandHandlerError, ReleaseStep> { - let current_step = ReleaseStep::CreateTrackerStorage; - - let ansible_client = Arc::new(AnsibleClient::new(environment.build_dir().join("ansible"))); - - CreateTrackerStorageStep::new(ansible_client) - .execute() - .map_err(|e| { - ( - ReleaseCommandHandlerError::TrackerStorageCreation(e.to_string()), - current_step, - ) - })?; - - info!( - command = "release", - step = %current_step, - "Tracker storage directories created successfully" - ); - - Ok(()) - } - - /// Initialize tracker database on the remote host - /// - /// # Errors - /// - /// Returns a tuple of (error, `ReleaseStep::InitTrackerDatabase`) if initialization fails - #[allow(clippy::result_large_err)] - fn init_tracker_database( - environment: &Environment, - _instance_ip: IpAddr, - ) -> StepResult<(), ReleaseCommandHandlerError, ReleaseStep> { - let current_step = ReleaseStep::InitTrackerDatabase; - - let ansible_client = Arc::new(AnsibleClient::new(environment.build_dir().join("ansible"))); - - InitTrackerDatabaseStep::new(ansible_client) - .execute() - .map_err(|e| { - ( - ReleaseCommandHandlerError::TrackerDatabaseInit(e.to_string()), - current_step, - ) - })?; - - info!( - command = "release", - step = %current_step, - "Tracker database initialized successfully" - ); - - Ok(()) - } - - /// Render Tracker configuration templates to the build directory - /// - /// # Errors - /// - /// Returns a tuple of (error, `ReleaseStep::RenderTrackerTemplates`) if rendering fails - #[allow(clippy::result_large_err)] - fn render_tracker_templates( - environment: &Environment, - ) -> StepResult { - let current_step = ReleaseStep::RenderTrackerTemplates; - - let template_manager = Arc::new(TemplateManager::new(environment.templates_dir())); - let step = RenderTrackerTemplatesStep::new( - Arc::new(environment.clone()), - template_manager, - environment.build_dir().clone(), - ); - - let tracker_build_dir = step.execute().map_err(|e| { - ( - ReleaseCommandHandlerError::TemplateRendering(e.to_string()), - current_step, - ) - })?; - - info!( - command = "release", - tracker_build_dir = %tracker_build_dir.display(), - "Tracker configuration templates rendered successfully" - ); - - Ok(tracker_build_dir) - } - - /// Render Prometheus configuration templates to the build directory (if enabled) - /// - /// This step is optional and only executes if Prometheus is configured in the environment. - /// If Prometheus is not configured, the step is skipped without error. - /// - /// # Errors - /// - /// Returns a tuple of (error, `ReleaseStep::RenderPrometheusTemplates`) if rendering fails - #[allow(clippy::result_large_err)] - fn render_prometheus_templates( - environment: &Environment, - ) -> StepResult<(), ReleaseCommandHandlerError, ReleaseStep> { - let current_step = ReleaseStep::RenderPrometheusTemplates; - - // Check if Prometheus is configured - if environment.context().user_inputs.prometheus().is_none() { - info!( - command = "release", - step = %current_step, - status = "skipped", - "Prometheus not configured - skipping template rendering" - ); - return Ok(()); - } - - let template_manager = Arc::new(TemplateManager::new(environment.templates_dir())); - let step = RenderPrometheusTemplatesStep::new( - Arc::new(environment.clone()), - template_manager, - environment.build_dir().clone(), - ); - - step.execute().map_err(|e| { - ( - ReleaseCommandHandlerError::TemplateRendering(e.to_string()), - current_step, - ) - })?; - - info!( - command = "release", - step = %current_step, - "Prometheus configuration templates rendered successfully" - ); - - Ok(()) - } - - /// Create Prometheus storage directories on the remote host (if enabled) - /// - /// This step is optional and only executes if Prometheus is configured in the environment. - /// If Prometheus is not configured, the step is skipped without error. - /// - /// # Errors - /// - /// Returns a tuple of (error, `ReleaseStep::CreatePrometheusStorage`) if creation fails - #[allow(clippy::result_large_err)] - fn create_prometheus_storage( - environment: &Environment, - _instance_ip: IpAddr, - ) -> StepResult<(), ReleaseCommandHandlerError, ReleaseStep> { - let current_step = ReleaseStep::CreatePrometheusStorage; - - // Check if Prometheus is configured - if environment.context().user_inputs.prometheus().is_none() { - info!( - command = "release", - step = %current_step, - status = "skipped", - "Prometheus not configured - skipping storage creation" - ); - return Ok(()); - } - - let ansible_client = Arc::new(AnsibleClient::new(environment.build_dir().join("ansible"))); - - CreatePrometheusStorageStep::new(ansible_client) - .execute() - .map_err(|e| { - ( - ReleaseCommandHandlerError::PrometheusStorageCreation(e.to_string()), - current_step, - ) - })?; - - info!( - command = "release", - step = %current_step, - "Prometheus storage directories created successfully" - ); - - Ok(()) - } - - /// Create Grafana storage directories on the remote host (if enabled) - /// - /// This step is optional and only executes if Grafana is configured in the environment. - /// If Grafana is not configured, the step is skipped without error. - /// - /// # Errors - /// - /// Returns a tuple of (error, `ReleaseStep::CreateGrafanaStorage`) if creation fails - #[allow(clippy::result_large_err)] - fn create_grafana_storage( - environment: &Environment, - _instance_ip: IpAddr, - ) -> StepResult<(), ReleaseCommandHandlerError, ReleaseStep> { - let current_step = ReleaseStep::CreateGrafanaStorage; - - // Check if Grafana is configured - if environment.context().user_inputs.grafana().is_none() { - info!( - command = "release", - step = %current_step, - status = "skipped", - "Grafana not configured - skipping storage creation" - ); - return Ok(()); - } - - let ansible_client = Arc::new(AnsibleClient::new(environment.build_dir().join("ansible"))); - - CreateGrafanaStorageStep::new(ansible_client) - .execute() - .map_err(|e| { - ( - ReleaseCommandHandlerError::GrafanaStorageCreation(e.to_string()), - current_step, - ) - })?; - - info!( - command = "release", - step = %current_step, - "Grafana storage directories created successfully" - ); - - Ok(()) - } - - /// Create `MySQL` storage directories on the remote host (if enabled) - /// - /// This step is optional and only executes if `MySQL` is configured as the tracker database. - /// If `MySQL` is not configured, the step is skipped without error. - /// - /// # Errors - /// - /// Returns a tuple of (error, `ReleaseStep::CreateMysqlStorage`) if creation fails - #[allow(clippy::result_large_err)] - fn create_mysql_storage( - environment: &Environment, - _instance_ip: IpAddr, - ) -> StepResult<(), ReleaseCommandHandlerError, ReleaseStep> { - let current_step = ReleaseStep::CreateMysqlStorage; - - // Check if MySQL is configured (via tracker database driver) - if !environment.context().user_inputs.tracker().uses_mysql() { - info!( - command = "release", - step = %current_step, - status = "skipped", - "MySQL not configured - skipping storage creation" - ); - return Ok(()); - } - - let ansible_client = Arc::new(AnsibleClient::new(environment.build_dir().join("ansible"))); - - CreateMysqlStorageStep::new(ansible_client) - .execute() - .map_err(|e| { - ( - ReleaseCommandHandlerError::MysqlStorageCreation(e.to_string()), - current_step, - ) - })?; - - info!( - command = "release", - step = %current_step, - "MySQL storage directories created successfully" - ); - - Ok(()) - } - - /// Deploy Prometheus configuration to the remote host via Ansible (if enabled) - /// - /// This step is optional and only executes if Prometheus is configured in the environment. - /// If Prometheus is not configured, the step is skipped without error. - /// - /// # Arguments - /// - /// * `environment` - The environment in Releasing state - /// * `instance_ip` - The target instance IP address - /// - /// # Errors - /// - /// Returns a tuple of (error, `ReleaseStep::DeployPrometheusConfigToRemote`) if deployment fails - #[allow(clippy::result_large_err, clippy::unused_self)] - fn deploy_prometheus_config_to_remote( - &self, - environment: &Environment, - _instance_ip: IpAddr, - ) -> StepResult<(), ReleaseCommandHandlerError, ReleaseStep> { - let current_step = ReleaseStep::DeployPrometheusConfigToRemote; - - // Check if Prometheus is configured - if environment.context().user_inputs.prometheus().is_none() { - info!( - command = "release", - step = %current_step, - status = "skipped", - "Prometheus not configured - skipping config deployment" - ); - return Ok(()); - } - - let ansible_client = Arc::new(AnsibleClient::new(environment.build_dir().join("ansible"))); - - DeployPrometheusConfigStep::new(ansible_client) - .execute() - .map_err(|e| { - ( - ReleaseCommandHandlerError::TemplateRendering(e.to_string()), - current_step, - ) - })?; - - info!( - command = "release", - step = %current_step, - "Prometheus configuration deployed successfully" - ); - - Ok(()) - } - - /// Render Grafana provisioning templates (if enabled) - /// - /// This step is optional and only executes if Grafana is configured in the environment. - /// If Grafana is not configured, the step is skipped without error. - /// - /// # Errors - /// - /// Returns a tuple of (error, `ReleaseStep::RenderGrafanaTemplates`) if rendering fails - #[allow(clippy::result_large_err)] - fn render_grafana_templates( - environment: &Environment, - ) -> StepResult<(), ReleaseCommandHandlerError, ReleaseStep> { - let current_step = ReleaseStep::RenderGrafanaTemplates; - - // Check if Grafana is configured - if environment.context().user_inputs.grafana().is_none() { - info!( - command = "release", - step = %current_step, - status = "skipped", - "Grafana not configured - skipping provisioning template rendering" - ); - return Ok(()); - } - - // Check if Prometheus is configured (required for datasource) - if environment.context().user_inputs.prometheus().is_none() { - info!( - command = "release", - step = %current_step, - status = "skipped", - "Prometheus not configured - skipping Grafana provisioning (datasource requires Prometheus)" - ); - return Ok(()); - } - - let template_manager = Arc::new(TemplateManager::new(environment.templates_dir())); - let step = RenderGrafanaTemplatesStep::new( - Arc::new(environment.clone()), - template_manager, - environment.build_dir().clone(), - ); - - step.execute().map_err(|e| { - ( - ReleaseCommandHandlerError::TemplateRendering(e.to_string()), - current_step, - ) - })?; - - info!( - command = "release", - step = %current_step, - "Grafana provisioning templates rendered successfully" - ); - - Ok(()) - } - - /// Render Caddy configuration templates (if HTTPS enabled) - /// - /// This step is optional and only executes if HTTPS is configured in the environment. - /// If HTTPS is not configured, the step is skipped without error. - /// - /// # Errors - /// - /// Returns a tuple of (error, `ReleaseStep::RenderCaddyTemplates`) if rendering fails - #[allow(clippy::result_large_err)] - fn render_caddy_templates( - environment: &Environment, - ) -> StepResult<(), ReleaseCommandHandlerError, ReleaseStep> { - let current_step = ReleaseStep::RenderCaddyTemplates; - - // Check if HTTPS is configured - if environment.context().user_inputs.https().is_none() { - info!( - command = "release", - step = %current_step, - status = "skipped", - "HTTPS not configured - skipping Caddy template rendering" - ); - return Ok(()); - } - - let template_manager = Arc::new(TemplateManager::new(environment.templates_dir())); - let step = RenderCaddyTemplatesStep::new( - Arc::new(environment.clone()), - template_manager, - environment.build_dir().clone(), - ); - - step.execute().map_err(|e| { - ( - ReleaseCommandHandlerError::TemplateRendering(e.to_string()), - current_step, - ) - })?; - - info!( - command = "release", - step = %current_step, - "Caddy configuration templates rendered successfully" - ); - - Ok(()) - } - - /// Deploy Caddy configuration to the remote host (if HTTPS enabled) - /// - /// This step is optional and only executes if HTTPS is configured in the environment. - /// If HTTPS is not configured, the step is skipped without error. - /// - /// # Errors - /// - /// Returns a tuple of (error, `ReleaseStep::DeployCaddyConfigToRemote`) if deployment fails - #[allow(clippy::result_large_err, clippy::unused_self)] - fn deploy_caddy_config_to_remote( - &self, - environment: &Environment, - _instance_ip: IpAddr, - ) -> StepResult<(), ReleaseCommandHandlerError, ReleaseStep> { - let current_step = ReleaseStep::DeployCaddyConfigToRemote; - - // Check if HTTPS is configured - if environment.context().user_inputs.https().is_none() { - info!( - command = "release", - step = %current_step, - status = "skipped", - "HTTPS not configured - skipping Caddy config deployment" - ); - return Ok(()); - } - - let ansible_client = Arc::new(AnsibleClient::new(environment.build_dir().join("ansible"))); - - DeployCaddyConfigStep::new(ansible_client) - .execute() - .map_err(|e| { - ( - ReleaseCommandHandlerError::CaddyConfigDeployment(e.to_string()), - current_step, - ) - })?; - - info!( - command = "release", - step = %current_step, - "Caddy configuration deployed to remote successfully" - ); - - Ok(()) - } - - /// Deploy Grafana provisioning configuration to the remote host (if enabled) - /// - /// This step is optional and only executes if Grafana is configured in the environment. - /// If Grafana is not configured, the step is skipped without error. - /// - /// # Errors - /// - /// Returns a tuple of (error, `ReleaseStep::DeployGrafanaProvisioning`) if deployment fails - #[allow(clippy::result_large_err, clippy::unused_self)] - fn deploy_grafana_provisioning_to_remote( - &self, - environment: &Environment, - _instance_ip: IpAddr, - ) -> StepResult<(), ReleaseCommandHandlerError, ReleaseStep> { - let current_step = ReleaseStep::DeployGrafanaProvisioning; - - // Check if Grafana is configured - if environment.context().user_inputs.grafana().is_none() { - info!( - command = "release", - step = %current_step, - status = "skipped", - "Grafana not configured - skipping provisioning deployment" - ); - return Ok(()); - } - - // Check if Prometheus is configured (required for datasource) - if environment.context().user_inputs.prometheus().is_none() { - info!( - command = "release", - step = %current_step, - status = "skipped", - "Prometheus not configured - skipping Grafana provisioning deployment" - ); - return Ok(()); - } - - let ansible_client = Arc::new(AnsibleClient::new(environment.build_dir().join("ansible"))); - - DeployGrafanaProvisioningStep::new(ansible_client) - .execute() - .map_err(|e| { - ( - ReleaseCommandHandlerError::TemplateRendering(e.to_string()), - current_step, - ) - })?; - - info!( - command = "release", - step = %current_step, - "Grafana provisioning configuration deployed successfully" - ); - - Ok(()) - } - - /// Deploy tracker configuration to the remote host via Ansible - /// - /// # Arguments - /// - /// * `environment` - The environment in Releasing state - /// * `tracker_build_dir` - Path to the rendered tracker configuration - /// * `instance_ip` - The target instance IP address - /// - /// # Errors - /// - /// Returns a tuple of (error, `ReleaseStep::DeployTrackerConfigToRemote`) if deployment fails - #[allow(clippy::result_large_err, clippy::unused_self)] - fn deploy_tracker_config_to_remote( - &self, - environment: &Environment, - tracker_build_dir: &Path, - _instance_ip: IpAddr, - ) -> StepResult<(), ReleaseCommandHandlerError, ReleaseStep> { - let current_step = ReleaseStep::DeployTrackerConfigToRemote; - - let ansible_client = Arc::new(AnsibleClient::new(environment.build_dir().join("ansible"))); - - DeployTrackerConfigStep::new(ansible_client, tracker_build_dir.to_path_buf()) - .execute() - .map_err(|e| { - ( - ReleaseCommandHandlerError::Deployment { - message: e.to_string(), - source: Box::new(e), - }, - current_step, - ) - })?; - - info!( - command = "release", - step = %current_step, - "Tracker configuration deployed successfully" - ); - - Ok(()) - } - - /// Render Docker Compose templates to the build directory - /// - /// # Errors - /// - /// Returns a tuple of (error, `ReleaseStep::RenderDockerComposeTemplates`) if rendering fails - async fn render_docker_compose_templates( - &self, - environment: &Environment, - ) -> StepResult { - let current_step = ReleaseStep::RenderDockerComposeTemplates; - - let template_manager = Arc::new(TemplateManager::new(environment.templates_dir())); - let step = RenderDockerComposeTemplatesStep::new( - Arc::new(environment.clone()), - template_manager, - environment.build_dir().clone(), - ); - - let compose_build_dir = step.execute().await.map_err(|e| { - ( - ReleaseCommandHandlerError::TemplateRendering(e.to_string()), - current_step, - ) - })?; - - info!( - command = "release", - compose_build_dir = %compose_build_dir.display(), - "Docker Compose templates rendered successfully" - ); - - Ok(compose_build_dir) - } - - /// Deploy compose files to the remote host via Ansible - /// - /// # Arguments - /// - /// * `environment` - The environment in Releasing state - /// * `compose_build_dir` - Path to the rendered compose files - /// * `instance_ip` - The target instance IP address - /// - /// # Errors - /// - /// Returns a tuple of (error, `ReleaseStep::DeployComposeFilesToRemote`) if deployment fails - #[allow(clippy::result_large_err, clippy::unused_self)] - fn deploy_compose_files_to_remote( - &self, - environment: &Environment, - compose_build_dir: &Path, - instance_ip: IpAddr, - ) -> StepResult<(), ReleaseCommandHandlerError, ReleaseStep> { - let current_step = ReleaseStep::DeployComposeFilesToRemote; - - let ansible_client = Arc::new(AnsibleClient::new(environment.ansible_build_dir())); - let step = DeployComposeFilesStep::new(ansible_client, compose_build_dir.to_path_buf()); - - step.execute().map_err(|e| { - ( - ReleaseCommandHandlerError::DeploymentFailed { - message: e.to_string(), - source: e, - }, - current_step, - ) - })?; - - info!( - command = "release", - compose_build_dir = %compose_build_dir.display(), - instance_ip = %instance_ip, - "Compose files deployed to remote host successfully" - ); - - Ok(()) - } + // ========================================================================= + // Helper methods + // ========================================================================= /// Build failure context for a release error and generate trace file /// diff --git a/src/application/command_handlers/release/mod.rs b/src/application/command_handlers/release/mod.rs index 2b56b592..4011d303 100644 --- a/src/application/command_handlers/release/mod.rs +++ b/src/application/command_handlers/release/mod.rs @@ -19,6 +19,13 @@ //! - **Explicit State Transitions**: Type-safe state machine for environment lifecycle //! - **Explicit Errors**: All errors implement `.help()` with actionable guidance //! +//! ## Module Organization +//! +//! - `handler.rs` - Core handler with `execute()`, state transitions, workflow orchestration +//! - `workflow.rs` - Release workflow orchestration (step coordination) +//! - `errors.rs` - Error types for release operations +//! - `steps/` - Service-specific step implementations (tracker, prometheus, etc.) +//! //! ## Release Workflow //! //! The command handler orchestrates a multi-step workflow: @@ -40,6 +47,8 @@ pub mod errors; pub mod handler; +mod steps; +mod workflow; #[cfg(test)] mod tests; diff --git a/src/application/command_handlers/release/steps/caddy.rs b/src/application/command_handlers/release/steps/caddy.rs new file mode 100644 index 00000000..e06e86da --- /dev/null +++ b/src/application/command_handlers/release/steps/caddy.rs @@ -0,0 +1,120 @@ +//! Caddy service release steps +//! +//! This module contains all steps required to release the Caddy service: +//! - Configuration template rendering +//! - Configuration deployment to remote +//! +//! All steps are optional and only execute if HTTPS is configured. + +use std::sync::Arc; + +use tracing::info; + +use super::common::ansible_client; +use crate::application::command_handlers::common::StepResult; +use crate::application::command_handlers::release::errors::ReleaseCommandHandlerError; +use crate::application::steps::application::DeployCaddyConfigStep; +use crate::application::steps::rendering::RenderCaddyTemplatesStep; +use crate::domain::environment::state::ReleaseStep; +use crate::domain::environment::{Environment, Releasing}; +use crate::domain::template::TemplateManager; + +/// Release the Caddy service (if HTTPS enabled) +/// +/// Executes all steps required to release Caddy: +/// 1. Render configuration templates +/// 2. Deploy configuration to remote +/// +/// If HTTPS is not configured, all steps are skipped. +/// +/// # Errors +/// +/// Returns a tuple of (error, step) if any Caddy step fails +#[allow(clippy::result_large_err)] +pub fn release( + environment: &Environment, +) -> StepResult<(), ReleaseCommandHandlerError, ReleaseStep> { + // Check if HTTPS is configured + if environment.context().user_inputs.https().is_none() { + info!( + command = "release", + service = "caddy", + status = "skipped", + "HTTPS not configured - skipping all Caddy steps" + ); + return Ok(()); + } + + render_templates(environment)?; + deploy_config_to_remote(environment)?; + Ok(()) +} + +/// Render Caddy configuration templates +/// +/// # Errors +/// +/// Returns a tuple of (error, `ReleaseStep::RenderCaddyTemplates`) if rendering fails +#[allow(clippy::result_large_err)] +fn render_templates( + environment: &Environment, +) -> StepResult<(), ReleaseCommandHandlerError, ReleaseStep> { + let current_step = ReleaseStep::RenderCaddyTemplates; + + let template_manager = Arc::new(TemplateManager::new(environment.templates_dir())); + let step = RenderCaddyTemplatesStep::new( + Arc::new(environment.clone()), + template_manager, + environment.build_dir().clone(), + ); + + step.execute().map_err(|e| { + ( + ReleaseCommandHandlerError::TemplateRendering { + message: e.to_string(), + source: Box::new(e), + }, + current_step, + ) + })?; + + info!( + command = "release", + step = %current_step, + "Caddy configuration templates rendered successfully" + ); + + Ok(()) +} + +/// Deploy Caddy configuration to the remote host +/// +/// # Errors +/// +/// Returns a tuple of (error, `ReleaseStep::DeployCaddyConfigToRemote`) if deployment fails +#[allow(clippy::result_large_err)] +fn deploy_config_to_remote( + environment: &Environment, +) -> StepResult<(), ReleaseCommandHandlerError, ReleaseStep> { + let current_step = ReleaseStep::DeployCaddyConfigToRemote; + + DeployCaddyConfigStep::new(ansible_client(environment)) + .execute() + .map_err(|e| { + ( + ReleaseCommandHandlerError::CaddyConfigDeployment { + message: e.to_string(), + source: Box::new(e), + }, + current_step, + ) + })?; + + info!( + command = "release", + step = %current_step, + "Caddy configuration deployed to remote successfully" + ); + + Ok(()) +} diff --git a/src/application/command_handlers/release/steps/common.rs b/src/application/command_handlers/release/steps/common.rs new file mode 100644 index 00000000..3d3eac75 --- /dev/null +++ b/src/application/command_handlers/release/steps/common.rs @@ -0,0 +1,14 @@ +//! Common utilities shared across release steps + +use std::sync::Arc; + +use crate::adapters::ansible::AnsibleClient; +use crate::domain::environment::{Environment, Releasing}; + +/// Create an Ansible client configured for the environment's build directory +/// +/// This is a helper function to reduce duplication across step implementations. +#[must_use] +pub fn ansible_client(environment: &Environment) -> Arc { + Arc::new(AnsibleClient::new(environment.build_dir().join("ansible"))) +} diff --git a/src/application/command_handlers/release/steps/compose.rs b/src/application/command_handlers/release/steps/compose.rs new file mode 100644 index 00000000..a3dea447 --- /dev/null +++ b/src/application/command_handlers/release/steps/compose.rs @@ -0,0 +1,111 @@ +//! Docker Compose release steps +//! +//! This module contains all steps required to deploy Docker Compose: +//! - Template rendering +//! - Compose files deployment to remote + +use std::path::{Path, PathBuf}; +use std::sync::Arc; + +use tracing::info; + +use crate::adapters::ansible::AnsibleClient; +use crate::application::command_handlers::common::StepResult; +use crate::application::command_handlers::release::errors::ReleaseCommandHandlerError; +use crate::application::steps::{DeployComposeFilesStep, RenderDockerComposeTemplatesStep}; +use crate::domain::environment::state::ReleaseStep; +use crate::domain::environment::{Environment, Releasing}; +use crate::domain::template::TemplateManager; + +/// Release Docker Compose configuration +/// +/// Executes all steps required to deploy Docker Compose: +/// 1. Render Docker Compose templates +/// 2. Deploy compose files to remote +/// +/// # Errors +/// +/// Returns a tuple of (error, step) if any Docker Compose step fails +pub async fn release( + environment: &Environment, +) -> StepResult<(), ReleaseCommandHandlerError, ReleaseStep> { + let compose_build_dir = render_templates(environment).await?; + deploy_files_to_remote(environment, &compose_build_dir)?; + Ok(()) +} + +/// Render Docker Compose templates to the build directory +/// +/// # Errors +/// +/// Returns a tuple of (error, `ReleaseStep::RenderDockerComposeTemplates`) if rendering fails +async fn render_templates( + environment: &Environment, +) -> StepResult { + let current_step = ReleaseStep::RenderDockerComposeTemplates; + + let template_manager = Arc::new(TemplateManager::new(environment.templates_dir())); + let step = RenderDockerComposeTemplatesStep::new( + Arc::new(environment.clone()), + template_manager, + environment.build_dir().clone(), + ); + + let compose_build_dir = step.execute().await.map_err(|e| { + ( + ReleaseCommandHandlerError::TemplateRendering { + message: e.to_string(), + source: Box::new(e), + }, + current_step, + ) + })?; + + info!( + command = "release", + compose_build_dir = %compose_build_dir.display(), + "Docker Compose templates rendered successfully" + ); + + Ok(compose_build_dir) +} + +/// Deploy compose files to the remote host via Ansible +/// +/// # Arguments +/// +/// * `environment` - The environment in Releasing state +/// * `compose_build_dir` - Path to the rendered compose files +/// +/// # Errors +/// +/// Returns a tuple of (error, `ReleaseStep::DeployComposeFilesToRemote`) if deployment fails +#[allow(clippy::result_large_err)] +fn deploy_files_to_remote( + environment: &Environment, + compose_build_dir: &Path, +) -> StepResult<(), ReleaseCommandHandlerError, ReleaseStep> { + let current_step = ReleaseStep::DeployComposeFilesToRemote; + + let ansible_client = Arc::new(AnsibleClient::new(environment.ansible_build_dir())); + let step = DeployComposeFilesStep::new(ansible_client, compose_build_dir.to_path_buf()); + + step.execute().map_err(|e| { + ( + ReleaseCommandHandlerError::ComposeFilesDeployment { + message: e.to_string(), + source: Box::new(e), + }, + current_step, + ) + })?; + + info!( + command = "release", + compose_build_dir = %compose_build_dir.display(), + instance_ip = ?environment.instance_ip(), + "Compose files deployed to remote host successfully" + ); + + Ok(()) +} diff --git a/src/application/command_handlers/release/steps/grafana.rs b/src/application/command_handlers/release/steps/grafana.rs new file mode 100644 index 00000000..38a9ebdd --- /dev/null +++ b/src/application/command_handlers/release/steps/grafana.rs @@ -0,0 +1,171 @@ +//! Grafana service release steps +//! +//! This module contains all steps required to release the Grafana service: +//! - Storage directory creation +//! - Provisioning template rendering +//! - Provisioning deployment to remote +//! +//! All steps are optional and only execute if Grafana is configured. +//! Provisioning steps additionally require Prometheus for datasource configuration. + +use std::sync::Arc; + +use tracing::info; + +use super::common::ansible_client; +use crate::application::command_handlers::common::StepResult; +use crate::application::command_handlers::release::errors::ReleaseCommandHandlerError; +use crate::application::steps::application::{ + CreateGrafanaStorageStep, DeployGrafanaProvisioningStep, +}; +use crate::application::steps::rendering::RenderGrafanaTemplatesStep; +use crate::domain::environment::state::ReleaseStep; +use crate::domain::environment::{Environment, Releasing}; +use crate::domain::template::TemplateManager; + +/// Release the Grafana service (if enabled) +/// +/// Executes all steps required to release Grafana: +/// 1. Create storage directories +/// 2. Render provisioning templates (requires Prometheus) +/// 3. Deploy provisioning to remote (requires Prometheus) +/// +/// If Grafana is not configured, all steps are skipped. +/// Provisioning steps are skipped if Prometheus is not configured. +/// +/// # Errors +/// +/// Returns a tuple of (error, step) if any Grafana step fails +#[allow(clippy::result_large_err)] +pub fn release( + environment: &Environment, +) -> StepResult<(), ReleaseCommandHandlerError, ReleaseStep> { + // Check if Grafana is configured + if environment.context().user_inputs.grafana().is_none() { + info!( + command = "release", + service = "grafana", + status = "skipped", + "Grafana not configured - skipping all Grafana steps" + ); + return Ok(()); + } + + create_storage(environment)?; + + // Provisioning requires Prometheus for datasource configuration + if environment.context().user_inputs.prometheus().is_none() { + info!( + command = "release", + service = "grafana", + status = "partial", + "Prometheus not configured - skipping Grafana provisioning (datasource requires Prometheus)" + ); + return Ok(()); + } + + render_templates(environment)?; + deploy_provisioning_to_remote(environment)?; + Ok(()) +} + +/// Create Grafana storage directories on the remote host +/// +/// # Errors +/// +/// Returns a tuple of (error, `ReleaseStep::CreateGrafanaStorage`) if creation fails +#[allow(clippy::result_large_err)] +fn create_storage( + environment: &Environment, +) -> StepResult<(), ReleaseCommandHandlerError, ReleaseStep> { + let current_step = ReleaseStep::CreateGrafanaStorage; + + CreateGrafanaStorageStep::new(ansible_client(environment)) + .execute() + .map_err(|e| { + ( + ReleaseCommandHandlerError::GrafanaStorageCreation { + message: e.to_string(), + source: Box::new(e), + }, + current_step, + ) + })?; + + info!( + command = "release", + step = %current_step, + "Grafana storage directories created successfully" + ); + + Ok(()) +} + +/// Render Grafana provisioning templates +/// +/// # Errors +/// +/// Returns a tuple of (error, `ReleaseStep::RenderGrafanaTemplates`) if rendering fails +#[allow(clippy::result_large_err)] +fn render_templates( + environment: &Environment, +) -> StepResult<(), ReleaseCommandHandlerError, ReleaseStep> { + let current_step = ReleaseStep::RenderGrafanaTemplates; + + let template_manager = Arc::new(TemplateManager::new(environment.templates_dir())); + let step = RenderGrafanaTemplatesStep::new( + Arc::new(environment.clone()), + template_manager, + environment.build_dir().clone(), + ); + + step.execute().map_err(|e| { + ( + ReleaseCommandHandlerError::TemplateRendering { + message: e.to_string(), + source: Box::new(e), + }, + current_step, + ) + })?; + + info!( + command = "release", + step = %current_step, + "Grafana provisioning templates rendered successfully" + ); + + Ok(()) +} + +/// Deploy Grafana provisioning configuration to the remote host +/// +/// # Errors +/// +/// Returns a tuple of (error, `ReleaseStep::DeployGrafanaProvisioning`) if deployment fails +#[allow(clippy::result_large_err)] +fn deploy_provisioning_to_remote( + environment: &Environment, +) -> StepResult<(), ReleaseCommandHandlerError, ReleaseStep> { + let current_step = ReleaseStep::DeployGrafanaProvisioning; + + DeployGrafanaProvisioningStep::new(ansible_client(environment)) + .execute() + .map_err(|e| { + ( + ReleaseCommandHandlerError::GrafanaProvisioningDeployment { + message: e.to_string(), + source: Box::new(e), + }, + current_step, + ) + })?; + + info!( + command = "release", + step = %current_step, + "Grafana provisioning configuration deployed successfully" + ); + + Ok(()) +} diff --git a/src/application/command_handlers/release/steps/mod.rs b/src/application/command_handlers/release/steps/mod.rs new file mode 100644 index 00000000..d9ec95da --- /dev/null +++ b/src/application/command_handlers/release/steps/mod.rs @@ -0,0 +1,13 @@ +//! Release step implementations organized by service +//! +//! This module contains the individual step implementations for the release workflow, +//! organized by the service they operate on. Each submodule provides functions that +//! wrap the underlying step structs with error mapping and logging. + +pub mod caddy; +pub mod common; +pub mod compose; +pub mod grafana; +pub mod mysql; +pub mod prometheus; +pub mod tracker; diff --git a/src/application/command_handlers/release/steps/mysql.rs b/src/application/command_handlers/release/steps/mysql.rs new file mode 100644 index 00000000..bfdfad5a --- /dev/null +++ b/src/application/command_handlers/release/steps/mysql.rs @@ -0,0 +1,76 @@ +//! `MySQL` service release steps +//! +//! This module contains all steps required to release the `MySQL` service: +//! - Storage directory creation +//! +//! All steps are optional and only execute if `MySQL` is configured as the tracker database. + +use tracing::info; + +use super::common::ansible_client; +use crate::application::command_handlers::common::StepResult; +use crate::application::command_handlers::release::errors::ReleaseCommandHandlerError; +use crate::application::steps::application::CreateMysqlStorageStep; +use crate::domain::environment::state::ReleaseStep; +use crate::domain::environment::{Environment, Releasing}; + +/// Release the `MySQL` service (if enabled) +/// +/// Executes all steps required to release `MySQL`: +/// 1. Create `MySQL` storage directories +/// +/// If `MySQL` is not configured as the tracker database, all steps are skipped. +/// +/// # Errors +/// +/// Returns a tuple of (error, step) if `MySQL` storage creation fails +#[allow(clippy::result_large_err)] +pub fn release( + environment: &Environment, +) -> StepResult<(), ReleaseCommandHandlerError, ReleaseStep> { + // Check if MySQL is configured (via tracker database driver) + if !environment.context().user_inputs.tracker().uses_mysql() { + info!( + command = "release", + service = "mysql", + status = "skipped", + "MySQL not configured - skipping all MySQL steps" + ); + return Ok(()); + } + + create_storage(environment)?; + Ok(()) +} + +/// Create `MySQL` storage directories on the remote host +/// +/// # Errors +/// +/// Returns a tuple of (error, `ReleaseStep::CreateMysqlStorage`) if creation fails +#[allow(clippy::result_large_err)] +fn create_storage( + environment: &Environment, +) -> StepResult<(), ReleaseCommandHandlerError, ReleaseStep> { + let current_step = ReleaseStep::CreateMysqlStorage; + + CreateMysqlStorageStep::new(ansible_client(environment)) + .execute() + .map_err(|e| { + ( + ReleaseCommandHandlerError::MysqlStorageCreation { + message: e.to_string(), + source: Box::new(e), + }, + current_step, + ) + })?; + + info!( + command = "release", + step = %current_step, + "MySQL storage directories created successfully" + ); + + Ok(()) +} diff --git a/src/application/command_handlers/release/steps/prometheus.rs b/src/application/command_handlers/release/steps/prometheus.rs new file mode 100644 index 00000000..a18f6a63 --- /dev/null +++ b/src/application/command_handlers/release/steps/prometheus.rs @@ -0,0 +1,157 @@ +//! Prometheus service release steps +//! +//! This module contains all steps required to release the Prometheus service: +//! - Storage directory creation +//! - Configuration template rendering +//! - Configuration deployment to remote +//! +//! All steps are optional and only execute if Prometheus is configured. + +use std::sync::Arc; + +use tracing::info; + +use super::common::ansible_client; +use crate::application::command_handlers::common::StepResult; +use crate::application::command_handlers::release::errors::ReleaseCommandHandlerError; +use crate::application::steps::application::{ + CreatePrometheusStorageStep, DeployPrometheusConfigStep, +}; +use crate::application::steps::rendering::RenderPrometheusTemplatesStep; +use crate::domain::environment::state::ReleaseStep; +use crate::domain::environment::{Environment, Releasing}; +use crate::domain::template::TemplateManager; + +/// Release the Prometheus service (if enabled) +/// +/// Executes all steps required to release Prometheus: +/// 1. Create storage directories +/// 2. Render configuration templates +/// 3. Deploy configuration to remote +/// +/// If Prometheus is not configured, all steps are skipped. +/// +/// # Errors +/// +/// Returns a tuple of (error, step) if any Prometheus step fails +#[allow(clippy::result_large_err)] +pub fn release( + environment: &Environment, +) -> StepResult<(), ReleaseCommandHandlerError, ReleaseStep> { + // Check if Prometheus is configured + if environment.context().user_inputs.prometheus().is_none() { + info!( + command = "release", + service = "prometheus", + status = "skipped", + "Prometheus not configured - skipping all Prometheus steps" + ); + return Ok(()); + } + + create_storage(environment)?; + render_templates(environment)?; + deploy_config_to_remote(environment)?; + Ok(()) +} + +/// Create Prometheus storage directories on the remote host +/// +/// # Errors +/// +/// Returns a tuple of (error, `ReleaseStep::CreatePrometheusStorage`) if creation fails +#[allow(clippy::result_large_err)] +fn create_storage( + environment: &Environment, +) -> StepResult<(), ReleaseCommandHandlerError, ReleaseStep> { + let current_step = ReleaseStep::CreatePrometheusStorage; + + CreatePrometheusStorageStep::new(ansible_client(environment)) + .execute() + .map_err(|e| { + ( + ReleaseCommandHandlerError::PrometheusStorageCreation { + message: e.to_string(), + source: Box::new(e), + }, + current_step, + ) + })?; + + info!( + command = "release", + step = %current_step, + "Prometheus storage directories created successfully" + ); + + Ok(()) +} + +/// Render Prometheus configuration templates to the build directory +/// +/// # Errors +/// +/// Returns a tuple of (error, `ReleaseStep::RenderPrometheusTemplates`) if rendering fails +#[allow(clippy::result_large_err)] +fn render_templates( + environment: &Environment, +) -> StepResult<(), ReleaseCommandHandlerError, ReleaseStep> { + let current_step = ReleaseStep::RenderPrometheusTemplates; + + let template_manager = Arc::new(TemplateManager::new(environment.templates_dir())); + let step = RenderPrometheusTemplatesStep::new( + Arc::new(environment.clone()), + template_manager, + environment.build_dir().clone(), + ); + + step.execute().map_err(|e| { + ( + ReleaseCommandHandlerError::TemplateRendering { + message: e.to_string(), + source: Box::new(e), + }, + current_step, + ) + })?; + + info!( + command = "release", + step = %current_step, + "Prometheus configuration templates rendered successfully" + ); + + Ok(()) +} + +/// Deploy Prometheus configuration to the remote host via Ansible +/// +/// # Errors +/// +/// Returns a tuple of (error, `ReleaseStep::DeployPrometheusConfigToRemote`) if deployment fails +#[allow(clippy::result_large_err)] +fn deploy_config_to_remote( + environment: &Environment, +) -> StepResult<(), ReleaseCommandHandlerError, ReleaseStep> { + let current_step = ReleaseStep::DeployPrometheusConfigToRemote; + + DeployPrometheusConfigStep::new(ansible_client(environment)) + .execute() + .map_err(|e| { + ( + ReleaseCommandHandlerError::PrometheusConfigDeployment { + message: e.to_string(), + source: Box::new(e), + }, + current_step, + ) + })?; + + info!( + command = "release", + step = %current_step, + "Prometheus configuration deployed successfully" + ); + + Ok(()) +} diff --git a/src/application/command_handlers/release/steps/tracker.rs b/src/application/command_handlers/release/steps/tracker.rs new file mode 100644 index 00000000..4ec54848 --- /dev/null +++ b/src/application/command_handlers/release/steps/tracker.rs @@ -0,0 +1,184 @@ +//! Tracker service release steps +//! +//! This module contains all steps required to release the Tracker service: +//! - Storage directory creation +//! - Database initialization +//! - Configuration template rendering +//! - Configuration deployment to remote + +use std::path::{Path, PathBuf}; +use std::sync::Arc; + +use tracing::info; + +use super::common::ansible_client; +use crate::application::command_handlers::common::StepResult; +use crate::application::command_handlers::release::errors::ReleaseCommandHandlerError; +use crate::application::steps::application::{ + CreateTrackerStorageStep, DeployTrackerConfigStep, InitTrackerDatabaseStep, +}; +use crate::application::steps::rendering::RenderTrackerTemplatesStep; +use crate::domain::environment::state::ReleaseStep; +use crate::domain::environment::{Environment, Releasing}; +use crate::domain::template::TemplateManager; + +/// Release the Tracker service +/// +/// Executes all steps required to release the Tracker: +/// 1. Create storage directories +/// 2. Initialize database +/// 3. Render configuration templates +/// 4. Deploy configuration to remote +/// +/// # Errors +/// +/// Returns a tuple of (error, step) if any tracker step fails +#[allow(clippy::result_large_err)] +pub fn release( + environment: &Environment, +) -> StepResult<(), ReleaseCommandHandlerError, ReleaseStep> { + create_storage(environment)?; + init_database(environment)?; + let tracker_build_dir = render_templates(environment)?; + deploy_config_to_remote(environment, &tracker_build_dir)?; + Ok(()) +} + +/// Create tracker storage directories on the remote host +/// +/// # Errors +/// +/// Returns a tuple of (error, `ReleaseStep::CreateTrackerStorage`) if creation fails +#[allow(clippy::result_large_err)] +fn create_storage( + environment: &Environment, +) -> StepResult<(), ReleaseCommandHandlerError, ReleaseStep> { + let current_step = ReleaseStep::CreateTrackerStorage; + + CreateTrackerStorageStep::new(ansible_client(environment)) + .execute() + .map_err(|e| { + ( + ReleaseCommandHandlerError::TrackerStorageCreation { + message: e.to_string(), + source: Box::new(e), + }, + current_step, + ) + })?; + + info!( + command = "release", + step = %current_step, + "Tracker storage directories created successfully" + ); + + Ok(()) +} + +/// Initialize tracker database on the remote host +/// +/// # Errors +/// +/// Returns a tuple of (error, `ReleaseStep::InitTrackerDatabase`) if initialization fails +#[allow(clippy::result_large_err)] +fn init_database( + environment: &Environment, +) -> StepResult<(), ReleaseCommandHandlerError, ReleaseStep> { + let current_step = ReleaseStep::InitTrackerDatabase; + + InitTrackerDatabaseStep::new(ansible_client(environment)) + .execute() + .map_err(|e| { + ( + ReleaseCommandHandlerError::TrackerDatabaseInit { + message: e.to_string(), + source: Box::new(e), + }, + current_step, + ) + })?; + + info!( + command = "release", + step = %current_step, + "Tracker database initialized successfully" + ); + + Ok(()) +} + +/// Render Tracker configuration templates to the build directory +/// +/// # Errors +/// +/// Returns a tuple of (error, `ReleaseStep::RenderTrackerTemplates`) if rendering fails +#[allow(clippy::result_large_err)] +fn render_templates( + environment: &Environment, +) -> StepResult { + let current_step = ReleaseStep::RenderTrackerTemplates; + + let template_manager = Arc::new(TemplateManager::new(environment.templates_dir())); + let step = RenderTrackerTemplatesStep::new( + Arc::new(environment.clone()), + template_manager, + environment.build_dir().clone(), + ); + + let tracker_build_dir = step.execute().map_err(|e| { + ( + ReleaseCommandHandlerError::TemplateRendering { + message: e.to_string(), + source: Box::new(e), + }, + current_step, + ) + })?; + + info!( + command = "release", + tracker_build_dir = %tracker_build_dir.display(), + "Tracker configuration templates rendered successfully" + ); + + Ok(tracker_build_dir) +} + +/// Deploy tracker configuration to the remote host via Ansible +/// +/// # Arguments +/// +/// * `environment` - The environment in Releasing state +/// * `tracker_build_dir` - Path to the rendered tracker configuration +/// +/// # Errors +/// +/// Returns a tuple of (error, `ReleaseStep::DeployTrackerConfigToRemote`) if deployment fails +#[allow(clippy::result_large_err)] +fn deploy_config_to_remote( + environment: &Environment, + tracker_build_dir: &Path, +) -> StepResult<(), ReleaseCommandHandlerError, ReleaseStep> { + let current_step = ReleaseStep::DeployTrackerConfigToRemote; + + DeployTrackerConfigStep::new(ansible_client(environment), tracker_build_dir.to_path_buf()) + .execute() + .map_err(|e| { + ( + ReleaseCommandHandlerError::TrackerConfigDeployment { + message: e.to_string(), + source: Box::new(e), + }, + current_step, + ) + })?; + + info!( + command = "release", + step = %current_step, + "Tracker configuration deployed successfully" + ); + + Ok(()) +} diff --git a/src/application/command_handlers/release/workflow.rs b/src/application/command_handlers/release/workflow.rs new file mode 100644 index 00000000..9d4c2345 --- /dev/null +++ b/src/application/command_handlers/release/workflow.rs @@ -0,0 +1,32 @@ +//! Release workflow orchestration +//! +//! This module orchestrates the complete release workflow by coordinating +//! all service-specific release steps in the correct order. + +use super::errors::ReleaseCommandHandlerError; +use super::steps::{caddy, compose, grafana, mysql, prometheus, tracker}; +use crate::application::command_handlers::common::StepResult; +use crate::domain::environment::state::ReleaseStep; +use crate::domain::environment::{Environment, Released, Releasing}; + +/// Execute the release workflow +/// +/// Orchestrates the complete release workflow by calling each service's +/// release steps in the correct order. Each service module handles its +/// own conditional logic (e.g., skipping if not enabled). +/// +/// # Errors +/// +/// Returns a tuple of (error, `current_step`) if any release step fails +pub async fn execute( + environment: &Environment, +) -> StepResult, ReleaseCommandHandlerError, ReleaseStep> { + tracker::release(environment)?; + prometheus::release(environment)?; + grafana::release(environment)?; + mysql::release(environment)?; + caddy::release(environment)?; + compose::release(environment).await?; + + Ok(environment.clone().released()) +}