Skip to main content

torrust_tracker_deployer_lib/testing/e2e/
context.rs

1//! Test context management for E2E testing
2//!
3//! This module provides the `TestContext` which manages the complete setup
4//! and configuration of test contexts for end-to-end deployment testing.
5//!
6//! ## Key Features
7//!
8//! - Temporary directory and SSH key management
9//! - Service container initialization with test configuration
10//! - Template preparation and cleanup for test isolation
11//! - Comprehensive error handling for environment setup failures
12//!
13//! ## Context Lifecycle
14//!
15//! 1. **Setup** - Create temporary directories, SSH keys, clean templates
16//! 2. **Configuration** - Initialize services with test-specific settings
17//! 3. **Usage** - Provide services for test execution
18//! 4. **Cleanup** - Automatic cleanup via RAII (`TempDir`)
19//!
20//! The context ensures each test runs in isolation with its own
21//! temporary resources and configuration.
22
23use tempfile::TempDir;
24use tracing::{info, warn};
25
26use super::container::Services;
27use crate::config::Config;
28use crate::domain::environment::state::AnyEnvironmentState;
29use crate::domain::Environment;
30use crate::testing::e2e::LXD_OPENTOFU_SUBFOLDER;
31
32/// Errors that can occur during test context creation and initialization
33#[derive(Debug, thiserror::Error)]
34pub enum TestContextError {
35    /// Invalid template directory path
36    #[error("Templates directory cannot be empty")]
37    EmptyTemplatesDirectory,
38
39    /// Templates directory contains only whitespace
40    #[error("Templates directory cannot be empty or whitespace-only")]
41    WhitespaceOnlyTemplatesDirectory,
42
43    /// Failed to determine current directory
44    #[error("Failed to determine current directory (project root): {0}")]
45    CurrentDirectoryError(#[from] std::io::Error),
46
47    /// Failed to create temporary directory
48    #[error("Failed to create temporary directory for test context SSH keys: {source}")]
49    TempDirectoryCreationError { source: std::io::Error },
50
51    /// Failed to setup SSH keys
52    #[error("Failed to setup SSH keys for test context: {source}")]
53    SshKeySetupError { source: anyhow::Error },
54
55    /// Failed to prepare environment (templates, etc.)
56    #[error("Failed to clean and prepare templates directory: {source}")]
57    ContextPreparationError { source: anyhow::Error },
58}
59
60/// Type of test context indicating what infrastructure is used
61#[derive(Debug, Clone, Copy, PartialEq, Eq)]
62pub enum TestContextType {
63    /// Container-based testing using Docker containers via testcontainers crate.
64    /// No manual cleanup needed as containers are automatically destroyed.
65    Container,
66    /// Virtual machine-based testing using LXD VMs provisioned via `OpenTofu`.
67    /// Requires `OpenTofu` resource cleanup when the environment is dropped.
68    VirtualMachine,
69}
70
71/// Main test context combining configuration and services
72pub struct TestContext {
73    pub config: Config,
74    pub services: Services,
75    /// The complete environment configuration containing instance name, SSH keys, and paths.
76    /// Stored as `AnyEnvironmentState` to track the actual current state throughout the
77    /// deployment lifecycle (Created → Provisioning → Provisioned → Configuring → Configured, etc.)
78    pub environment: AnyEnvironmentState,
79    /// Whether to keep the deployment environment after completion.
80    ///
81    /// When `false`, the environment will be automatically cleaned up (destroyed)
82    /// after the test process completes. When `true`, the environment
83    /// will be left running for manual inspection or debugging.
84    pub keep_env: bool,
85    /// Temporary directory for SSH keys. Must be kept alive for the lifetime
86    /// of the test context to prevent cleanup of SSH key files.
87    /// This field is not directly read but must be retained for RAII cleanup.
88    temp_dir: Option<tempfile::TempDir>,
89    /// The type of test context, determining what cleanup is needed.
90    context_type: TestContextType,
91}
92
93impl TestContext {
94    /// Creates a new test environment with custom SSH user (private constructor)
95    ///
96    /// This method performs the setup including validation, SSH key setup,
97    /// and configuration creation, but does NOT initialize the environment.
98    /// Callers must explicitly call `.init()` to complete the setup.
99    ///
100    /// # Arguments
101    ///
102    /// * `keep_env` - Whether to keep the environment after tests complete
103    /// * `environment` - The Environment entity containing all necessary configuration
104    /// * `context_type` - The type of test environment (Container or `VirtualMachine`)
105    ///
106    /// # Errors
107    ///
108    /// Returns an error if:
109    /// - Input validation fails (empty or invalid templates directory)
110    /// - Current directory cannot be determined
111    /// - Temporary directory creation fails
112    /// - SSH key setup fails
113    fn new(
114        keep_env: bool,
115        environment: Environment,
116        context_type: TestContextType,
117    ) -> Result<Self, TestContextError> {
118        let templates_dir = environment.templates_dir();
119
120        Self::validate_inputs(&templates_dir)?;
121
122        let project_root = Self::get_project_root()?;
123        let temp_dir = Self::create_temp_directory()?;
124
125        let config = Config::new(
126            environment.templates_dir().clone(),
127            project_root,
128            environment.build_dir().clone(),
129        );
130
131        let services = Services::new(
132            &config,
133            environment.ssh_credentials().clone(),
134            environment.instance_name().clone(),
135            environment.provider_config().clone(),
136        );
137
138        let env = Self {
139            config,
140            services,
141            environment: environment.into_any(), // Convert to AnyEnvironmentState for runtime state tracking
142            keep_env,
143            temp_dir: Some(temp_dir),
144            context_type,
145        };
146
147        Ok(env)
148    }
149
150    /// Creates a new test environment from an Environment entity
151    ///
152    /// This method provides a simplified interface that accepts an Environment entity
153    /// containing all the necessary configuration, rather than individual parameters.
154    ///
155    /// **Important**: This method does NOT initialize the environment. You must call
156    /// `.init()` on the returned `TestContext` to complete the setup.
157    ///
158    /// # Arguments
159    ///
160    /// * `keep_env` - Whether to keep the environment after tests complete
161    /// * `environment` - The Environment entity containing instance name, SSH keys, and paths
162    /// * `context_type` - The type of test environment (Container or `VirtualMachine`)
163    ///
164    /// # Returns
165    ///
166    /// A `TestContext` that requires `.init()` to be called before use.
167    ///
168    /// # Errors
169    ///
170    /// Returns an error if:
171    /// - Input validation fails (empty or invalid templates directory)
172    /// - Current directory cannot be determined
173    /// - Temporary directory creation fails
174    /// - SSH key setup fails
175    ///
176    /// # Examples
177    ///
178    /// ```rust,no_run
179    /// use torrust_tracker_deployer_lib::domain::{Environment, EnvironmentName};
180    /// use torrust_tracker_deployer_lib::domain::provider::{LxdConfig, ProviderConfig};
181    /// use torrust_tracker_deployer_lib::domain::ProfileName;
182    /// use torrust_tracker_deployer_lib::shared::Username;
183    /// use torrust_tracker_deployer_lib::adapters::ssh::SshCredentials;
184    /// use torrust_tracker_deployer_lib::testing::e2e::context::{TestContext, TestContextType};
185    /// use std::path::PathBuf;
186    /// use tempfile::TempDir;
187    /// use chrono::{TimeZone, Utc};
188    ///
189    /// // Use temporary directory to avoid creating real directories
190    /// let temp_dir = TempDir::new()?;
191    /// let temp_path = temp_dir.path();
192    ///
193    /// let env_name = EnvironmentName::new("test-example".to_string())?;
194    /// let ssh_username = Username::new("torrust".to_string())?;
195    /// let ssh_credentials = SshCredentials::new(
196    ///     temp_path.join("testing_rsa"),
197    ///     temp_path.join("testing_rsa.pub"),
198    ///     ssh_username,
199    /// );
200    /// let provider_config = ProviderConfig::Lxd(LxdConfig {
201    ///     profile_name: ProfileName::new(format!("lxd-{}", env_name.as_str())).unwrap(),
202    /// });
203    /// let created_at = Utc.with_ymd_and_hms(2025, 1, 1, 0, 0, 0).unwrap();
204    /// let environment = Environment::new(env_name, provider_config, ssh_credentials, 22, created_at);
205    ///
206    /// let test_context = TestContext::from_environment(
207    ///     false,
208    ///     environment,
209    ///     TestContextType::Container,
210    /// )?.init()?;
211    ///
212    /// # Ok::<(), Box<dyn std::error::Error>>(())
213    /// ```
214    pub fn from_environment(
215        keep_env: bool,
216        environment: Environment,
217        context_type: TestContextType,
218    ) -> Result<Self, TestContextError> {
219        Self::new(keep_env, environment, context_type)
220    }
221
222    /// Initializes the test environment by preparing templates and logging setup
223    ///
224    /// This method performs the final environment setup with side effects.
225    /// It must be called explicitly after creating a `TestContext` to complete the setup.
226    ///
227    /// # Errors
228    ///
229    /// Returns an error if:
230    /// - Template preparation fails
231    /// - Environment persistence fails
232    pub fn init(self) -> Result<Self, TestContextError> {
233        Self::prepare_environment(&self.services)?;
234        self.persist_initial_environment_state()?;
235        self.log_environment_info();
236        Ok(self)
237    }
238
239    /// Validates input parameters
240    fn validate_inputs(templates_dir: &std::path::Path) -> Result<(), TestContextError> {
241        if templates_dir.as_os_str().is_empty() {
242            return Err(TestContextError::EmptyTemplatesDirectory);
243        }
244
245        // Check if the path string representation is only whitespace
246        if let Some(dir_str) = templates_dir.to_str() {
247            if dir_str.trim().is_empty() {
248                return Err(TestContextError::WhitespaceOnlyTemplatesDirectory);
249            }
250        }
251
252        Ok(())
253    }
254
255    /// Gets the current project root directory
256    fn get_project_root() -> Result<std::path::PathBuf, TestContextError> {
257        std::env::current_dir().map_err(TestContextError::CurrentDirectoryError)
258    }
259
260    /// Creates a temporary directory for SSH keys
261    fn create_temp_directory() -> Result<TempDir, TestContextError> {
262        TempDir::new().map_err(|e| TestContextError::TempDirectoryCreationError { source: e })
263    }
264
265    /// Prepares the test environment (templates, etc.)
266    fn prepare_environment(services: &Services) -> Result<(), TestContextError> {
267        info!(
268            operation = "clean_templates",
269            "Cleaning templates directory to ensure fresh embedded templates"
270        );
271
272        services
273            .template_manager
274            .reset_templates_dir()
275            .map_err(|e| TestContextError::ContextPreparationError {
276                source: anyhow::anyhow!(e),
277            })?;
278        Ok(())
279    }
280
281    /// Persists the environment immediately after creation
282    ///
283    /// This method ensures the environment is saved in its initial Created state
284    /// before any commands are executed that might change the state.
285    ///
286    /// # Errors
287    ///
288    /// Returns an error if environment persistence fails
289    fn persist_initial_environment_state(&self) -> Result<(), TestContextError> {
290        let repository = self.create_repository();
291        info!(
292            environment = %self.environment.name(),
293            state = %self.environment.state_name(),
294            "Persisting initial environment state"
295        );
296        repository
297            .save(&self.environment)
298            .map_err(|e| TestContextError::ContextPreparationError {
299                source: anyhow::anyhow!("Failed to persist initial environment state: {e}"),
300            })
301    }
302
303    /// Logs environment information
304    fn log_environment_info(&self) {
305        // Warn if keep_env is enabled with Container environment type
306        if self.keep_env && self.context_type == TestContextType::Container {
307            warn!(
308                environment_type = "container",
309                keep_env = true,
310                "keep_env flag is enabled but Container environments are automatically destroyed by testcontainers - the flag will be ignored"
311            );
312            // TODO: Investigate if testcontainers crate supports keeping containers alive after test completion
313            // This would require exploring testcontainers configuration options or lifecycle management
314        }
315
316        if let Some(temp_path) = self.temp_dir_path() {
317            info!(
318                environment = "temporary_directory",
319                path = %temp_path.display(),
320                "Temporary directory created"
321            );
322        }
323
324        info!(
325            environment = "templates_directory",
326            path = %self.services.template_manager.templates_dir().display(),
327            "Templates directory configured"
328        );
329
330        // Log the temp directory path to demonstrate the field is used
331        if let Some(temp_path) = self.temp_dir_path() {
332            info!(
333                temp_dir_path = %temp_path.display(),
334                "Test context initialized with temporary directory"
335            );
336        }
337    }
338
339    /// Gets the temporary directory path for logging or debugging purposes
340    #[must_use]
341    pub fn temp_dir_path(&self) -> Option<&std::path::Path> {
342        self.temp_dir.as_ref().map(tempfile::TempDir::path)
343    }
344
345    /// Updates the test context environment from a provisioned environment
346    ///
347    /// This method updates the internal environment state after provisioning
348    /// completes, ensuring the `TestContext` maintains the latest and accurate environment state.
349    ///
350    /// # Arguments
351    ///
352    /// * `provisioned_env` - The provisioned environment returned by `ProvisionCommandHandler`
353    ///
354    /// # Examples
355    ///
356    /// ```rust,no_run
357    /// # use torrust_tracker_deployer_lib::testing::e2e::context::TestContext;
358    /// # use torrust_tracker_deployer_lib::domain::Environment;
359    /// # fn example(test_context: &mut TestContext, provisioned_env: Environment<torrust_tracker_deployer_lib::domain::environment::Provisioned>) {
360    /// // After provisioning succeeds, update the test context
361    /// test_context.update_from_provisioned(provisioned_env);
362    /// # }
363    /// ```
364    pub fn update_from_provisioned(
365        &mut self,
366        provisioned_env: crate::domain::Environment<crate::domain::environment::Provisioned>,
367    ) {
368        // Replace the environment with the provisioned state using type erasure
369        // This properly represents the actual state (Provisioned) rather than keeping it in Created state
370        self.environment = provisioned_env.into_any();
371    }
372
373    /// Updates the test context environment from a configured environment
374    ///
375    /// This method updates the internal environment state after configuration
376    /// completes, ensuring the `TestContext` maintains the latest and accurate environment state.
377    ///
378    /// # Arguments
379    ///
380    /// * `configured_env` - The configured environment returned by `ConfigureCommandHandler`
381    ///
382    /// # Examples
383    ///
384    /// ```rust,no_run
385    /// # use torrust_tracker_deployer_lib::testing::e2e::context::TestContext;
386    /// # use torrust_tracker_deployer_lib::domain::Environment;
387    /// # fn example(test_context: &mut TestContext, configured_env: Environment<torrust_tracker_deployer_lib::domain::environment::Configured>) {
388    /// // After configuration succeeds, update the test context
389    /// test_context.update_from_configured(configured_env);
390    /// # }
391    /// ```
392    pub fn update_from_configured(
393        &mut self,
394        configured_env: crate::domain::Environment<crate::domain::environment::Configured>,
395    ) {
396        // Replace the environment with the configured state using type erasure
397        // This properly represents the actual state (Configured) rather than keeping it in Created state
398        self.environment = configured_env.into_any();
399    }
400
401    /// Updates the test context environment from a destroyed environment
402    ///
403    /// This method updates the internal environment state after destruction
404    /// completes, ensuring the `TestContext` maintains the latest and accurate environment state.
405    ///
406    /// # Arguments
407    ///
408    /// * `destroyed_env` - The destroyed environment returned by `DestroyCommandHandler`
409    ///
410    /// # Examples
411    ///
412    /// ```rust,no_run
413    /// # use torrust_tracker_deployer_lib::testing::e2e::context::TestContext;
414    /// # use torrust_tracker_deployer_lib::domain::Environment;
415    /// # fn example(test_context: &mut TestContext, destroyed_env: Environment<torrust_tracker_deployer_lib::domain::environment::Destroyed>) {
416    /// // After destruction succeeds, update the test context
417    /// test_context.update_from_destroyed(destroyed_env);
418    /// # }
419    /// ```
420    pub fn update_from_destroyed(
421        &mut self,
422        destroyed_env: crate::domain::Environment<crate::domain::environment::Destroyed>,
423    ) {
424        // Replace the environment with the destroyed state using type erasure
425        // This properly represents the actual state (Destroyed) rather than keeping it in previous state
426        self.environment = destroyed_env.into_any();
427    }
428
429    /// Creates a repository for the current environment
430    ///
431    /// This is a convenience method that creates an `EnvironmentRepository`
432    /// configured for this test context's environment. The repository is
433    /// created using the repository factory with the environment's data directory.
434    ///
435    /// # Returns
436    ///
437    /// An `Arc<dyn EnvironmentRepository>` that can be used to persist and load
438    /// environment state for this test context.
439    ///
440    /// # Examples
441    ///
442    /// ```rust,no_run
443    /// # use torrust_tracker_deployer_lib::testing::e2e::context::TestContext;
444    /// # fn example(test_context: &TestContext) {
445    /// let repository = test_context.create_repository();
446    /// // Use repository for state persistence...
447    /// # }
448    /// ```
449    #[must_use]
450    pub fn create_repository(
451        &self,
452    ) -> std::sync::Arc<dyn crate::domain::environment::repository::EnvironmentRepository> {
453        // Pass the parent "data" directory, not the environment-specific directory
454        // The repository will add the environment name subdirectory automatically
455        // e.g., "{project_root}/data" + "e2e-provision" = "{project_root}/data/e2e-provision/environment.json"
456        let base_data_dir = self.config.project_root.join("data");
457        self.services.file_repository_factory.create(base_data_dir)
458    }
459}
460
461impl std::fmt::Debug for TestContext {
462    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
463        f.debug_struct("TestContext")
464            .field("keep_env", &self.keep_env)
465            .field("templates_dir", &self.config.templates_dir)
466            .field("project_root", &self.config.project_root)
467            .field("build_dir", &self.config.build_dir)
468            .field("has_temp_dir", &self.temp_dir.is_some())
469            .finish_non_exhaustive() // Services field is complex and not needed for debugging
470    }
471}
472
473impl Drop for TestContext {
474    fn drop(&mut self) {
475        if !self.keep_env {
476            // Only cleanup OpenTofu resources for VirtualMachine environments
477            // Container environments use Docker/testcontainers which handle their own cleanup
478            match self.context_type {
479                TestContextType::VirtualMachine => {
480                    // Skip cleanup if infrastructure already destroyed
481                    // This prevents unnecessary cleanup attempts when DestroyCommand already cleaned up
482                    if matches!(self.environment, AnyEnvironmentState::Destroyed(_)) {
483                        return;
484                    }
485
486                    // Try basic cleanup in case async cleanup failed
487                    // Using emergency_destroy for consistent OpenTofu handling
488                    let tofu_dir = self.config.build_dir.join(LXD_OPENTOFU_SUBFOLDER);
489
490                    if let Err(e) = crate::adapters::tofu::emergency_destroy(&tofu_dir) {
491                        eprintln!("Warning: Failed to cleanup OpenTofu resources during TestContext drop: {e}");
492                    }
493                }
494                TestContextType::Container => {
495                    // Container environments are managed by testcontainers
496                    // No OpenTofu cleanup needed
497                }
498            }
499        }
500    }
501}