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}