Skip to main content

torrust_tracker_deployer_lib/domain/environment/state/
destroy_failed.rs

1//! `DestroyFailed` State
2//!
3//! Error state - Infrastructure destruction failed
4//!
5//! The destroy command failed during execution. The `context` field
6//! contains structured error information including the failed step, error kind,
7//! timing information, and a reference to the detailed trace file.
8//!
9//! **Recovery Options:**
10//! - Retry the destroy operation
11//! - Manual cleanup of remaining resources
12//! - Review trace file for detailed error information
13
14use serde::{Deserialize, Serialize};
15
16use crate::domain::environment::state::{AnyEnvironmentState, BaseFailureContext, StateTypeError};
17use crate::domain::environment::Environment;
18use crate::shared::ErrorKind;
19
20// ============================================================================
21// Destroy Command Error Context
22// ============================================================================
23
24/// Error context for destroy command failures
25///
26/// Captures comprehensive information about destroy failures including
27/// the specific step that failed, error classification, timing, and trace details.
28#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
29pub struct DestroyFailureContext {
30    /// Which step failed during destruction
31    pub failed_step: DestroyStep,
32
33    /// Error category for type-safe handling
34    pub error_kind: ErrorKind,
35
36    /// Base failure context with common fields
37    #[serde(flatten)]
38    pub base: BaseFailureContext,
39}
40
41/// Steps in the destroy workflow
42#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
43pub enum DestroyStep {
44    /// Loading environment state
45    LoadEnvironment,
46    /// Destroying infrastructure via `OpenTofu`
47    DestroyInfrastructure,
48    /// Cleaning up state files
49    CleanupStateFiles,
50}
51
52/// Error state - Infrastructure destruction failed
53///
54/// The destroy command failed during execution. The `context` field
55/// contains structured error information including the failed step, error kind,
56/// timing information, and a reference to the detailed trace file.
57///
58/// **Recovery Options:**
59/// - Retry the destroy operation
60/// - Manual cleanup of remaining resources
61/// - Review trace file for detailed error information
62#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
63pub struct DestroyFailed {
64    /// Structured error context with detailed failure information
65    pub context: DestroyFailureContext,
66}
67
68// Type Erasure: Typed → Runtime conversion (into_any)
69impl Environment<DestroyFailed> {
70    /// Converts typed `Environment<DestroyFailed>` into type-erased `AnyEnvironmentState`
71    #[must_use]
72    pub fn into_any(self) -> AnyEnvironmentState {
73        AnyEnvironmentState::DestroyFailed(self)
74    }
75}
76
77// Type Restoration: Runtime → Typed conversion (try_into_destroy_failed)
78impl AnyEnvironmentState {
79    /// Attempts to convert `AnyEnvironmentState` to `Environment<DestroyFailed>`
80    ///
81    /// # Errors
82    ///
83    /// Returns `StateTypeError::UnexpectedState` if the environment is not in `DestroyFailed` state.
84    pub fn try_into_destroy_failed(self) -> Result<Environment<DestroyFailed>, StateTypeError> {
85        match self {
86            Self::DestroyFailed(env) => Ok(env),
87            other => Err(StateTypeError::UnexpectedState {
88                expected: "destroy_failed",
89                actual: other.state_name().to_string(),
90            }),
91        }
92    }
93}
94
95#[cfg(test)]
96mod tests {
97    use super::*;
98    use crate::domain::environment::TraceId;
99    use chrono::Utc;
100    use std::path::PathBuf;
101    use std::time::Duration;
102
103    fn create_test_context() -> DestroyFailureContext {
104        DestroyFailureContext {
105            failed_step: DestroyStep::DestroyInfrastructure,
106            error_kind: ErrorKind::InfrastructureOperation,
107            base: BaseFailureContext {
108                error_summary: "infrastructure_destroy_failed".to_string(),
109                failed_at: Utc::now(),
110                execution_started_at: Utc::now(),
111                execution_duration: Duration::from_secs(30),
112                trace_id: TraceId::new(),
113                trace_file_path: None,
114            },
115        }
116    }
117
118    #[test]
119    fn it_should_create_destroy_failed_state_with_context() {
120        let context = create_test_context();
121        let state = DestroyFailed {
122            context: context.clone(),
123        };
124        assert_eq!(
125            state.context.failed_step,
126            DestroyStep::DestroyInfrastructure
127        );
128        assert_eq!(state.context.error_kind, ErrorKind::InfrastructureOperation);
129    }
130
131    #[test]
132    fn it_should_clone_destroy_failed_state() {
133        let state = DestroyFailed {
134            context: create_test_context(),
135        };
136        let cloned = state.clone();
137        assert_eq!(state.context.failed_step, cloned.context.failed_step);
138    }
139
140    #[test]
141    fn it_should_serialize_destroy_failed_state_to_json() {
142        let state = DestroyFailed {
143            context: create_test_context(),
144        };
145        let json = serde_json::to_string(&state).unwrap();
146        assert!(json.contains("DestroyInfrastructure"));
147        assert!(json.contains("InfrastructureOperation"));
148    }
149
150    #[test]
151    fn it_should_deserialize_destroy_failed_state_from_json() {
152        let state = DestroyFailed {
153            context: create_test_context(),
154        };
155        let json = serde_json::to_string(&state).unwrap();
156        let deserialized: DestroyFailed = serde_json::from_str(&json).unwrap();
157        assert_eq!(
158            deserialized.context.failed_step,
159            DestroyStep::DestroyInfrastructure
160        );
161    }
162
163    mod conversion_tests {
164        use super::*;
165        use crate::adapters::ssh::SshCredentials;
166        use crate::domain::environment::name::EnvironmentName;
167        use crate::domain::environment::runtime_outputs::ProvisionMethod;
168        use crate::domain::provider::{LxdConfig, ProviderConfig};
169        use crate::domain::ProfileName;
170        use crate::shared::Username;
171        use std::net::{IpAddr, Ipv4Addr};
172        use std::path::PathBuf;
173
174        fn default_lxd_provider_config(env_name: &EnvironmentName) -> ProviderConfig {
175            ProviderConfig::Lxd(LxdConfig {
176                profile_name: ProfileName::new(format!("lxd-{}", env_name.as_str())).unwrap(),
177            })
178        }
179
180        fn create_test_ssh_credentials() -> SshCredentials {
181            let username = Username::new("test-user".to_string()).unwrap();
182            SshCredentials::new(
183                PathBuf::from("/tmp/test_key"),
184                PathBuf::from("/tmp/test_key.pub"),
185                username,
186            )
187        }
188
189        fn create_test_environment_destroy_failed() -> Environment<DestroyFailed> {
190            let name = EnvironmentName::new("test-env".to_string()).unwrap();
191            let ssh_creds = create_test_ssh_credentials();
192            Environment::new(
193                name.clone(),
194                default_lxd_provider_config(&name),
195                ssh_creds,
196                22,
197                chrono::Utc::now(),
198            )
199            .start_provisioning()
200            .provisioned(
201                IpAddr::V4(Ipv4Addr::new(192, 168, 1, 100)),
202                ProvisionMethod::Provisioned,
203            )
204            .start_destroying()
205            .destroy_failed(super::create_test_context())
206        }
207
208        #[test]
209        fn it_should_convert_destroy_failed_environment_into_any() {
210            let env = create_test_environment_destroy_failed();
211            let any_env = env.into_any();
212            assert!(matches!(any_env, AnyEnvironmentState::DestroyFailed(_)));
213        }
214
215        #[test]
216        fn it_should_convert_any_to_destroy_failed_successfully() {
217            let env = create_test_environment_destroy_failed();
218            let any_env = env.into_any();
219            let result = any_env.try_into_destroy_failed();
220            assert!(result.is_ok());
221        }
222
223        #[test]
224        fn it_should_preserve_error_details_in_failed_states() {
225            let name = EnvironmentName::new("test-env".to_string()).unwrap();
226            let ssh_creds = create_test_ssh_credentials();
227            let context = super::create_test_context();
228            let env = Environment::new(
229                name.clone(),
230                default_lxd_provider_config(&name),
231                ssh_creds,
232                22,
233                chrono::Utc::now(),
234            )
235            .start_provisioning()
236            .provisioned(
237                IpAddr::V4(Ipv4Addr::new(192, 168, 1, 100)),
238                ProvisionMethod::Provisioned,
239            )
240            .start_destroying()
241            .destroy_failed(context.clone());
242
243            // Round-trip conversion
244            let any_env = env.into_any();
245            let env_restored = any_env.try_into_destroy_failed().unwrap();
246
247            assert_eq!(
248                env_restored.state().context.failed_step,
249                context.failed_step
250            );
251            assert_eq!(
252                env_restored.state().context.base.error_summary,
253                context.base.error_summary
254            );
255        }
256    }
257
258    mod context_tests {
259        use super::*;
260
261        #[test]
262        fn it_should_serialize_destroy_failure_context() {
263            let context = DestroyFailureContext {
264                failed_step: DestroyStep::CleanupStateFiles,
265                error_kind: ErrorKind::StatePersistence,
266                base: BaseFailureContext {
267                    error_summary: "Failed to clean up state files".to_string(),
268                    failed_at: Utc::now(),
269                    execution_started_at: Utc::now(),
270                    execution_duration: Duration::from_secs(5),
271                    trace_id: TraceId::new(),
272                    trace_file_path: Some(PathBuf::from("/data/env/traces/trace.log")),
273                },
274            };
275
276            let json = serde_json::to_string(&context).unwrap();
277            assert!(json.contains("CleanupStateFiles"));
278            assert!(json.contains("StatePersistence"));
279        }
280
281        #[test]
282        fn it_should_deserialize_destroy_failure_context() {
283            let trace_id = TraceId::new();
284            let json = format!(
285                r#"{{
286                    "failed_step": "LoadEnvironment",
287                    "error_kind": "StatePersistence",
288                    "error_summary": "Failed to load environment",
289                    "failed_at": "2025-10-24T10:00:00Z",
290                    "execution_started_at": "2025-10-24T09:59:00Z",
291                    "execution_duration": {{"secs": 60, "nanos": 0}},
292                    "trace_id": "{trace_id}",
293                    "trace_file_path": null
294                }}"#
295            );
296
297            let context: DestroyFailureContext = serde_json::from_str(&json).unwrap();
298            assert_eq!(context.failed_step, DestroyStep::LoadEnvironment);
299            assert_eq!(context.error_kind, ErrorKind::StatePersistence);
300        }
301    }
302}