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}