Skip to main content

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}