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}