torrust_tracker_deployer_lib/application/command_handlers/configure/handler.rs
1//! Configure command handler implementation
2
3use std::sync::Arc;
4
5use tracing::{error, info, instrument};
6
7use super::errors::ConfigureCommandHandlerError;
8use crate::adapters::ansible::AnsibleClient;
9use crate::application::command_handlers::common::StepResult;
10use crate::application::steps::{
11 ConfigureFirewallStep, ConfigureSecurityUpdatesStep, InstallDockerComposeStep,
12 InstallDockerStep,
13};
14use crate::application::traits::CommandProgressListener;
15use crate::domain::environment::repository::{EnvironmentRepository, TypedEnvironmentRepository};
16use crate::domain::environment::state::{ConfigureFailureContext, ConfigureStep};
17use crate::domain::environment::{Configured, Configuring, Environment};
18use crate::domain::EnvironmentName;
19use crate::infrastructure::trace::ConfigureTraceWriter;
20use crate::shared::error::Traceable;
21
22/// Total number of steps in the configuration workflow.
23///
24/// This constant is used for progress reporting via `CommandProgressListener`
25/// to display step progress like "[Step 1/4] Installing Docker...".
26const TOTAL_CONFIGURE_STEPS: usize = 4;
27
28/// `ConfigureCommandHandler` orchestrates the complete infrastructure configuration workflow
29///
30/// The `ConfigureCommandHandler` orchestrates the complete infrastructure configuration workflow.
31///
32/// This command handles all steps required to configure infrastructure:
33/// 1. Install Docker
34/// 2. Install Docker Compose
35/// 3. Configure automatic security updates
36/// 4. Configure UFW firewall
37///
38/// # State Management
39///
40/// The command integrates with the type-state pattern for environment lifecycle:
41/// - Accepts `Environment<Provisioned>` as input
42/// - Transitions to `Environment<Configuring>` at start
43/// - Returns `Environment<Configured>` on success
44/// - Transitions to `Environment<ConfigureFailed>` on error
45///
46/// State is persisted after each transition using the injected repository.
47/// Persistence failures are logged but don't fail the command (state remains valid in memory).
48pub struct ConfigureCommandHandler {
49 pub(crate) clock: Arc<dyn crate::shared::Clock>,
50 pub(crate) repository: TypedEnvironmentRepository,
51}
52
53impl ConfigureCommandHandler {
54 /// Create a new `ConfigureCommandHandler`
55 #[must_use]
56 pub fn new(
57 clock: Arc<dyn crate::shared::Clock>,
58 repository: Arc<dyn EnvironmentRepository>,
59 ) -> Self {
60 Self {
61 clock,
62 repository: TypedEnvironmentRepository::new(repository),
63 }
64 }
65
66 /// Execute the complete configuration workflow
67 ///
68 /// # Arguments
69 ///
70 /// * `env_name` - The name of the environment to configure
71 /// * `listener` - Optional progress listener for reporting step-level progress.
72 /// When provided, the handler reports progress at each of the 4 configuration steps.
73 /// When `None`, the handler executes silently (backward compatible).
74 ///
75 /// # Returns
76 ///
77 /// Returns the configured environment
78 ///
79 /// # Errors
80 ///
81 /// Returns an error if any step in the configuration workflow fails:
82 /// * Environment not found or not in `Provisioned` state
83 /// * Docker installation fails
84 /// * Docker Compose installation fails
85 /// * Security updates configuration fails
86 /// * Firewall configuration fails
87 ///
88 /// On error, the environment transitions to `ConfigureFailed` state and is persisted.
89 #[instrument(
90 name = "configure_command",
91 skip_all,
92 fields(
93 command_type = "configure",
94 environment = %env_name
95 )
96 )]
97 pub fn execute(
98 &self,
99 env_name: &EnvironmentName,
100 listener: Option<&dyn CommandProgressListener>,
101 ) -> Result<Environment<Configured>, ConfigureCommandHandlerError> {
102 let environment = self.load_provisioned_environment(env_name)?;
103
104 let started_at = self.clock.now();
105
106 let environment = environment.start_configuring();
107
108 self.repository.save_configuring(&environment)?;
109
110 match Self::execute_configuration_with_tracking(&environment, listener) {
111 Ok(configured_env) => {
112 info!(
113 command = "configure",
114 environment = %configured_env.name(),
115 "Infrastructure configuration completed successfully"
116 );
117
118 self.repository.save_configured(&configured_env)?;
119
120 Ok(configured_env)
121 }
122 Err((e, current_step)) => {
123 error!(
124 command = "configure",
125 environment = %environment.name(),
126 failed_step = ?current_step,
127 error = %e,
128 "Infrastructure configuration failed"
129 );
130
131 let context =
132 self.build_failure_context(&environment, &e, current_step, started_at);
133
134 let failed = environment.configure_failed(context);
135
136 self.repository.save_configure_failed(&failed)?;
137
138 Err(e)
139 }
140 }
141 }
142
143 /// Execute the configuration steps with step tracking
144 ///
145 /// This method executes all configuration steps while tracking which step is currently
146 /// being executed. If an error occurs, it returns both the error and the step that
147 /// was being executed, enabling accurate failure context generation.
148 ///
149 /// # Arguments
150 ///
151 /// * `environment` - The environment in Configuring state
152 /// * `listener` - Optional progress listener for step-level reporting
153 ///
154 /// # Errors
155 ///
156 /// Returns a tuple of (error, `current_step`) if any configuration step fails
157 fn execute_configuration_with_tracking(
158 environment: &Environment<Configuring>,
159 listener: Option<&dyn CommandProgressListener>,
160 ) -> StepResult<Environment<Configured>, ConfigureCommandHandlerError, ConfigureStep> {
161 let ansible_client = Arc::new(AnsibleClient::new(environment.ansible_build_dir()));
162
163 // Allow tests or CI to skip Docker installation
164 // (useful for container-based tests where Docker is already installed via Dockerfile)
165 let skip_docker =
166 std::env::var("TORRUST_TD_SKIP_DOCKER_INSTALL_IN_CONTAINER").is_ok_and(|v| v == "true");
167
168 // Step 1/4: Install Docker
169 let current_step = ConfigureStep::InstallDocker;
170 Self::notify_step_started(listener, 1, "Installing Docker");
171 if skip_docker {
172 info!(
173 command = "configure",
174 step = "install_docker",
175 status = "skipped",
176 "Skipping Docker installation due to TORRUST_TD_SKIP_DOCKER_INSTALL_IN_CONTAINER (Docker pre-installed)"
177 );
178 } else {
179 InstallDockerStep::new(Arc::clone(&ansible_client))
180 .execute(listener)
181 .map_err(|e| (e.into(), current_step))?;
182 }
183
184 // Step 2/4: Install Docker Compose
185 let current_step = ConfigureStep::InstallDockerCompose;
186 Self::notify_step_started(listener, 2, "Installing Docker Compose");
187 if skip_docker {
188 info!(
189 command = "configure",
190 step = "install_docker_compose",
191 status = "skipped",
192 "Skipping Docker Compose installation due to TORRUST_TD_SKIP_DOCKER_INSTALL_IN_CONTAINER (Docker Compose pre-installed)"
193 );
194 } else {
195 InstallDockerComposeStep::new(Arc::clone(&ansible_client))
196 .execute(listener)
197 .map_err(|e| (e.into(), current_step))?;
198 }
199
200 // Step 3/4: Configure automatic security updates
201 let current_step = ConfigureStep::ConfigureSecurityUpdates;
202 Self::notify_step_started(listener, 3, "Configuring automatic security updates");
203 ConfigureSecurityUpdatesStep::new(Arc::clone(&ansible_client))
204 .execute(listener)
205 .map_err(|e| (e.into(), current_step))?;
206
207 // Step 4/4: Configure firewall (UFW)
208 let current_step = ConfigureStep::ConfigureFirewall;
209 Self::notify_step_started(listener, 4, "Configuring firewall (UFW)");
210 // Allow tests or CI to explicitly skip the firewall configuration step
211 // (useful for container-based test runs where iptables/ufw require
212 // elevated kernel capabilities not available in unprivileged containers).
213 let skip_firewall =
214 std::env::var("TORRUST_TD_SKIP_FIREWALL_IN_CONTAINER").is_ok_and(|v| v == "true");
215
216 if skip_firewall {
217 info!(
218 command = "configure",
219 step = "configure_firewall",
220 status = "skipped",
221 "Skipping UFW firewall configuration due to TORRUST_TD_SKIP_FIREWALL_IN_CONTAINER"
222 );
223 } else {
224 ConfigureFirewallStep::new(Arc::clone(&ansible_client))
225 .execute(listener)
226 .map_err(|e| (e.into(), current_step))?;
227 }
228
229 // Transition to Configured state
230 let configured = environment.clone().configured();
231
232 Ok(configured)
233 }
234
235 /// Build failure context for a configuration error and generate trace file
236 ///
237 /// This helper method builds structured error context including the failed step,
238 /// error classification, timing information, and generates a trace file for
239 /// post-mortem analysis.
240 ///
241 /// The trace file is written to `{environment.data_dir()}/traces/{trace_id}.txt`
242 /// and contains a formatted representation of the entire error chain.
243 ///
244 /// # Arguments
245 ///
246 /// * `environment` - The environment being configured (for trace directory path)
247 /// * `error` - The configuration error that occurred
248 /// * `current_step` - The step that was executing when the error occurred
249 /// * `started_at` - The timestamp when configuration execution started
250 ///
251 /// # Returns
252 ///
253 /// A structured `ConfigureFailureContext` with timing, error details, and trace file path
254 fn build_failure_context(
255 &self,
256 environment: &Environment<Configuring>,
257 error: &ConfigureCommandHandlerError,
258 current_step: ConfigureStep,
259 started_at: chrono::DateTime<chrono::Utc>,
260 ) -> ConfigureFailureContext {
261 use crate::application::command_handlers::common::failure_context::build_base_failure_context;
262
263 // Step that failed is directly provided - no reverse engineering needed
264 let failed_step = current_step;
265
266 // Get error kind from the error itself (errors are self-describing)
267 let error_kind = error.error_kind();
268
269 // Build base failure context using common helper
270 let base = build_base_failure_context(&self.clock, started_at, error.to_string());
271
272 // Build handler-specific context
273 let mut context = ConfigureFailureContext {
274 failed_step,
275 error_kind,
276 base,
277 };
278
279 // Generate trace file (logging handled by trace writer)
280 let traces_dir = environment.traces_dir();
281 let trace_writer = ConfigureTraceWriter::new(traces_dir, Arc::clone(&self.clock));
282
283 if let Ok(trace_file_path) = trace_writer.write_trace(&context, error) {
284 context.base.trace_file_path = Some(trace_file_path);
285 }
286
287 context
288 }
289
290 /// Load environment from storage and validate it is in `Provisioned` state
291 ///
292 /// # Errors
293 ///
294 /// Returns an error if:
295 /// * Persistence error occurs during load
296 /// * Environment does not exist
297 /// * Environment is not in `Provisioned` state
298 fn load_provisioned_environment(
299 &self,
300 env_name: &EnvironmentName,
301 ) -> Result<
302 crate::domain::environment::Environment<crate::domain::environment::Provisioned>,
303 ConfigureCommandHandlerError,
304 > {
305 let any_env = self
306 .repository
307 .inner()
308 .load(env_name)
309 .map_err(|e| ConfigureCommandHandlerError::StatePersistence(e.into()))?;
310
311 let any_env = any_env.ok_or_else(|| ConfigureCommandHandlerError::EnvironmentNotFound {
312 name: env_name.to_string(),
313 })?;
314
315 Ok(any_env.try_into_provisioned()?)
316 }
317
318 /// Notify progress listener that a step has started
319 ///
320 /// Helper method to notify the listener when a configuration step begins.
321 /// If no listener is provided, this is a no-op.
322 ///
323 /// # Arguments
324 ///
325 /// * `listener` - Optional progress listener
326 /// * `step_number` - The current step number (1-based)
327 /// * `description` - User-facing description of the step
328 fn notify_step_started(
329 listener: Option<&dyn CommandProgressListener>,
330 step_number: usize,
331 description: &str,
332 ) {
333 if let Some(l) = listener {
334 l.on_step_started(step_number, TOTAL_CONFIGURE_STEPS, description);
335 }
336 }
337}