Skip to main content

torrust_tracker_deployer_lib/domain/environment/state/
provision_failed.rs

1//! `ProvisionFailed` State
2//!
3//! Error state - Infrastructure provisioning failed
4//!
5//! The provision 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//! - Destroy and recreate the environment
11//! - Manual inspection and repair (advanced users)
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// Provision Command Error Context
22// ============================================================================
23
24/// Error context for provision command failures
25///
26/// Captures comprehensive information about provision failures including
27/// the specific step that failed, error classification, timing, and trace details.
28#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
29pub struct ProvisionFailureContext {
30    /// Which step failed during provisioning
31    pub failed_step: ProvisionStep,
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 provision workflow
42#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
43pub enum ProvisionStep {
44    /// Rendering `OpenTofu` templates
45    RenderOpenTofuTemplates,
46    /// Initializing `OpenTofu`
47    OpenTofuInit,
48    /// Validating infrastructure configuration
49    OpenTofuValidate,
50    /// Planning infrastructure changes
51    OpenTofuPlan,
52    /// Applying infrastructure changes
53    OpenTofuApply,
54    /// Retrieving instance information
55    GetInstanceInfo,
56    /// Rendering Ansible templates with runtime data
57    RenderAnsibleTemplates,
58    /// Waiting for SSH connectivity
59    WaitSshConnectivity,
60    /// Waiting for cloud-init completion
61    CloudInitWait,
62}
63
64/// Error state - Infrastructure provisioning failed
65///
66/// The provision command failed during execution. The `context` field
67/// contains structured error information including the failed step, error kind,
68/// timing information, and a reference to the detailed trace file.
69///
70/// **Recovery Options:**
71/// - Destroy and recreate the environment
72/// - Manual inspection and repair (advanced users)
73/// - Review trace file for detailed error information
74#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
75pub struct ProvisionFailed {
76    /// Structured error context with detailed failure information
77    pub context: ProvisionFailureContext,
78}
79
80// Type Erasure: Typed → Runtime conversion (into_any)
81impl Environment<ProvisionFailed> {
82    /// Converts typed `Environment<ProvisionFailed>` into type-erased `AnyEnvironmentState`
83    #[must_use]
84    pub fn into_any(self) -> AnyEnvironmentState {
85        AnyEnvironmentState::ProvisionFailed(self)
86    }
87}
88
89// Type Restoration: Runtime → Typed conversion (try_into_provision_failed)
90impl AnyEnvironmentState {
91    /// Attempts to convert `AnyEnvironmentState` to `Environment<ProvisionFailed>`
92    ///
93    /// # Errors
94    ///
95    /// Returns `StateTypeError::UnexpectedState` if the environment is not in `ProvisionFailed` state.
96    pub fn try_into_provision_failed(self) -> Result<Environment<ProvisionFailed>, StateTypeError> {
97        match self {
98            Self::ProvisionFailed(env) => Ok(env),
99            other => Err(StateTypeError::UnexpectedState {
100                expected: "provision_failed",
101                actual: other.state_name().to_string(),
102            }),
103        }
104    }
105}
106
107#[cfg(test)]
108mod tests {
109    use super::*;
110    use crate::domain::environment::TraceId;
111    use chrono::Utc;
112    use std::path::PathBuf;
113    use std::time::Duration;
114
115    fn create_test_context() -> ProvisionFailureContext {
116        ProvisionFailureContext {
117            failed_step: ProvisionStep::CloudInitWait,
118            error_kind: ErrorKind::Timeout,
119            base: BaseFailureContext {
120                error_summary: "cloud_init_timeout".to_string(),
121                failed_at: Utc::now(),
122                execution_started_at: Utc::now(),
123                execution_duration: Duration::from_secs(30),
124                trace_id: TraceId::new(),
125                trace_file_path: None,
126            },
127        }
128    }
129
130    #[test]
131    fn it_should_create_provision_failed_state_with_context() {
132        let context = create_test_context();
133        let state = ProvisionFailed {
134            context: context.clone(),
135        };
136        assert_eq!(state.context.failed_step, ProvisionStep::CloudInitWait);
137        assert_eq!(state.context.error_kind, ErrorKind::Timeout);
138    }
139
140    #[test]
141    fn it_should_clone_provision_failed_state() {
142        let state = ProvisionFailed {
143            context: create_test_context(),
144        };
145        let cloned = state.clone();
146        assert_eq!(state.context.failed_step, cloned.context.failed_step);
147    }
148
149    #[test]
150    fn it_should_serialize_provision_failed_state_to_json() {
151        let state = ProvisionFailed {
152            context: create_test_context(),
153        };
154        let json = serde_json::to_string(&state).unwrap();
155        assert!(json.contains("CloudInitWait"));
156        assert!(json.contains("Timeout"));
157    }
158
159    #[test]
160    fn it_should_deserialize_provision_failed_state_from_json() {
161        let state = ProvisionFailed {
162            context: create_test_context(),
163        };
164        let json = serde_json::to_string(&state).unwrap();
165        let deserialized: ProvisionFailed = serde_json::from_str(&json).unwrap();
166        assert_eq!(
167            deserialized.context.failed_step,
168            ProvisionStep::CloudInitWait
169        );
170    }
171
172    mod conversion_tests {
173        use super::*;
174        use crate::adapters::ssh::SshCredentials;
175        use crate::domain::environment::name::EnvironmentName;
176        use crate::domain::provider::{LxdConfig, ProviderConfig};
177        use crate::domain::ProfileName;
178        use crate::shared::Username;
179        use std::path::PathBuf;
180
181        fn default_lxd_provider_config(env_name: &EnvironmentName) -> ProviderConfig {
182            ProviderConfig::Lxd(LxdConfig {
183                profile_name: ProfileName::new(format!("lxd-{}", env_name.as_str())).unwrap(),
184            })
185        }
186
187        fn create_test_ssh_credentials() -> SshCredentials {
188            let username = Username::new("test-user".to_string()).unwrap();
189            SshCredentials::new(
190                PathBuf::from("/tmp/test_key"),
191                PathBuf::from("/tmp/test_key.pub"),
192                username,
193            )
194        }
195
196        fn create_test_environment_provision_failed() -> Environment<ProvisionFailed> {
197            let name = EnvironmentName::new("test-env".to_string()).unwrap();
198            let ssh_creds = create_test_ssh_credentials();
199            Environment::new(
200                name.clone(),
201                default_lxd_provider_config(&name),
202                ssh_creds,
203                22,
204                chrono::Utc::now(),
205            )
206            .start_provisioning()
207            .provision_failed(super::create_test_context())
208        }
209
210        #[test]
211        fn it_should_convert_provision_failed_environment_into_any() {
212            let env = create_test_environment_provision_failed();
213            let any_env = env.into_any();
214            assert!(matches!(any_env, AnyEnvironmentState::ProvisionFailed(_)));
215        }
216
217        #[test]
218        fn it_should_convert_any_to_provision_failed_successfully() {
219            let env = create_test_environment_provision_failed();
220            let any_env = env.into_any();
221            let result = any_env.try_into_provision_failed();
222            assert!(result.is_ok());
223        }
224
225        #[test]
226        fn it_should_preserve_error_details_in_failed_states() {
227            let name = EnvironmentName::new("test-env".to_string()).unwrap();
228            let ssh_creds = create_test_ssh_credentials();
229            let context = super::create_test_context();
230            let env = Environment::new(
231                name.clone(),
232                default_lxd_provider_config(&name),
233                ssh_creds,
234                22,
235                chrono::Utc::now(),
236            )
237            .start_provisioning()
238            .provision_failed(context.clone());
239
240            // Round-trip conversion
241            let any_env = env.into_any();
242            let env_restored = any_env.try_into_provision_failed().unwrap();
243
244            assert_eq!(
245                env_restored.state().context.failed_step,
246                context.failed_step
247            );
248            assert_eq!(
249                env_restored.state().context.base.error_summary,
250                context.base.error_summary
251            );
252        }
253    }
254
255    mod context_tests {
256        use super::*;
257
258        #[test]
259        fn it_should_serialize_provision_failure_context() {
260            let context = ProvisionFailureContext {
261                failed_step: ProvisionStep::OpenTofuApply,
262                error_kind: ErrorKind::InfrastructureOperation,
263                base: BaseFailureContext {
264                    error_summary: "Infrastructure provisioning failed".to_string(),
265                    failed_at: Utc::now(),
266                    execution_started_at: Utc::now(),
267                    execution_duration: Duration::from_secs(30),
268                    trace_id: TraceId::new(),
269                    trace_file_path: Some(PathBuf::from("/data/env/traces/trace.log")),
270                },
271            };
272
273            let json = serde_json::to_string(&context).unwrap();
274            assert!(json.contains("OpenTofuApply"));
275            assert!(json.contains("InfrastructureOperation"));
276        }
277
278        #[test]
279        fn it_should_deserialize_provision_failure_context() {
280            let trace_id = TraceId::new();
281            let json = format!(
282                r#"{{
283                    "failed_step": "RenderOpenTofuTemplates",
284                    "error_kind": "TemplateRendering",
285                    "error_summary": "Template rendering failed",
286                    "failed_at": "2025-10-06T10:00:00Z",
287                    "execution_started_at": "2025-10-06T09:59:00Z",
288                    "execution_duration": {{"secs": 60, "nanos": 0}},
289                    "trace_id": "{trace_id}",
290                    "trace_file_path": null
291                }}"#
292            );
293
294            let context: ProvisionFailureContext = serde_json::from_str(&json).unwrap();
295            assert_eq!(context.failed_step, ProvisionStep::RenderOpenTofuTemplates);
296            assert_eq!(context.error_kind, ErrorKind::TemplateRendering);
297        }
298    }
299}