torrust_tracker_deployer_lib/application/command_handlers/run/handler.rs
1//! Run command handler implementation
2
3use std::net::IpAddr;
4use std::sync::Arc;
5
6use tracing::{error, info, instrument};
7
8use super::errors::RunCommandHandlerError;
9use crate::adapters::ansible::AnsibleClient;
10use crate::application::command_handlers::common::StepResult;
11use crate::application::steps::application::StartServicesStep;
12use crate::domain::environment::repository::{EnvironmentRepository, TypedEnvironmentRepository};
13use crate::domain::environment::runtime_outputs::ServiceEndpoints;
14use crate::domain::environment::state::{RunFailureContext, RunStep};
15use crate::domain::environment::{Environment, Released, Running};
16use crate::domain::EnvironmentName;
17use crate::shared::error::Traceable;
18
19/// `RunCommandHandler` orchestrates the stack execution workflow
20///
21/// The `RunCommandHandler` orchestrates the execution of the deployed software
22/// stack on the target environment.
23///
24/// This command handler handles all steps required to run the stack:
25/// 1. Load the environment from storage
26/// 2. Validate the environment is in the correct state
27/// 3. Start services via Ansible playbook
28/// 4. Transition environment to `Running` state
29///
30/// # Architecture
31///
32/// Follows the three-level architecture:
33/// - **Command** (Level 1): This handler orchestrates the run workflow
34/// - **Step** (Level 2): `StartServicesStep`
35/// - **Remote Action** (Level 3): Ansible playbook executes on remote host
36///
37/// # State Management
38///
39/// The command handler integrates with the type-state pattern for environment lifecycle:
40/// - Accepts environment in `Released` state
41/// - Transitions to `Environment<Running>` on success
42/// - Transitions to `Environment<RunFailed>` on error
43///
44/// State is persisted after each transition using the injected repository.
45pub struct RunCommandHandler {
46 pub(crate) clock: Arc<dyn crate::shared::Clock>,
47 pub(crate) repository: TypedEnvironmentRepository,
48}
49
50impl RunCommandHandler {
51 /// Create a new `RunCommandHandler`
52 #[must_use]
53 pub fn new(
54 repository: Arc<dyn EnvironmentRepository>,
55 clock: Arc<dyn crate::shared::Clock>,
56 ) -> Self {
57 Self {
58 clock,
59 repository: TypedEnvironmentRepository::new(repository),
60 }
61 }
62
63 /// Execute the run workflow
64 ///
65 /// # Arguments
66 ///
67 /// * `env_name` - The name of the environment to run
68 ///
69 /// # Returns
70 ///
71 /// Returns `Ok(Environment<Running>)` on success
72 ///
73 /// # Errors
74 ///
75 /// Returns an error if:
76 /// * Environment not found
77 /// * Environment is not in `Released` state
78 /// * Instance IP is not available
79 /// * Starting services fails
80 /// * State persistence fails
81 #[allow(clippy::result_large_err)]
82 #[instrument(
83 name = "run_command",
84 skip_all,
85 fields(
86 command_type = "run",
87 environment = %env_name
88 )
89 )]
90 pub fn execute(
91 &self,
92 env_name: &EnvironmentName,
93 ) -> Result<Environment<Running>, RunCommandHandlerError> {
94 let environment = self.load_released_environment(env_name)?;
95
96 let instance_ip =
97 environment
98 .instance_ip()
99 .ok_or_else(|| RunCommandHandlerError::MissingInstanceIp {
100 name: env_name.to_string(),
101 })?;
102
103 let started_at = self.clock.now();
104
105 info!(
106 command = "run",
107 environment = %env_name,
108 instance_ip = %instance_ip,
109 current_state = "released",
110 target_state = "running",
111 "Environment loaded and validated. Executing run steps."
112 );
113
114 match self.execute_run_workflow(&environment, instance_ip) {
115 Ok(running) => {
116 info!(
117 command = "run",
118 environment = %running.name(),
119 final_state = "running",
120 "Stack execution completed successfully"
121 );
122
123 self.repository.save_running(&running)?;
124
125 Ok(running)
126 }
127 Err((e, current_step)) => {
128 error!(
129 command = "run",
130 environment = %environment.name(),
131 error = %e,
132 step = ?current_step,
133 "Stack execution failed"
134 );
135
136 let context =
137 self.build_failure_context(&environment, &e, current_step, started_at);
138
139 let failed = environment.start_running().run_failed(context);
140
141 self.repository.save_run_failed(&failed)?;
142
143 Err(e)
144 }
145 }
146 }
147
148 /// Execute the run workflow with step tracking
149 ///
150 /// This method orchestrates the complete run workflow:
151 /// 1. Start Docker Compose services on the remote host
152 /// 2. Build service endpoints for display
153 ///
154 /// If an error occurs, it returns both the error and the step that was being
155 /// executed, enabling accurate failure context generation.
156 ///
157 /// # Arguments
158 ///
159 /// * `environment` - The environment in Released state
160 /// * `instance_ip` - The validated instance IP address (precondition checked by caller)
161 ///
162 /// # Errors
163 ///
164 /// Returns a tuple of (error, `current_step`) if any run step fails
165 #[allow(clippy::result_large_err)]
166 fn execute_run_workflow(
167 &self,
168 environment: &Environment<Released>,
169 instance_ip: IpAddr,
170 ) -> StepResult<Environment<Running>, RunCommandHandlerError, RunStep> {
171 // Step 1: Start Docker Compose services
172 self.start_services(environment, instance_ip)?;
173
174 // Build service endpoints from tracker config and instance IP
175 let service_endpoints =
176 ServiceEndpoints::from_tracker_config(environment.tracker_config(), instance_ip);
177
178 // Transition to running state with service endpoints
179 let running = environment
180 .clone()
181 .start_running_with_endpoints(service_endpoints);
182
183 Ok(running)
184 }
185
186 /// Start Docker Compose services on the remote host via Ansible
187 ///
188 /// # Errors
189 ///
190 /// Returns a tuple of (error, `RunStep::StartServices`) if starting services fails
191 #[allow(clippy::result_large_err, clippy::unused_self)]
192 fn start_services(
193 &self,
194 environment: &Environment<Released>,
195 instance_ip: IpAddr,
196 ) -> StepResult<(), RunCommandHandlerError, RunStep> {
197 let current_step = RunStep::StartServices;
198
199 let ansible_client = Arc::new(AnsibleClient::new(environment.ansible_build_dir()));
200 let step = StartServicesStep::new(ansible_client);
201
202 step.execute().map_err(|e| {
203 (
204 RunCommandHandlerError::StartServicesFailed {
205 message: e.to_string(),
206 source: e,
207 },
208 current_step,
209 )
210 })?;
211
212 info!(
213 command = "run",
214 instance_ip = %instance_ip,
215 "Docker Compose services started successfully"
216 );
217
218 Ok(())
219 }
220
221 /// Build failure context for a run error and generate trace file
222 ///
223 /// This helper method builds structured error context including the failed step,
224 /// error classification, timing information, and generates a trace file for
225 /// post-mortem analysis.
226 ///
227 /// # Arguments
228 ///
229 /// * `environment` - The environment being run (for trace directory path)
230 /// * `error` - The run error that occurred
231 /// * `current_step` - The step that was executing when the error occurred
232 /// * `started_at` - The timestamp when run execution started
233 ///
234 /// # Returns
235 ///
236 /// A `RunFailureContext` with all failure metadata and trace file path
237 fn build_failure_context(
238 &self,
239 environment: &Environment<Released>,
240 error: &RunCommandHandlerError,
241 current_step: RunStep,
242 started_at: chrono::DateTime<chrono::Utc>,
243 ) -> RunFailureContext {
244 use crate::application::command_handlers::common::failure_context::build_base_failure_context;
245 use crate::infrastructure::trace::RunTraceWriter;
246
247 // Step that failed is directly provided - no reverse engineering needed
248 let failed_step = current_step;
249
250 // Get error kind from the error itself (errors are self-describing)
251 let error_kind = error.error_kind();
252
253 // Build base failure context using common helper
254 let base = build_base_failure_context(&self.clock, started_at, error.to_string());
255
256 // Build handler-specific context
257 let mut context = RunFailureContext {
258 failed_step,
259 error_kind,
260 base,
261 };
262
263 // Generate trace file (logging handled by trace writer)
264 let traces_dir = environment.traces_dir();
265 let writer = RunTraceWriter::new(traces_dir, Arc::clone(&self.clock));
266
267 if let Ok(trace_file) = writer.write_trace(&context, error) {
268 context.base.trace_file_path = Some(trace_file);
269 }
270
271 context
272 }
273
274 /// Load environment from storage and validate it is in `Released` state
275 ///
276 /// # Errors
277 ///
278 /// Returns an error if:
279 /// * Persistence error occurs during load
280 /// * Environment does not exist
281 /// * Environment is not in `Released` state
282 #[allow(clippy::result_large_err)]
283 fn load_released_environment(
284 &self,
285 env_name: &EnvironmentName,
286 ) -> Result<Environment<Released>, RunCommandHandlerError> {
287 let any_env = self
288 .repository
289 .inner()
290 .load(env_name)
291 .map_err(|e| RunCommandHandlerError::StatePersistence(e.into()))?;
292
293 let any_env = any_env.ok_or_else(|| RunCommandHandlerError::EnvironmentNotFound {
294 name: env_name.to_string(),
295 })?;
296
297 Ok(any_env.try_into_released()?)
298 }
299}