Skip to main content

torrust_tracker_deployer_lib/domain/environment/state/
release_failed.rs

1//! `ReleaseFailed` State
2//!
3//! Error state - Release preparation failed
4//!
5//! The release command failed during execution. The `context` field
6//! contains detailed information about the failure, including which step
7//! failed, error classification, and trace file location.
8//!
9//! **Recovery Options:**
10//! - Destroy and recreate the environment
11//! - Manual release correction (advanced users)
12
13use std::fmt;
14
15use serde::{Deserialize, Serialize};
16
17use crate::domain::environment::state::{AnyEnvironmentState, BaseFailureContext, StateTypeError};
18use crate::domain::environment::Environment;
19use crate::shared::error::ErrorKind;
20
21/// Steps in the release workflow
22///
23/// Each variant represents a distinct phase in the release process.
24/// This allows precise tracking of which step failed during release.
25///
26/// The release workflow follows the three-level architecture:
27/// - **Command** (Level 1): `ReleaseCommandHandler` orchestrates the workflow
28/// - **Step** (Level 2): Individual steps like `RenderDockerComposeTemplatesStep` and `DeployComposeFilesStep`
29/// - **Remote Action** (Level 3): Ansible playbooks execute on remote hosts
30#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
31#[serde(rename_all = "snake_case")]
32pub enum ReleaseStep {
33    /// Creating tracker storage directories on remote host
34    CreateTrackerStorage,
35    /// Initializing tracker `SQLite` database file
36    InitTrackerDatabase,
37    /// Rendering Tracker configuration templates to the build directory
38    RenderTrackerTemplates,
39    /// Deploying tracker configuration to the remote host via Ansible
40    DeployTrackerConfigToRemote,
41    /// Creating Prometheus storage directories on remote host
42    CreatePrometheusStorage,
43    /// Rendering Prometheus configuration templates to the build directory
44    RenderPrometheusTemplates,
45    /// Deploying Prometheus configuration to the remote host via Ansible
46    DeployPrometheusConfigToRemote,
47    /// Creating Grafana storage directories on remote host
48    CreateGrafanaStorage,
49    /// Rendering Grafana provisioning templates to the build directory
50    RenderGrafanaTemplates,
51    /// Deploying Grafana provisioning configuration to the remote host via Ansible
52    DeployGrafanaProvisioning,
53    /// Creating `MySQL` storage directories on remote host
54    CreateMysqlStorage,
55    /// Rendering Backup configuration templates to the build directory (if backup enabled)
56    RenderBackupTemplates,
57    /// Creating Backup storage directories on remote host (if backup enabled)
58    CreateBackupStorage,
59    /// Deploying Backup configuration to the remote host via Ansible (if backup enabled)
60    DeployBackupConfigToRemote,
61    /// Installing backup crontab and maintenance script (if backup enabled)
62    InstallBackupCrontab,
63    /// Rendering Caddy configuration templates to the build directory (if HTTPS enabled)
64    RenderCaddyTemplates,
65    /// Deploying Caddy configuration to the remote host via Ansible (if HTTPS enabled)
66    DeployCaddyConfigToRemote,
67    /// Rendering Docker Compose templates to the build directory
68    RenderDockerComposeTemplates,
69    /// Deploying compose files to the remote host via Ansible
70    DeployComposeFilesToRemote,
71}
72
73impl fmt::Display for ReleaseStep {
74    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
75        let name = match self {
76            Self::CreateTrackerStorage => "Create Tracker Storage",
77            Self::InitTrackerDatabase => "Initialize Tracker Database",
78            Self::RenderTrackerTemplates => "Render Tracker Templates",
79            Self::DeployTrackerConfigToRemote => "Deploy Tracker Config to Remote",
80            Self::CreatePrometheusStorage => "Create Prometheus Storage",
81            Self::RenderPrometheusTemplates => "Render Prometheus Templates",
82            Self::DeployPrometheusConfigToRemote => "Deploy Prometheus Config to Remote",
83            Self::CreateGrafanaStorage => "Create Grafana Storage",
84            Self::RenderGrafanaTemplates => "Render Grafana Templates",
85            Self::DeployGrafanaProvisioning => "Deploy Grafana Provisioning",
86            Self::CreateMysqlStorage => "Create MySQL Storage",
87            Self::RenderBackupTemplates => "Render Backup Templates",
88            Self::CreateBackupStorage => "Create Backup Storage",
89            Self::DeployBackupConfigToRemote => "Deploy Backup Config to Remote",
90            Self::InstallBackupCrontab => "Install Backup Crontab",
91            Self::RenderCaddyTemplates => "Render Caddy Templates",
92            Self::DeployCaddyConfigToRemote => "Deploy Caddy Config to Remote",
93            Self::RenderDockerComposeTemplates => "Render Docker Compose Templates",
94            Self::DeployComposeFilesToRemote => "Deploy Compose Files to Remote",
95        };
96        write!(f, "{name}")
97    }
98}
99
100/// Structured failure context for release command errors
101///
102/// Contains comprehensive information about a release failure:
103/// - Which step failed
104/// - Error classification for recovery guidance
105/// - Base failure metadata (timing, trace ID, error summary)
106///
107/// This enables:
108/// - Accurate error reporting
109/// - Recovery suggestions based on the specific failure
110/// - Post-mortem analysis via trace files
111#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
112pub struct ReleaseFailureContext {
113    /// The step that was executing when the failure occurred
114    pub failed_step: ReleaseStep,
115
116    /// Classification of the error for recovery guidance
117    pub error_kind: ErrorKind,
118
119    /// Common failure metadata (timing, trace, error summary)
120    pub base: BaseFailureContext,
121}
122
123/// Error state - Release preparation failed
124///
125/// The release command failed during execution. The `context` field
126/// contains detailed information about the failure.
127///
128/// **Recovery Options:**
129/// - Destroy and recreate the environment
130/// - Manual release correction (advanced users)
131#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
132pub struct ReleaseFailed {
133    /// Structured failure context with step info, error classification, and trace
134    pub context: ReleaseFailureContext,
135}
136
137// Type Erasure: Typed → Runtime conversion (into_any)
138impl Environment<ReleaseFailed> {
139    /// Converts typed `Environment<ReleaseFailed>` into type-erased `AnyEnvironmentState`
140    #[must_use]
141    pub fn into_any(self) -> AnyEnvironmentState {
142        AnyEnvironmentState::ReleaseFailed(self)
143    }
144}
145
146// Type Restoration: Runtime → Typed conversion (try_into_release_failed)
147impl AnyEnvironmentState {
148    /// Attempts to convert `AnyEnvironmentState` to `Environment<ReleaseFailed>`
149    ///
150    /// # Errors
151    ///
152    /// Returns `StateTypeError::UnexpectedState` if the environment is not in `ReleaseFailed` state.
153    pub fn try_into_release_failed(self) -> Result<Environment<ReleaseFailed>, StateTypeError> {
154        match self {
155            Self::ReleaseFailed(env) => Ok(env),
156            other => Err(StateTypeError::UnexpectedState {
157                expected: "release_failed",
158                actual: other.state_name().to_string(),
159            }),
160        }
161    }
162}
163
164#[cfg(test)]
165mod tests {
166    use std::time::Duration;
167
168    use chrono::Utc;
169
170    use super::*;
171    use crate::domain::environment::TraceId;
172
173    fn create_test_failure_context() -> ReleaseFailureContext {
174        let now = Utc::now();
175        ReleaseFailureContext {
176            failed_step: ReleaseStep::RenderDockerComposeTemplates,
177            error_kind: ErrorKind::Configuration,
178            base: BaseFailureContext {
179                error_summary: "Test error".to_string(),
180                failed_at: now,
181                execution_started_at: now,
182                execution_duration: Duration::from_secs(10),
183                trace_id: TraceId::new(),
184                trace_file_path: None,
185            },
186        }
187    }
188
189    #[test]
190    fn it_should_create_release_failed_state_with_context() {
191        let context = create_test_failure_context();
192        let state = ReleaseFailed {
193            context: context.clone(),
194        };
195        assert_eq!(
196            state.context.failed_step,
197            ReleaseStep::RenderDockerComposeTemplates
198        );
199        assert_eq!(state.context.error_kind, ErrorKind::Configuration);
200    }
201
202    #[test]
203    fn it_should_display_release_step() {
204        assert_eq!(
205            format!("{}", ReleaseStep::RenderDockerComposeTemplates),
206            "Render Docker Compose Templates"
207        );
208        assert_eq!(
209            format!("{}", ReleaseStep::DeployComposeFilesToRemote),
210            "Deploy Compose Files to Remote"
211        );
212    }
213
214    #[test]
215    fn it_should_serialize_release_step_to_snake_case() {
216        let step = ReleaseStep::RenderDockerComposeTemplates;
217        let json = serde_json::to_string(&step).unwrap();
218        assert_eq!(json, r#""render_docker_compose_templates""#);
219
220        let step = ReleaseStep::DeployComposeFilesToRemote;
221        let json = serde_json::to_string(&step).unwrap();
222        assert_eq!(json, r#""deploy_compose_files_to_remote""#);
223    }
224
225    #[test]
226    fn it_should_deserialize_release_step_from_snake_case() {
227        let step: ReleaseStep =
228            serde_json::from_str(r#""render_docker_compose_templates""#).unwrap();
229        assert_eq!(step, ReleaseStep::RenderDockerComposeTemplates);
230
231        let step: ReleaseStep =
232            serde_json::from_str(r#""deploy_compose_files_to_remote""#).unwrap();
233        assert_eq!(step, ReleaseStep::DeployComposeFilesToRemote);
234    }
235
236    mod conversion_tests {
237        use super::*;
238        use crate::adapters::ssh::SshCredentials;
239        use crate::domain::environment::name::EnvironmentName;
240        use crate::domain::environment::runtime_outputs::ProvisionMethod;
241        use crate::domain::provider::{LxdConfig, ProviderConfig};
242        use crate::domain::ProfileName;
243        use crate::shared::Username;
244        use std::net::{IpAddr, Ipv4Addr};
245        use std::path::PathBuf;
246
247        fn default_lxd_provider_config(env_name: &EnvironmentName) -> ProviderConfig {
248            ProviderConfig::Lxd(LxdConfig {
249                profile_name: ProfileName::new(format!("lxd-{}", env_name.as_str())).unwrap(),
250            })
251        }
252
253        fn create_test_ssh_credentials() -> SshCredentials {
254            let username = Username::new("test-user".to_string()).unwrap();
255            SshCredentials::new(
256                PathBuf::from("/tmp/test_key"),
257                PathBuf::from("/tmp/test_key.pub"),
258                username,
259            )
260        }
261
262        fn create_test_environment_release_failed() -> Environment<ReleaseFailed> {
263            let name = EnvironmentName::new("test-env".to_string()).unwrap();
264            let ssh_creds = create_test_ssh_credentials();
265            let now = Utc::now();
266            let context = ReleaseFailureContext {
267                failed_step: ReleaseStep::DeployComposeFilesToRemote,
268                error_kind: ErrorKind::InfrastructureOperation,
269                base: BaseFailureContext {
270                    error_summary: "SSH connection failed".to_string(),
271                    failed_at: now,
272                    execution_started_at: now,
273                    execution_duration: Duration::from_secs(5),
274                    trace_id: TraceId::new(),
275                    trace_file_path: None,
276                },
277            };
278            Environment::new(
279                name.clone(),
280                default_lxd_provider_config(&name),
281                ssh_creds,
282                22,
283                chrono::Utc::now(),
284            )
285            .start_provisioning()
286            .provisioned(
287                IpAddr::V4(Ipv4Addr::new(192, 168, 1, 100)),
288                ProvisionMethod::Provisioned,
289            )
290            .start_configuring()
291            .configured()
292            .start_releasing()
293            .release_failed(context)
294        }
295
296        #[test]
297        fn it_should_convert_release_failed_environment_into_any() {
298            let env = create_test_environment_release_failed();
299            let any_env = env.into_any();
300            assert!(matches!(any_env, AnyEnvironmentState::ReleaseFailed(_)));
301        }
302
303        #[test]
304        fn it_should_convert_any_to_release_failed_successfully() {
305            let env = create_test_environment_release_failed();
306            let any_env = env.into_any();
307            let result = any_env.try_into_release_failed();
308            assert!(result.is_ok());
309        }
310    }
311}