Skip to main content

torrust_tracker_deployer_lib/application/command_handlers/release/
handler.rs

1//! Release command handler implementation
2
3use std::sync::Arc;
4
5use tracing::{error, info, instrument};
6
7use super::errors::ReleaseCommandHandlerError;
8use super::workflow;
9use crate::application::traits::CommandProgressListener;
10use crate::domain::environment::repository::{EnvironmentRepository, TypedEnvironmentRepository};
11use crate::domain::environment::state::{ReleaseFailureContext, ReleaseStep};
12use crate::domain::environment::{Configured, Environment, Released, Releasing};
13use crate::domain::EnvironmentName;
14use crate::shared::error::Traceable;
15
16/// Total number of steps in the release workflow.
17///
18/// This constant is used for progress reporting via `CommandProgressListener`
19/// to display step progress like "[Step 1/7] Releasing Tracker service...".
20pub(super) const TOTAL_RELEASE_STEPS: usize = 7;
21
22/// `ReleaseCommandHandler` orchestrates the software release workflow
23///
24/// The `ReleaseCommandHandler` orchestrates the software release workflow to
25/// deploy software to a configured environment.
26///
27/// This command handler handles all steps required to release software:
28/// 1. Load the environment from storage
29/// 2. Validate the environment is in the correct state
30/// 3. Render Docker Compose templates to the build directory
31/// 4. Deploy compose files to the remote host via Ansible
32/// 5. Transition environment to `Released` state
33///
34/// # Architecture
35///
36/// Follows the three-level architecture:
37/// - **Command** (Level 1): This handler orchestrates the release workflow
38/// - **Step** (Level 2): `RenderDockerComposeTemplatesStep`, `DeployComposeFilesStep`
39/// - **Remote Action** (Level 3): Ansible playbook executes on remote host
40///
41/// # State Management
42///
43/// The command handler integrates with the type-state pattern for environment lifecycle:
44/// - Accepts environment in `Configured` state
45/// - Transitions to `Environment<Releasing>` at start
46/// - Returns `Environment<Released>` on success
47/// - Transitions to `Environment<ReleaseFailed>` on error
48///
49/// State is persisted after each transition using the injected repository.
50pub struct ReleaseCommandHandler {
51    clock: Arc<dyn crate::shared::Clock>,
52    repository: TypedEnvironmentRepository,
53}
54
55impl ReleaseCommandHandler {
56    /// Create a new `ReleaseCommandHandler`
57    #[must_use]
58    pub fn new(
59        repository: Arc<dyn EnvironmentRepository>,
60        clock: Arc<dyn crate::shared::Clock>,
61    ) -> Self {
62        Self {
63            clock,
64            repository: TypedEnvironmentRepository::new(repository),
65        }
66    }
67
68    /// Execute the release workflow
69    ///
70    /// # Arguments
71    ///
72    /// * `env_name` - The name of the environment to release to
73    /// * `listener` - Optional progress listener for step-level reporting
74    ///
75    /// # Returns
76    ///
77    /// Returns `Ok(Environment<Released>)` on success
78    ///
79    /// # Errors
80    ///
81    /// Returns an error if:
82    /// * Environment not found
83    /// * Environment is not in `Configured` state
84    /// * Docker Compose template rendering fails
85    /// * File deployment to VM fails
86    /// * State persistence fails
87    #[instrument(
88        name = "release_command",
89        skip_all,
90        fields(
91            command_type = "release",
92            environment = %env_name
93        )
94    )]
95    pub async fn execute(
96        &self,
97        env_name: &EnvironmentName,
98        listener: Option<&dyn CommandProgressListener>,
99    ) -> Result<Environment<Released>, ReleaseCommandHandlerError> {
100        let environment = self.load_configured_environment(env_name)?;
101
102        // Validate instance IP exists before proceeding (fail early)
103        let instance_ip = environment.instance_ip().ok_or_else(|| {
104            ReleaseCommandHandlerError::MissingInstanceIp {
105                name: env_name.to_string(),
106            }
107        })?;
108
109        let started_at = self.clock.now();
110
111        info!(
112            command = "release",
113            environment = %env_name,
114            instance_ip = %instance_ip,
115            current_state = "configured",
116            target_state = "releasing",
117            "Environment loaded and validated. Transitioning to Releasing state."
118        );
119
120        let releasing_env = environment.start_releasing();
121
122        self.repository.save_releasing(&releasing_env)?;
123
124        info!(
125            command = "release",
126            environment = %env_name,
127            current_state = "releasing",
128            "Releasing state persisted. Executing release steps."
129        );
130
131        match workflow::execute(&releasing_env, listener).await {
132            Ok(released) => {
133                info!(
134                    command = "release",
135                    environment = %released.name(),
136                    final_state = "released",
137                    "Software release completed successfully"
138                );
139
140                self.repository.save_released(&released)?;
141
142                Ok(released)
143            }
144            Err((e, current_step)) => {
145                error!(
146                    command = "release",
147                    environment = %releasing_env.name(),
148                    error = %e,
149                    step = ?current_step,
150                    "Software release failed"
151                );
152
153                let context =
154                    self.build_failure_context(&releasing_env, &e, current_step, started_at);
155                let failed = releasing_env.release_failed(context);
156
157                self.repository.save_release_failed(&failed)?;
158
159                Err(e)
160            }
161        }
162    }
163
164    // =========================================================================
165    // Helper methods
166    // =========================================================================
167
168    /// Build failure context for a release error and generate trace file
169    ///
170    /// This helper method builds structured error context including the failed step,
171    /// error classification, timing information, and generates a trace file for
172    /// post-mortem analysis.
173    ///
174    /// # Arguments
175    ///
176    /// * `environment` - The environment being released (for trace directory path)
177    /// * `error` - The release error that occurred
178    /// * `current_step` - The step that was executing when the error occurred
179    /// * `started_at` - The timestamp when release execution started
180    ///
181    /// # Returns
182    ///
183    /// A `ReleaseFailureContext` with all failure metadata and trace file path
184    fn build_failure_context(
185        &self,
186        environment: &Environment<Releasing>,
187        error: &ReleaseCommandHandlerError,
188        current_step: ReleaseStep,
189        started_at: chrono::DateTime<chrono::Utc>,
190    ) -> ReleaseFailureContext {
191        use crate::application::command_handlers::common::failure_context::build_base_failure_context;
192        use crate::infrastructure::trace::ReleaseTraceWriter;
193
194        // Step that failed is directly provided - no reverse engineering needed
195        let failed_step = current_step;
196
197        // Get error kind from the error itself (errors are self-describing)
198        let error_kind = error.error_kind();
199
200        // Build base failure context using common helper
201        let base = build_base_failure_context(&self.clock, started_at, error.to_string());
202
203        // Build handler-specific context
204        let mut context = ReleaseFailureContext {
205            failed_step,
206            error_kind,
207            base,
208        };
209
210        // Generate trace file (logging handled by trace writer)
211        let traces_dir = environment.traces_dir();
212        let writer = ReleaseTraceWriter::new(traces_dir, Arc::clone(&self.clock));
213
214        if let Ok(trace_file) = writer.write_trace(&context, error) {
215            context.base.trace_file_path = Some(trace_file);
216        }
217
218        context
219    }
220
221    /// Load environment from storage and validate it is in `Configured` state
222    ///
223    /// # Errors
224    ///
225    /// Returns an error if:
226    /// * Persistence error occurs during load
227    /// * Environment does not exist
228    /// * Environment is not in `Configured` state
229    #[allow(clippy::result_large_err)]
230    fn load_configured_environment(
231        &self,
232        env_name: &EnvironmentName,
233    ) -> Result<Environment<Configured>, ReleaseCommandHandlerError> {
234        let any_env = self
235            .repository
236            .inner()
237            .load(env_name)
238            .map_err(|e| ReleaseCommandHandlerError::StatePersistence(e.into()))?;
239
240        let any_env = any_env.ok_or_else(|| ReleaseCommandHandlerError::EnvironmentNotFound {
241            name: env_name.to_string(),
242        })?;
243
244        Ok(any_env.try_into_configured()?)
245    }
246}