Skip to main content

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}