Skip to main content

torrust_tracker_deployer_lib/application/command_handlers/destroy/
handler.rs

1//! Destroy command handler implementation
2
3use std::sync::Arc;
4
5use tracing::{info, instrument};
6
7use super::errors::DestroyCommandHandlerError;
8use crate::application::command_handlers::common::StepResult;
9use crate::application::steps::DestroyInfrastructureStep;
10use crate::domain::environment::repository::{EnvironmentRepository, TypedEnvironmentRepository};
11use crate::domain::environment::{Destroyed, Destroying, Environment};
12use crate::domain::{AnyEnvironmentState, EnvironmentName};
13use crate::shared::error::Traceable;
14
15/// `DestroyCommandHandler` orchestrates the complete infrastructure destruction workflow
16///
17/// The `DestroyCommandHandler` orchestrates the complete infrastructure teardown workflow.
18///
19/// This command handler handles all steps required to destroy infrastructure:
20/// 1. Destroy infrastructure via `OpenTofu`
21/// 2. Clean up state files
22/// 3. Transition environment to `Destroyed` state
23///
24/// # State Management
25///
26/// The command handler integrates with the type-state pattern for environment lifecycle:
27/// - Accepts `Environment<S>` (any state) as input via environment name lookup
28/// - Transitions to `Environment<Destroying>` at start
29/// - Returns `Environment<Destroyed>` on success
30/// - Transitions to `Environment<DestroyFailed>` on error
31///
32/// State is persisted after each transition using the injected repository.
33///
34/// # Idempotency
35///
36/// The destroy operation is idempotent. Running it multiple times on the same
37/// environment will:
38/// - Succeed if the infrastructure is already destroyed
39/// - Report appropriate status to the user
40/// - Not fail due to missing resources
41pub struct DestroyCommandHandler {
42    pub(crate) repository: TypedEnvironmentRepository,
43    pub(crate) clock: Arc<dyn crate::shared::Clock>,
44}
45
46impl DestroyCommandHandler {
47    /// Create a new `DestroyCommandHandler`
48    #[must_use]
49    pub fn new(
50        repository: Arc<dyn EnvironmentRepository>,
51        clock: Arc<dyn crate::shared::Clock>,
52    ) -> Self {
53        Self {
54            repository: TypedEnvironmentRepository::new(repository),
55            clock,
56        }
57    }
58
59    /// Execute the complete destruction workflow
60    ///
61    /// # Arguments
62    ///
63    /// * `env_name` - The name of the environment to destroy
64    ///
65    /// # Returns
66    ///
67    /// Returns the destroyed environment
68    ///
69    /// # Errors
70    ///
71    /// Returns an error if any step in the destruction workflow fails:
72    /// * Environment not found or cannot be loaded
73    /// * Environment is in an invalid state for destruction  
74    /// * `OpenTofu` destroy fails
75    /// * Unable to persist the destroyed state
76    ///
77    /// On error, the environment transitions to `DestroyFailed` state and is persisted.
78    #[instrument(
79        name = "destroy_command",
80        skip_all,
81        fields(
82            command_type = "destroy",
83            environment = %env_name
84        )
85    )]
86    pub fn execute(
87        &self,
88        env_name: &EnvironmentName,
89    ) -> Result<Environment<Destroyed>, DestroyCommandHandlerError> {
90        let any_env = self.load_environment(env_name)?;
91
92        if let AnyEnvironmentState::Destroyed(env) = any_env {
93            info!(
94                command = "destroy",
95                environment = %env_name,
96                "Environment is already destroyed"
97            );
98            return Ok(env);
99        }
100
101        let started_at = self.clock.now();
102
103        let opentofu_build_dir = any_env.tofu_build_dir();
104
105        let destroying_env = match any_env {
106            AnyEnvironmentState::Created(env) => env.start_destroying(),
107            AnyEnvironmentState::Provisioning(env) => env.start_destroying(),
108            AnyEnvironmentState::Provisioned(env) => env.start_destroying(),
109            AnyEnvironmentState::Configuring(env) => env.start_destroying(),
110            AnyEnvironmentState::Configured(env) => env.start_destroying(),
111            AnyEnvironmentState::Releasing(env) => env.start_destroying(),
112            AnyEnvironmentState::Released(env) => env.start_destroying(),
113            AnyEnvironmentState::Running(env) => env.start_destroying(),
114            AnyEnvironmentState::Destroying(env) => env, // Already destroying
115            AnyEnvironmentState::ProvisionFailed(env) => env.start_destroying(),
116            AnyEnvironmentState::ConfigureFailed(env) => env.start_destroying(),
117            AnyEnvironmentState::ReleaseFailed(env) => env.start_destroying(),
118            AnyEnvironmentState::RunFailed(env) => env.start_destroying(),
119            AnyEnvironmentState::DestroyFailed(env) => env.start_destroying(),
120            AnyEnvironmentState::Destroyed(_) => {
121                unreachable!("Already handled Destroyed state above")
122            }
123        };
124
125        self.repository.save_destroying(&destroying_env)?;
126
127        let opentofu_client = Arc::new(crate::adapters::tofu::client::OpenTofuClient::new(
128            opentofu_build_dir,
129        ));
130
131        match Self::execute_destruction_with_tracking(&destroying_env, &opentofu_client) {
132            Ok(()) => {
133                let destroyed = destroying_env.destroyed();
134
135                self.repository.save_destroyed(&destroyed)?;
136
137                info!(
138                    command = "destroy",
139                    environment = %destroyed.name(),
140                    "Infrastructure destruction completed successfully"
141                );
142
143                Ok(destroyed)
144            }
145            Err((e, current_step)) => {
146                let context =
147                    self.build_failure_context(&destroying_env, &e, current_step, started_at);
148                let failed = destroying_env.destroy_failed(context);
149
150                self.repository.save_destroy_failed(&failed)?;
151
152                Err(e)
153            }
154        }
155    }
156
157    // pub(crate) helper methods for testing business logic
158
159    /// Check if infrastructure should be destroyed
160    ///
161    /// Determines whether to attempt infrastructure destruction based on:
162    /// 1. Whether the infrastructure is managed by this tool (domain logic)
163    /// 2. Whether the `OpenTofu` build directory exists (infrastructure check)
164    ///
165    /// # Arguments
166    ///
167    /// * `environment` - The environment being destroyed
168    ///
169    /// # Returns
170    ///
171    /// Returns `true` if infrastructure destruction should be attempted, `false` otherwise.
172    /// Returns `false` for registered environments even if the build directory exists.
173    pub(crate) fn should_destroy_infrastructure(environment: &Environment<Destroying>) -> bool {
174        // Domain logic: check if we manage this infrastructure
175        if !environment.is_infrastructure_managed() {
176            return false;
177        }
178
179        // Infrastructure check: only destroy if OpenTofu build directory exists
180        let tofu_build_dir = environment.tofu_build_dir();
181        tofu_build_dir.exists()
182    }
183
184    /// Check if the environment was registered from existing infrastructure
185    ///
186    /// # Arguments
187    ///
188    /// * `environment` - The environment being destroyed
189    ///
190    /// # Returns
191    ///
192    /// Returns `true` if the environment was registered (not provisioned), `false` otherwise.
193    pub(crate) fn is_registered(environment: &Environment<Destroying>) -> bool {
194        !environment.is_infrastructure_managed()
195    }
196
197    /// Clean up state files during environment destruction
198    ///
199    /// Removes the data and build directories for the environment.
200    /// This is called as part of the destruction workflow.
201    ///
202    /// # Arguments
203    ///
204    /// * `env` - The environment being destroyed
205    ///
206    /// # Errors
207    ///
208    /// Returns an error if state file cleanup fails
209    pub(crate) fn cleanup_state_files<S>(
210        env: &Environment<S>,
211    ) -> Result<(), DestroyCommandHandlerError> {
212        let data_dir = env.data_dir();
213        let build_dir = env.build_dir();
214
215        // Remove data directory if it exists
216        if data_dir.exists() {
217            std::fs::remove_dir_all(data_dir).map_err(|source| {
218                DestroyCommandHandlerError::StateCleanupFailed {
219                    path: data_dir.clone(),
220                    source,
221                }
222            })?;
223            info!(
224                command = "destroy",
225                path = %data_dir.display(),
226                "Removed state directory"
227            );
228        }
229
230        // Remove build directory if it exists
231        if build_dir.exists() {
232            std::fs::remove_dir_all(build_dir).map_err(|source| {
233                DestroyCommandHandlerError::StateCleanupFailed {
234                    path: build_dir.clone(),
235                    source,
236                }
237            })?;
238            info!(
239                command = "destroy",
240                path = %build_dir.display(),
241                "Removed build directory"
242            );
243        }
244
245        Ok(())
246    }
247
248    // Private helper methods
249
250    /// Execute the destruction steps with step tracking
251    ///
252    /// This method executes all destruction steps while tracking which step is currently
253    /// being executed. If an error occurs, it returns both the error and the step that
254    /// was being executed, enabling accurate failure context generation.
255    ///
256    /// # Errors
257    ///
258    /// Returns a tuple of (error, `current_step`) if any destruction step fails
259    fn execute_destruction_with_tracking(
260        environment: &crate::domain::environment::Environment<
261            crate::domain::environment::Destroying,
262        >,
263        opentofu_client: &Arc<crate::adapters::tofu::client::OpenTofuClient>,
264    ) -> StepResult<(), DestroyCommandHandlerError, crate::domain::environment::state::DestroyStep>
265    {
266        use crate::domain::environment::state::DestroyStep;
267
268        // Step 1: Conditionally destroy infrastructure via OpenTofu
269        // Only attempt infrastructure destruction if infrastructure was provisioned (not registered)
270        if Self::should_destroy_infrastructure(environment) {
271            info!(
272                environment = %environment.name(),
273                "Destroying provisioned infrastructure"
274            );
275            Self::destroy_infrastructure(opentofu_client)
276                .map_err(|e| (e, DestroyStep::DestroyInfrastructure))?;
277        } else if Self::is_registered(environment) {
278            // Registered environments have external infrastructure that we don't manage
279            tracing::warn!(
280                environment = %environment.name(),
281                instance_ip = ?environment.instance_ip(),
282                "This environment was registered from existing infrastructure. \
283                 The infrastructure will NOT be destroyed. \
284                 You are responsible for destroying the actual instance (VM, container, or server) manually."
285            );
286            info!(
287                environment = %environment.name(),
288                "Skipping infrastructure destruction (registered environment - external infrastructure)"
289            );
290        } else {
291            info!(
292                environment = %environment.name(),
293                "Skipping infrastructure destruction (environment was never provisioned)"
294            );
295        }
296
297        // Step 2: Clean up state files
298        Self::cleanup_state_files(environment).map_err(|e| (e, DestroyStep::CleanupStateFiles))?;
299
300        Ok(())
301    }
302
303    /// Build structured failure context for destroy command errors
304    ///
305    /// Creates a comprehensive `DestroyFailureContext` containing all relevant
306    /// metadata about the failure including step, timing, error classification,
307    /// and trace file location.
308    ///
309    /// # Arguments
310    ///
311    /// * `environment` - The environment being destroyed (for trace directory path)
312    /// * `error` - The destroy error that occurred
313    /// * `current_step` - The step that was executing when the error occurred
314    /// * `started_at` - The timestamp when destruction execution started
315    ///
316    /// # Returns
317    ///
318    /// A `DestroyFailureContext` with all failure metadata and trace file path
319    fn build_failure_context(
320        &self,
321        _environment: &crate::domain::environment::Environment<
322            crate::domain::environment::Destroying,
323        >,
324        error: &DestroyCommandHandlerError,
325        current_step: crate::domain::environment::state::DestroyStep,
326        started_at: chrono::DateTime<chrono::Utc>,
327    ) -> crate::domain::environment::state::DestroyFailureContext {
328        use crate::application::command_handlers::common::failure_context::build_base_failure_context;
329        use crate::domain::environment::state::DestroyFailureContext;
330
331        // Step that failed is directly provided - no reverse engineering needed
332        let failed_step = current_step;
333
334        // Get error kind from the error itself (errors are self-describing)
335        let error_kind = error.error_kind();
336
337        // Build base failure context using common helper
338        let base = build_base_failure_context(&self.clock, started_at, error.to_string());
339
340        // Build handler-specific context
341        // Note: Trace file generation not implemented for destroy yet
342        DestroyFailureContext {
343            failed_step,
344            error_kind,
345            base,
346        }
347    }
348
349    /// Destroy the infrastructure using `OpenTofu`
350    ///
351    /// Executes the `OpenTofu` destroy workflow to remove all managed infrastructure.
352    ///
353    /// # Arguments
354    ///
355    /// * `opentofu_client` - The `OpenTofu` client configured with the correct build directory
356    ///
357    /// # Errors
358    ///
359    /// Returns an error if `OpenTofu` destroy fails
360    fn destroy_infrastructure(
361        opentofu_client: &Arc<crate::adapters::tofu::client::OpenTofuClient>,
362    ) -> Result<(), DestroyCommandHandlerError> {
363        DestroyInfrastructureStep::new(Arc::clone(opentofu_client)).execute()?;
364        Ok(())
365    }
366
367    /// Load environment from storage
368    ///
369    /// # Errors
370    ///
371    /// Returns an error if:
372    /// * Persistence error occurs during load
373    /// * Environment does not exist
374    fn load_environment(
375        &self,
376        env_name: &EnvironmentName,
377    ) -> Result<AnyEnvironmentState, DestroyCommandHandlerError> {
378        let any_env = self
379            .repository
380            .inner()
381            .load(env_name)
382            .map_err(|e| DestroyCommandHandlerError::StatePersistence(e.into()))?;
383
384        any_env.ok_or_else(|| DestroyCommandHandlerError::EnvironmentNotFound {
385            name: env_name.to_string(),
386        })
387    }
388}