torrust_tracker_deployer_lib/domain/environment/mod.rs
1//! Environment Domain Module
2//!
3//! This module contains all environment-related domain entities and types.
4//!
5//! ## Architecture: Context + State Design
6//!
7//! The `Environment` entity uses a two-part design to separate immutable identity
8//! from mutable lifecycle state:
9//!
10//! ### `EnvironmentContext` - Three Semantic Categories
11//!
12//! The context is organized into three distinct semantic types, each with a clear purpose:
13//!
14//! #### 1. **User Inputs** (`UserInputs`)
15//! - **Purpose**: Configuration provided when creating an environment
16//! - **Characteristics**: Immutable throughout environment lifecycle
17//! - **Fields**: `name`, `instance_name`, `profile_name`, `ssh_credentials`, `ssh_port`
18//! - **When to add**: User needs to configure something at creation time
19//!
20//! #### 2. **Internal Config** (`InternalConfig`)
21//! - **Purpose**: Derived configuration for internal use
22//! - **Characteristics**: Calculated from user inputs
23//! - **Fields**: `build_dir`, `data_dir`
24//! - **When to add**: Need internal paths or derived configuration
25//!
26//! #### 3. **Runtime Outputs** (`RuntimeOutputs`)
27//! - **Purpose**: Data generated during deployment operations
28//! - **Characteristics**: Mutable as operations progress
29//! - **Fields**: `instance_ip` (more fields expected as deployment evolves)
30//! - **When to add**: Operations produce new data about deployed infrastructure
31//!
32//! ### `state: S` - Mutable Lifecycle State
33//!
34//! Tracks the current phase in the deployment lifecycle using the type-state pattern:
35//! - **Success states**: `Created`, `Provisioning`, `Provisioned`, `Configuring`, etc.
36//! - **Error states**: `ProvisionFailed`, `ConfigureFailed`, etc.
37//!
38//! ### Benefits of This Design
39//!
40//! - **Compile-time safety**: Invalid state transitions caught at compile time
41//! - **Reduced pattern matching**: Access common fields without matching on state (83% reduction)
42//! - **Clear separation**: Identity vs. lifecycle are distinct concerns
43//! - **Semantic clarity**: Types document the purpose of each field
44//! - **Developer guidance**: Clear where to add new fields based on their purpose
45//! - **Easy extension**: Adding fields or states is straightforward
46//!
47//! ## Submodules
48//!
49//! - `context` - Environment context composing the three semantic types
50//! - `user_inputs` - User-provided configuration
51//! - `internal_config` - Derived paths and internal settings
52//! - `runtime_outputs` - Data generated during deployment
53//! - `name` - Environment name validation and management
54//! - `state` - State marker types and type erasure for environment state machine
55//!
56//! ## Main Entity
57//!
58//! The `Environment` entity encapsulates all environment-specific configuration for deployments.
59//! Each environment represents an isolated deployment context with its own directories,
60//! SSH keys, and instance naming.
61//!
62//! ## Purpose
63//!
64//! The Environment entity provides:
65//! - Environment-specific directory structure (`data/{env_name}/`, `build/{env_name}/`)
66//! - Instance naming with conflict avoidance (`torrust-tracker-vm-{env_name}`)
67//! - SSH key pair management per environment
68//! - JSON serialization for future state persistence
69//!
70//! ## Usage Example
71//!
72//! ```rust
73//! use torrust_tracker_deployer_lib::domain::environment::{Environment, name::EnvironmentName};
74//! use torrust_tracker_deployer_lib::domain::provider::{LxdConfig, ProviderConfig};
75//! use torrust_tracker_deployer_lib::domain::ProfileName;
76//! use torrust_tracker_deployer_lib::shared::Username;
77//! use torrust_tracker_deployer_lib::adapters::ssh::SshCredentials;
78//! use std::path::PathBuf;
79//! use chrono::{TimeZone, Utc};
80//!
81//! let env_name = EnvironmentName::new("e2e-config".to_string())?;
82//! let ssh_username = Username::new("torrust".to_string())?;
83//! let ssh_credentials = SshCredentials::new(
84//! PathBuf::from("fixtures/testing_rsa"),
85//! PathBuf::from("fixtures/testing_rsa.pub"),
86//! ssh_username,
87//! );
88//! let provider_config = ProviderConfig::Lxd(LxdConfig {
89//! profile_name: ProfileName::new(format!("lxd-{}", env_name.as_str())).unwrap(),
90//! });
91//! let created_at = Utc.with_ymd_and_hms(2025, 1, 1, 0, 0, 0).unwrap();
92//! let environment = Environment::new(env_name, provider_config, ssh_credentials, 22, created_at);
93//!
94//! // Environment automatically generates paths
95//! assert_eq!(*environment.data_dir(), PathBuf::from("./data/e2e-config"));
96//! assert_eq!(*environment.build_dir(), PathBuf::from("./build/e2e-config"));
97//! assert_eq!(environment.templates_dir(), PathBuf::from("./data/e2e-config/templates"));
98//!
99//! # Ok::<(), Box<dyn std::error::Error>>(())
100//! ```
101
102pub mod context;
103pub mod internal_config;
104pub mod name;
105pub mod params;
106pub mod repository;
107pub mod runtime_outputs;
108pub mod state;
109mod trace_id;
110pub mod user_inputs;
111
112// Test utilities (only available in test configuration)
113#[cfg(test)]
114pub mod testing;
115
116// Re-export TraceId for use by state module
117pub use trace_id::TraceId;
118
119// Re-export commonly used types for convenience
120pub use context::EnvironmentContext;
121pub use internal_config::InternalConfig;
122pub use name::{EnvironmentName, EnvironmentNameError};
123pub use params::EnvironmentParams;
124pub use runtime_outputs::{ProvisionMethod, RuntimeOutputs};
125pub use state::{
126 AnyEnvironmentState, ConfigureFailed, Configured, Configuring, Created, DestroyFailed,
127 Destroyed, Destroying, ProvisionFailed, Provisioned, Provisioning, ReleaseFailed, Released,
128 Releasing, RunFailed, Running,
129};
130pub use user_inputs::{UserInputs, UserInputsError};
131
132// Re-export tracker types for convenience
133pub use crate::domain::tracker::{
134 DatabaseConfig, HealthCheckApiConfig, HttpApiConfig, HttpTrackerConfig, MysqlConfig,
135 SqliteConfig, TrackerConfig, TrackerCoreConfig, UdpTrackerConfig,
136};
137
138// Re-export Prometheus types for convenience
139pub use crate::domain::prometheus::PrometheusConfig;
140
141// Re-export Grafana types for convenience
142pub use crate::domain::grafana::GrafanaConfig;
143
144// Re-export Backup types for convenience
145pub use crate::domain::backup::BackupConfig;
146
147use crate::adapters::ssh::SshCredentials;
148use crate::domain::provider::ProviderConfig;
149use crate::domain::{InstanceName, ProfileName};
150use crate::shared::Username;
151use chrono::{DateTime, Utc};
152use serde::{Deserialize, Serialize};
153use std::net::IpAddr;
154use std::path::PathBuf;
155
156/// Directory name for trace files within an environment's data directory
157pub const TRACES_DIR_NAME: &str = "traces";
158
159/// Directory name for template files within an environment's data directory
160pub const TEMPLATES_DIR_NAME: &str = "templates";
161
162/// Directory name for Ansible-related files
163pub const ANSIBLE_DIR_NAME: &str = "ansible";
164
165/// Directory name for OpenTofu-related files
166pub const TOFU_DIR_NAME: &str = "tofu";
167
168/// Provider name for LXD infrastructure
169pub const LXD_PROVIDER_NAME: &str = "lxd";
170
171/// Environment configuration encapsulating all environment-specific settings
172///
173/// This entity represents a complete environment configuration including naming,
174/// directory structure, SSH keys, and derived paths. It follows the principle of
175/// environment isolation where each environment has its own separate resources.
176///
177/// # Architecture: Context + State Design
178///
179/// The `Environment<S>` is composed of two distinct parts:
180///
181/// ## `context: EnvironmentContext` - Immutable Identity
182///
183/// Contains all state-independent data that remains constant throughout the
184/// environment's lifecycle. This includes identity (`name`, `instance_name`),
185/// configuration (SSH credentials, port), and paths (`build_dir`, `data_dir`).
186///
187/// Accessing context data is efficient and requires no pattern matching on state.
188///
189/// ## `state: S` - Mutable Lifecycle State
190///
191/// Represents the current phase in the deployment lifecycle using type parameters.
192/// The type-state pattern ensures that state transitions are validated at compile-time.
193///
194/// # Type-State Pattern
195///
196/// The Environment uses the type-state pattern to enforce valid state transitions
197/// at compile-time. Each state is represented by a distinct type parameter `S`,
198/// ensuring that operations are only callable on appropriate states.
199///
200/// # Design Principles
201///
202/// - **Isolation**: Each environment is completely isolated from others
203/// - **Compile-time Safety**: Invalid state transitions caught during compilation
204/// - **Separation of Concerns**: Context (identity) vs. State (lifecycle) are distinct
205/// - **Consistency**: All paths follow the same naming pattern
206/// - **Predictability**: Paths are derived automatically from environment name
207/// - **Traceability**: All artifacts are organized by environment for debugging
208/// - **Type Safety**: Invalid state transitions are prevented at compile-time
209///
210/// # Directory Structure
211///
212/// ```text
213/// data/{env_name}/
214/// templates/ # Environment-specific templates
215/// build/{env_name}/ # Environment-specific build artifacts
216/// ```
217///
218/// # Instance Naming
219///
220/// Instance names follow the pattern: `torrust-tracker-vm-{env_name}`
221/// This ensures multiple environments can run concurrently without conflicts.
222#[derive(Debug, Clone, Serialize, Deserialize)]
223pub struct Environment<S = Created> {
224 /// Core environment data shared across all states
225 context: EnvironmentContext,
226
227 /// Current state of the environment in the deployment lifecycle
228 state: S,
229}
230
231impl Environment {
232 /// Creates a new Environment with auto-generated paths and instance name
233 ///
234 /// # Arguments
235 ///
236 /// * `name` - The validated environment name
237 /// * `provider_config` - Provider-specific configuration (LXD, Hetzner, etc.)
238 /// * `ssh_credentials` - SSH credentials for connecting to instances
239 /// * `ssh_port` - SSH port for connecting to instances
240 ///
241 /// # Returns
242 ///
243 /// A new Environment instance with all paths and instance name automatically
244 /// generated based on the environment name.
245 ///
246 /// # Examples
247 ///
248 /// ```rust
249 /// use torrust_tracker_deployer_lib::domain::{Environment, EnvironmentName, ProfileName};
250 /// use torrust_tracker_deployer_lib::domain::provider::{ProviderConfig, LxdConfig};
251 /// use torrust_tracker_deployer_lib::shared::Username;
252 /// use torrust_tracker_deployer_lib::adapters::ssh::SshCredentials;
253 /// use std::path::PathBuf;
254 /// use chrono::{TimeZone, Utc};
255 ///
256 /// let env_name = EnvironmentName::new("production".to_string())?;
257 /// let ssh_username = Username::new("torrust".to_string())?;
258 /// let ssh_credentials = SshCredentials::new(
259 /// PathBuf::from("keys/prod_rsa"),
260 /// PathBuf::from("keys/prod_rsa.pub"),
261 /// ssh_username,
262 /// );
263 /// let provider_config = ProviderConfig::Lxd(LxdConfig {
264 /// profile_name: ProfileName::new("torrust-profile-production".to_string())?,
265 /// });
266 /// let ssh_port = 22;
267 /// let created_at = Utc.with_ymd_and_hms(2025, 1, 1, 0, 0, 0).unwrap();
268 /// let environment = Environment::new(env_name, provider_config, ssh_credentials, ssh_port, created_at);
269 ///
270 /// assert_eq!(environment.instance_name().as_str(), "torrust-tracker-vm-production");
271 /// assert_eq!(*environment.data_dir(), PathBuf::from("./data/production"));
272 /// assert_eq!(*environment.build_dir(), PathBuf::from("./build/production"));
273 ///
274 /// # Ok::<(), Box<dyn std::error::Error>>(())
275 /// ```
276 ///
277 /// # Panics
278 ///
279 /// This function does not panic. All instance name generation is guaranteed
280 /// to succeed for valid environment names.
281 #[must_use]
282 #[allow(clippy::needless_pass_by_value)] // Public API takes ownership for ergonomics
283 pub fn new(
284 name: EnvironmentName,
285 provider_config: ProviderConfig,
286 ssh_credentials: SshCredentials,
287 ssh_port: u16,
288 created_at: DateTime<Utc>,
289 ) -> Environment<Created> {
290 let context = EnvironmentContext::new(
291 &name,
292 provider_config,
293 ssh_credentials,
294 ssh_port,
295 created_at,
296 );
297
298 Environment {
299 context,
300 state: Created,
301 }
302 }
303
304 /// Creates a new environment in Created state from validated parameters
305 ///
306 /// This is the primary factory method for creating a fully-configured
307 /// `Environment` aggregate. It accepts an `EnvironmentParams` value object
308 /// containing all validated domain inputs.
309 ///
310 /// This creates absolute paths for data and build directories by using the
311 /// provided working directory as the base.
312 ///
313 /// # Arguments
314 ///
315 /// * `params` - Validated environment parameters (domain value object)
316 /// * `working_dir` - Base directory for data and build directories
317 /// * `created_at` - Timestamp for environment creation
318 ///
319 /// # Errors
320 ///
321 /// Returns `UserInputsError` if the cross-service configuration is invalid:
322 /// - `GrafanaRequiresPrometheus`: Grafana is configured but Prometheus is not
323 /// - `HttpsSectionWithoutTlsServices`: HTTPS section exists but no service uses TLS
324 /// - `TlsServicesWithoutHttpsSection`: Service has TLS but HTTPS section is missing
325 #[allow(clippy::needless_pass_by_value)] // Public API takes ownership for ergonomics
326 pub fn create(
327 params: EnvironmentParams,
328 working_dir: &std::path::Path,
329 created_at: DateTime<Utc>,
330 ) -> Result<Environment<Created>, UserInputsError> {
331 let context = EnvironmentContext::create(params, working_dir, created_at)?;
332
333 Ok(Environment {
334 context,
335 state: Created,
336 })
337 }
338}
339
340// Common transitions available from any state
341impl<S> Environment<S> {
342 /// Internal helper: Creates a new environment with a different state
343 ///
344 /// This is a private helper method used by state transition methods to avoid
345 /// duplicating field copying code. It transfers all environment data while
346 /// changing only the state type parameter.
347 ///
348 /// This method automatically logs all state transitions at info level with
349 /// structured fields for observability and audit trail purposes.
350 ///
351 /// # Type Parameters
352 ///
353 /// * `T` - The target state type
354 ///
355 /// # Arguments
356 ///
357 /// * `new_state` - The new state instance to transition to
358 ///
359 /// # Returns
360 ///
361 /// A new `Environment<T>` with all fields preserved except the state
362 fn with_state<T>(self, new_state: T) -> Environment<T> {
363 // Log state transition for observability and audit trail
364 tracing::info!(
365 environment_name = %self.context.user_inputs.name(),
366 instance_name = %self.context.user_inputs.instance_name(),
367 from_state = std::any::type_name::<S>(),
368 to_state = std::any::type_name::<T>(),
369 "Environment state transition"
370 );
371
372 Environment {
373 context: self.context,
374 state: new_state,
375 }
376 }
377
378 /// Transitions from any state to Destroying state
379 ///
380 /// This method can be called from any state to begin the environment destruction process.
381 /// It indicates that the destroy command has started executing.
382 #[must_use]
383 pub fn start_destroying(self) -> Environment<Destroying> {
384 self.with_state(Destroying)
385 }
386
387 /// Transitions from any state to Destroyed state
388 ///
389 /// This method can be called from any state to destroy the environment.
390 /// It indicates that all infrastructure resources have been released.
391 #[must_use]
392 pub fn destroy(self) -> Environment<Destroyed> {
393 self.with_state(Destroyed)
394 }
395}
396
397// Type Erasure: Typed → Runtime conversions (into_any)
398// Generic implementations for all states
399impl<S> Environment<S> {
400 /// Get a reference to the environment context
401 ///
402 /// Provides access to all state-independent environment data.
403 #[must_use]
404 pub fn context(&self) -> &EnvironmentContext {
405 &self.context
406 }
407
408 /// Get a mutable reference to the environment context
409 ///
410 /// Used for operations that need to modify context data, such as
411 /// setting the instance IP after provisioning.
412 fn context_mut(&mut self) -> &mut EnvironmentContext {
413 &mut self.context
414 }
415
416 /// Returns a reference to the current state
417 #[must_use]
418 pub fn state(&self) -> &S {
419 &self.state
420 }
421
422 /// Returns the environment name
423 #[must_use]
424 pub fn name(&self) -> &EnvironmentName {
425 self.context.user_inputs.name()
426 }
427
428 /// Returns the instance name for this environment
429 #[must_use]
430 pub fn instance_name(&self) -> &InstanceName {
431 self.context.instance_name()
432 }
433
434 /// Returns the provider configuration for this environment
435 #[must_use]
436 pub fn provider_config(&self) -> &ProviderConfig {
437 self.context.provider_config()
438 }
439
440 /// Returns the SSH credentials for this environment
441 #[must_use]
442 pub fn ssh_credentials(&self) -> &SshCredentials {
443 self.context.ssh_credentials()
444 }
445
446 /// Returns the SSH port for this environment
447 #[must_use]
448 pub fn ssh_port(&self) -> u16 {
449 self.context.ssh_port()
450 }
451
452 /// Returns the database configuration for this environment
453 #[must_use]
454 pub fn database_config(&self) -> &DatabaseConfig {
455 self.context.database_config()
456 }
457
458 /// Returns the tracker configuration for this environment
459 #[must_use]
460 pub fn tracker_config(&self) -> &TrackerConfig {
461 self.context.tracker_config()
462 }
463
464 /// Returns the admin token for the HTTP API
465 #[must_use]
466 pub fn admin_token(&self) -> &str {
467 self.context.admin_token()
468 }
469
470 /// Returns the Prometheus configuration if enabled
471 #[must_use]
472 pub fn prometheus_config(&self) -> Option<&PrometheusConfig> {
473 self.context.prometheus_config()
474 }
475
476 /// Returns the Grafana configuration if enabled
477 #[must_use]
478 pub fn grafana_config(&self) -> Option<&GrafanaConfig> {
479 self.context.grafana_config()
480 }
481
482 /// Returns the Backup configuration if enabled
483 #[must_use]
484 pub fn backup_config(&self) -> Option<&BackupConfig> {
485 self.context.backup_config()
486 }
487
488 /// Returns the SSH username for this environment
489 #[must_use]
490 pub fn ssh_username(&self) -> &Username {
491 self.context.ssh_username()
492 }
493
494 /// Returns the SSH private key path for this environment
495 #[must_use]
496 pub fn ssh_private_key_path(&self) -> &PathBuf {
497 self.context.ssh_private_key_path()
498 }
499
500 /// Returns the SSH public key path for this environment
501 #[must_use]
502 pub fn ssh_public_key_path(&self) -> &PathBuf {
503 self.context.ssh_public_key_path()
504 }
505
506 /// Returns the build directory for this environment
507 #[must_use]
508 pub fn build_dir(&self) -> &PathBuf {
509 self.context.build_dir()
510 }
511
512 /// Returns the data directory for this environment
513 #[must_use]
514 pub fn data_dir(&self) -> &PathBuf {
515 self.context.data_dir()
516 }
517
518 /// Returns the instance IP address if available
519 ///
520 /// The instance IP is populated after successful provisioning and is
521 /// `None` for environments that haven't been provisioned yet.
522 ///
523 /// # Returns
524 ///
525 /// - `Some(IpAddr)` if the environment has been provisioned
526 /// - `None` if the environment hasn't been provisioned yet
527 ///
528 /// # Examples
529 ///
530 /// ```rust
531 /// use torrust_tracker_deployer_lib::domain::{Environment, EnvironmentName};
532 /// use torrust_tracker_deployer_lib::domain::provider::{LxdConfig, ProviderConfig};
533 /// use torrust_tracker_deployer_lib::domain::ProfileName;
534 /// use torrust_tracker_deployer_lib::shared::Username;
535 /// use torrust_tracker_deployer_lib::adapters::ssh::SshCredentials;
536 /// use std::path::PathBuf;
537 /// use std::net::{IpAddr, Ipv4Addr};
538 /// use chrono::{TimeZone, Utc};
539 ///
540 /// let env_name = EnvironmentName::new("test".to_string())?;
541 /// let ssh_username = Username::new("torrust".to_string())?;
542 /// let ssh_credentials = SshCredentials::new(
543 /// PathBuf::from("keys/test_rsa"),
544 /// PathBuf::from("keys/test_rsa.pub"),
545 /// ssh_username,
546 /// );
547 /// let provider_config = ProviderConfig::Lxd(LxdConfig {
548 /// profile_name: ProfileName::new(format!("lxd-{}", env_name.as_str())).unwrap(),
549 /// });
550 /// let created_at = Utc.with_ymd_and_hms(2025, 1, 1, 0, 0, 0).unwrap();
551 /// let environment = Environment::new(env_name, provider_config, ssh_credentials, 22, created_at);
552 ///
553 /// // Before provisioning
554 /// assert_eq!(environment.instance_ip(), None);
555 ///
556 /// // After provisioning (simulated)
557 /// let ip = IpAddr::V4(Ipv4Addr::new(192, 168, 1, 100));
558 /// let environment = environment.with_instance_ip(ip);
559 /// assert_eq!(environment.instance_ip(), Some(ip));
560 ///
561 /// # Ok::<(), Box<dyn std::error::Error>>(())
562 /// ```
563 #[must_use]
564 pub fn instance_ip(&self) -> Option<IpAddr> {
565 self.context.instance_ip()
566 }
567
568 /// Returns when this environment was created
569 ///
570 /// This timestamp is set when the environment is first created using the
571 /// `create environment` command and never changes throughout the
572 /// environment's lifecycle.
573 ///
574 /// # Returns
575 ///
576 /// The UTC timestamp when the environment was created.
577 #[must_use]
578 pub fn created_at(&self) -> DateTime<Utc> {
579 self.context.created_at()
580 }
581
582 /// Returns the provision method for this environment
583 ///
584 /// This method indicates how the infrastructure was provisioned:
585 /// - `Some(Provisioned)`: Created via `provision` command using `OpenTofu`
586 /// - `Some(Registered)`: Connected to existing infrastructure via `register` command
587 /// - `None`: Unknown or legacy state (before this field was added)
588 ///
589 /// # Returns
590 ///
591 /// The provision method, if set.
592 #[must_use]
593 pub fn provision_method(&self) -> Option<ProvisionMethod> {
594 self.context.provision_method()
595 }
596
597 /// Returns whether this environment's infrastructure is managed by this tool
598 ///
599 /// Infrastructure is considered "managed" if it was created via the `provision` command
600 /// using `OpenTofu`. Managed infrastructure can be destroyed using `tofu destroy`.
601 ///
602 /// Infrastructure is NOT managed if:
603 /// - It was registered from existing infrastructure via the `register` command
604 /// - The provision method is unknown (legacy state)
605 ///
606 /// # Returns
607 ///
608 /// `true` if the infrastructure was provisioned by this tool and can be destroyed,
609 /// `false` if the infrastructure is external and should not be touched.
610 ///
611 /// # Examples
612 ///
613 /// ```rust
614 /// use torrust_tracker_deployer_lib::domain::{Environment, EnvironmentName};
615 /// use torrust_tracker_deployer_lib::domain::environment::runtime_outputs::ProvisionMethod;
616 /// use torrust_tracker_deployer_lib::domain::provider::{LxdConfig, ProviderConfig};
617 /// use torrust_tracker_deployer_lib::domain::ProfileName;
618 /// use torrust_tracker_deployer_lib::shared::Username;
619 /// use torrust_tracker_deployer_lib::adapters::ssh::SshCredentials;
620 /// use std::path::PathBuf;
621 /// use chrono::{TimeZone, Utc};
622 ///
623 /// let env_name = EnvironmentName::new("production".to_string())?;
624 /// let ssh_username = Username::new("torrust".to_string())?;
625 /// let ssh_credentials = SshCredentials::new(
626 /// PathBuf::from("keys/prod_rsa"),
627 /// PathBuf::from("keys/prod_rsa.pub"),
628 /// ssh_username,
629 /// );
630 /// let provider_config = ProviderConfig::Lxd(LxdConfig {
631 /// profile_name: ProfileName::new(format!("lxd-{}", env_name.as_str())).unwrap(),
632 /// });
633 ///
634 /// let created_at = Utc.with_ymd_and_hms(2025, 1, 1, 0, 0, 0).unwrap();
635 /// // Provisioned environment - infrastructure is managed
636 /// let provisioned_env = Environment::new(env_name.clone(), provider_config.clone(), ssh_credentials.clone(), 22, created_at)
637 /// .with_provision_method(ProvisionMethod::Provisioned);
638 /// assert!(provisioned_env.is_infrastructure_managed());
639 ///
640 /// // Registered environment - infrastructure is NOT managed
641 /// let registered_env = Environment::new(env_name, provider_config, ssh_credentials, 22, created_at)
642 /// .with_provision_method(ProvisionMethod::Registered);
643 /// assert!(!registered_env.is_infrastructure_managed());
644 ///
645 /// # Ok::<(), Box<dyn std::error::Error>>(())
646 /// ```
647 #[must_use]
648 pub fn is_infrastructure_managed(&self) -> bool {
649 // Only infrastructure provisioned by this tool can be managed/destroyed
650 // Registered environments have external infrastructure we don't control
651 match self.provision_method() {
652 Some(ProvisionMethod::Registered) => false,
653 // Provisioned or legacy (None) environments are assumed managed
654 Some(ProvisionMethod::Provisioned) | None => true,
655 }
656 }
657
658 /// Sets the instance IP address for this environment
659 ///
660 /// This method is typically called by the `ProvisionCommandHandler` after successfully
661 /// provisioning the infrastructure and obtaining the instance's IP address.
662 ///
663 /// # Arguments
664 ///
665 /// * `ip` - The IP address of the provisioned instance
666 ///
667 /// # Returns
668 ///
669 /// A new Environment instance with the IP address set
670 ///
671 /// # Examples
672 ///
673 /// ```rust
674 /// use torrust_tracker_deployer_lib::domain::{Environment, EnvironmentName};
675 /// use torrust_tracker_deployer_lib::domain::provider::{LxdConfig, ProviderConfig};
676 /// use torrust_tracker_deployer_lib::domain::ProfileName;
677 /// use torrust_tracker_deployer_lib::shared::Username;
678 /// use torrust_tracker_deployer_lib::adapters::ssh::SshCredentials;
679 /// use std::path::PathBuf;
680 /// use std::net::{IpAddr, Ipv4Addr};
681 /// use chrono::{TimeZone, Utc};
682 ///
683 /// let env_name = EnvironmentName::new("production".to_string())?;
684 /// let ssh_username = Username::new("torrust".to_string())?;
685 /// let ssh_credentials = SshCredentials::new(
686 /// PathBuf::from("keys/prod_rsa"),
687 /// PathBuf::from("keys/prod_rsa.pub"),
688 /// ssh_username,
689 /// );
690 /// let provider_config = ProviderConfig::Lxd(LxdConfig {
691 /// profile_name: ProfileName::new(format!("lxd-{}", env_name.as_str())).unwrap(),
692 /// });
693 /// let created_at = Utc.with_ymd_and_hms(2025, 1, 1, 0, 0, 0).unwrap();
694 /// let environment = Environment::new(env_name, provider_config, ssh_credentials, 22, created_at);
695 ///
696 /// // Set IP after provisioning
697 /// let ip = IpAddr::V4(Ipv4Addr::new(10, 0, 0, 42));
698 /// let environment = environment.with_instance_ip(ip);
699 ///
700 /// assert_eq!(environment.instance_ip(), Some(ip));
701 ///
702 /// # Ok::<(), Box<dyn std::error::Error>>(())
703 /// ```
704 #[must_use]
705 pub fn with_instance_ip(mut self, ip: IpAddr) -> Self {
706 self.context_mut().runtime_outputs.set_instance_ip(ip);
707 self
708 }
709
710 /// Sets the provision method and returns a new environment with the method set
711 ///
712 /// This method is used to track how the infrastructure was provisioned:
713 /// - `Provisioned`: Created via `provision` command using `OpenTofu`
714 /// - `Registered`: Connected to existing infrastructure via `register` command
715 ///
716 /// # Arguments
717 ///
718 /// * `method` - The provision method to set
719 ///
720 /// # Returns
721 ///
722 /// Returns the environment with the provision method set.
723 #[must_use]
724 pub fn with_provision_method(mut self, method: runtime_outputs::ProvisionMethod) -> Self {
725 self.context_mut()
726 .runtime_outputs
727 .set_provision_method(method);
728 self
729 }
730
731 /// Returns the templates directory for this environment
732 ///
733 /// The templates directory is located at `data/{env_name}/templates/`
734 /// and contains environment-specific template files.
735 ///
736 /// # Examples
737 ///
738 /// ```rust
739 /// use torrust_tracker_deployer_lib::domain::{Environment, EnvironmentName};
740 /// use torrust_tracker_deployer_lib::domain::provider::{LxdConfig, ProviderConfig};
741 /// use torrust_tracker_deployer_lib::domain::ProfileName;
742 /// use torrust_tracker_deployer_lib::shared::Username;
743 /// use torrust_tracker_deployer_lib::adapters::ssh::SshCredentials;
744 /// use std::path::PathBuf;
745 /// use chrono::{TimeZone, Utc};
746 ///
747 /// let env_name = EnvironmentName::new("staging".to_string())?;
748 /// let ssh_username = Username::new("torrust".to_string())?;
749 /// let ssh_credentials = SshCredentials::new(
750 /// PathBuf::from("keys/staging_rsa"),
751 /// PathBuf::from("keys/staging_rsa.pub"),
752 /// ssh_username,
753 /// );
754 /// let provider_config = ProviderConfig::Lxd(LxdConfig {
755 /// profile_name: ProfileName::new(format!("lxd-{}", env_name.as_str())).unwrap(),
756 /// });
757 /// let created_at = Utc.with_ymd_and_hms(2025, 1, 1, 0, 0, 0).unwrap();
758 /// let environment = Environment::new(env_name, provider_config, ssh_credentials, 22, created_at);
759 ///
760 /// assert_eq!(
761 /// environment.templates_dir(),
762 /// PathBuf::from("./data/staging/templates")
763 /// );
764 ///
765 /// # Ok::<(), Box<dyn std::error::Error>>(())
766 /// ```
767 #[must_use]
768 pub fn templates_dir(&self) -> PathBuf {
769 self.context.templates_dir()
770 }
771
772 /// Returns the traces directory for this environment
773 ///
774 /// The traces directory is located at `data/{env_name}/traces/`
775 /// and contains error trace files for failed operations.
776 ///
777 /// # Examples
778 ///
779 /// ```rust
780 /// use torrust_tracker_deployer_lib::domain::{Environment, EnvironmentName};
781 /// use torrust_tracker_deployer_lib::domain::provider::{LxdConfig, ProviderConfig};
782 /// use torrust_tracker_deployer_lib::domain::ProfileName;
783 /// use torrust_tracker_deployer_lib::shared::Username;
784 /// use torrust_tracker_deployer_lib::adapters::ssh::SshCredentials;
785 /// use std::path::PathBuf;
786 /// use chrono::{TimeZone, Utc};
787 ///
788 /// let env_name = EnvironmentName::new("production".to_string())?;
789 /// let ssh_username = Username::new("torrust".to_string())?;
790 /// let ssh_credentials = SshCredentials::new(
791 /// PathBuf::from("keys/prod_rsa"),
792 /// PathBuf::from("keys/prod_rsa.pub"),
793 /// ssh_username,
794 /// );
795 /// let provider_config = ProviderConfig::Lxd(LxdConfig {
796 /// profile_name: ProfileName::new(format!("lxd-{}", env_name.as_str())).unwrap(),
797 /// });
798 /// let created_at = Utc.with_ymd_and_hms(2025, 1, 1, 0, 0, 0).unwrap();
799 /// let environment = Environment::new(env_name, provider_config, ssh_credentials, 22, created_at);
800 ///
801 /// assert_eq!(
802 /// environment.traces_dir(),
803 /// PathBuf::from("./data/production/traces")
804 /// );
805 ///
806 /// # Ok::<(), Box<dyn std::error::Error>>(())
807 /// ```
808 #[must_use]
809 pub fn traces_dir(&self) -> PathBuf {
810 self.context.traces_dir()
811 }
812
813 /// Returns the ansible build directory for this environment
814 ///
815 /// # Examples
816 ///
817 /// ```rust
818 /// use torrust_tracker_deployer_lib::domain::{Environment, EnvironmentName};
819 /// use torrust_tracker_deployer_lib::domain::provider::{LxdConfig, ProviderConfig};
820 /// use torrust_tracker_deployer_lib::domain::ProfileName;
821 /// use torrust_tracker_deployer_lib::shared::Username;
822 /// use torrust_tracker_deployer_lib::adapters::ssh::SshCredentials;
823 /// use std::path::PathBuf;
824 /// use chrono::{TimeZone, Utc};
825 ///
826 /// let env_name = EnvironmentName::new("dev".to_string())?;
827 /// let ssh_username = Username::new("torrust".to_string())?;
828 /// let ssh_credentials = SshCredentials::new(
829 /// PathBuf::from("keys/dev_rsa"),
830 /// PathBuf::from("keys/dev_rsa.pub"),
831 /// ssh_username,
832 /// );
833 /// let provider_config = ProviderConfig::Lxd(LxdConfig {
834 /// profile_name: ProfileName::new(format!("lxd-{}", env_name.as_str())).unwrap(),
835 /// });
836 /// let created_at = Utc.with_ymd_and_hms(2025, 1, 1, 0, 0, 0).unwrap();
837 /// let environment = Environment::new(env_name, provider_config, ssh_credentials, 22, created_at);
838 ///
839 /// assert_eq!(
840 /// environment.ansible_build_dir(),
841 /// PathBuf::from("./build/dev/ansible")
842 /// );
843 ///
844 /// # Ok::<(), Box<dyn std::error::Error>>(())
845 /// ```
846 #[must_use]
847 pub fn ansible_build_dir(&self) -> PathBuf {
848 self.context.ansible_build_dir()
849 }
850
851 /// Returns the tofu build directory for this environment
852 ///
853 /// # Examples
854 ///
855 /// ```rust
856 /// use torrust_tracker_deployer_lib::domain::{Environment, EnvironmentName};
857 /// use torrust_tracker_deployer_lib::domain::provider::{LxdConfig, ProviderConfig};
858 /// use torrust_tracker_deployer_lib::domain::ProfileName;
859 /// use torrust_tracker_deployer_lib::shared::Username;
860 /// use torrust_tracker_deployer_lib::adapters::ssh::SshCredentials;
861 /// use std::path::PathBuf;
862 /// use chrono::{TimeZone, Utc};
863 ///
864 /// let env_name = EnvironmentName::new("test".to_string())?;
865 /// let ssh_username = Username::new("torrust".to_string())?;
866 /// let ssh_credentials = SshCredentials::new(
867 /// PathBuf::from("keys/test_rsa"),
868 /// PathBuf::from("keys/test_rsa.pub"),
869 /// ssh_username,
870 /// );
871 /// let provider_config = ProviderConfig::Lxd(LxdConfig {
872 /// profile_name: ProfileName::new(format!("lxd-{}", env_name.as_str())).unwrap(),
873 /// });
874 /// let created_at = Utc.with_ymd_and_hms(2025, 1, 1, 0, 0, 0).unwrap();
875 /// let environment = Environment::new(env_name, provider_config, ssh_credentials, 22, created_at);
876 ///
877 /// assert_eq!(
878 /// environment.tofu_build_dir(),
879 /// PathBuf::from("./build/test/tofu/lxd")
880 /// );
881 ///
882 /// # Ok::<(), Box<dyn std::error::Error>>(())
883 /// ```
884 #[must_use]
885 pub fn tofu_build_dir(&self) -> PathBuf {
886 self.context.tofu_build_dir()
887 }
888
889 /// Returns the ansible templates directory for this environment
890 ///
891 /// # Examples
892 ///
893 /// ```rust
894 /// use torrust_tracker_deployer_lib::domain::{Environment, EnvironmentName};
895 /// use torrust_tracker_deployer_lib::domain::provider::{LxdConfig, ProviderConfig};
896 /// use torrust_tracker_deployer_lib::domain::ProfileName;
897 /// use torrust_tracker_deployer_lib::shared::Username;
898 /// use torrust_tracker_deployer_lib::adapters::ssh::SshCredentials;
899 /// use std::path::PathBuf;
900 /// use chrono::{TimeZone, Utc};
901 ///
902 /// let env_name = EnvironmentName::new("integration".to_string())?;
903 /// let ssh_username = Username::new("torrust".to_string())?;
904 /// let ssh_credentials = SshCredentials::new(
905 /// PathBuf::from("keys/integration_rsa"),
906 /// PathBuf::from("keys/integration_rsa.pub"),
907 /// ssh_username,
908 /// );
909 /// let provider_config = ProviderConfig::Lxd(LxdConfig {
910 /// profile_name: ProfileName::new(format!("lxd-{}", env_name.as_str())).unwrap(),
911 /// });
912 /// let created_at = Utc.with_ymd_and_hms(2025, 1, 1, 0, 0, 0).unwrap();
913 /// let environment = Environment::new(env_name, provider_config, ssh_credentials, 22, created_at);
914 ///
915 /// assert_eq!(
916 /// environment.ansible_templates_dir(),
917 /// PathBuf::from("./data/integration/templates/ansible")
918 /// );
919 ///
920 /// # Ok::<(), Box<dyn std::error::Error>>(())
921 /// ```
922 #[must_use]
923 pub fn ansible_templates_dir(&self) -> PathBuf {
924 self.context.ansible_templates_dir()
925 }
926
927 /// Returns the tofu templates directory for this environment
928 ///
929 /// # Examples
930 ///
931 /// ```rust
932 /// use torrust_tracker_deployer_lib::domain::{Environment, EnvironmentName};
933 /// use torrust_tracker_deployer_lib::domain::provider::{LxdConfig, ProviderConfig};
934 /// use torrust_tracker_deployer_lib::domain::ProfileName;
935 /// use torrust_tracker_deployer_lib::shared::Username;
936 /// use torrust_tracker_deployer_lib::adapters::ssh::SshCredentials;
937 /// use std::path::PathBuf;
938 /// use chrono::{TimeZone, Utc};
939 ///
940 /// let env_name = EnvironmentName::new("load-test".to_string())?;
941 /// let ssh_username = Username::new("torrust".to_string())?;
942 /// let ssh_credentials = SshCredentials::new(
943 /// PathBuf::from("keys/load-test-rsa"),
944 /// PathBuf::from("keys/load-test-rsa.pub"),
945 /// ssh_username,
946 /// );
947 /// let provider_config = ProviderConfig::Lxd(LxdConfig {
948 /// profile_name: ProfileName::new(format!("lxd-{}", env_name.as_str())).unwrap(),
949 /// });
950 /// let created_at = Utc.with_ymd_and_hms(2025, 1, 1, 0, 0, 0).unwrap();
951 /// let environment = Environment::new(env_name, provider_config, ssh_credentials, 22, created_at);
952 ///
953 /// assert_eq!(
954 /// environment.tofu_templates_dir(),
955 /// PathBuf::from("./data/load-test/templates/tofu")
956 /// );
957 ///
958 /// # Ok::<(), Box<dyn std::error::Error>>(())
959 /// ```
960 #[must_use]
961 pub fn tofu_templates_dir(&self) -> PathBuf {
962 self.context.tofu_templates_dir()
963 }
964}
965
966#[cfg(test)]
967mod tests {
968 use super::*;
969 use crate::adapters::ssh::SshCredentials;
970 use crate::domain::grafana::GrafanaConfig;
971 use crate::domain::prometheus::PrometheusConfig;
972 use crate::domain::provider::{LxdConfig, ProviderConfig};
973 use crate::domain::tracker::TrackerConfig;
974 use crate::domain::EnvironmentName;
975 use std::path::Path;
976 use tempfile::TempDir;
977
978 // ============================================================================
979 // Test Fixtures - Builder Pattern
980 // ============================================================================
981
982 /// Test builder for creating Environment instances with sensible defaults
983 ///
984 /// This builder simplifies test setup by providing default values and allowing
985 /// customization through a fluent API. It automatically manages temporary
986 /// directories and creates all required value objects.
987 ///
988 /// # Examples
989 ///
990 /// ```rust
991 /// // Simple environment with defaults
992 /// let env = EnvironmentTestBuilder::new().build();
993 ///
994 /// // Customized environment
995 /// let env = EnvironmentTestBuilder::new()
996 /// .with_name("staging")
997 /// .with_ssh_key_name("custom_key")
998 /// .build();
999 ///
1000 /// // Environment with access to temp directory
1001 /// let (env, temp_dir) = EnvironmentTestBuilder::new()
1002 /// .with_name("test-env")
1003 /// .build_with_temp_dir();
1004 /// ```
1005 pub struct EnvironmentTestBuilder {
1006 env_name: String,
1007 ssh_key_name: String,
1008 ssh_username: String,
1009 temp_dir: TempDir,
1010 }
1011
1012 impl EnvironmentTestBuilder {
1013 /// Creates a new builder with sensible defaults
1014 pub fn new() -> Self {
1015 Self {
1016 env_name: "test-env".to_string(),
1017 ssh_key_name: "test_key".to_string(),
1018 ssh_username: "torrust".to_string(),
1019 temp_dir: TempDir::new().expect("Failed to create temp directory"),
1020 }
1021 }
1022
1023 /// Sets the environment name
1024 pub fn with_name(mut self, name: &str) -> Self {
1025 self.env_name = name.to_string();
1026 self
1027 }
1028
1029 /// Sets the SSH key name (without .pub extension)
1030 pub fn with_ssh_key_name(mut self, key_name: &str) -> Self {
1031 self.ssh_key_name = key_name.to_string();
1032 self
1033 }
1034
1035 /// Sets the SSH username
1036 #[allow(dead_code)]
1037 pub fn with_ssh_username(mut self, username: &str) -> Self {
1038 self.ssh_username = username.to_string();
1039 self
1040 }
1041
1042 /// Builds an Environment in Created state
1043 ///
1044 /// This is the most common use case - creates an environment with
1045 /// auto-generated paths based on the environment name.
1046 pub fn build(self) -> Environment<Created> {
1047 let env_name = EnvironmentName::new(self.env_name).unwrap();
1048 let ssh_username = Username::new(self.ssh_username).unwrap();
1049 let temp_path = self.temp_dir.path();
1050
1051 let ssh_credentials = SshCredentials::new(
1052 temp_path.join(&self.ssh_key_name),
1053 temp_path.join(format!("{}.pub", &self.ssh_key_name)),
1054 ssh_username,
1055 );
1056
1057 let ssh_port = 22;
1058 let provider_config = ProviderConfig::Lxd(LxdConfig {
1059 profile_name: ProfileName::new(format!("lxd-{}", env_name.as_str())).unwrap(),
1060 });
1061
1062 Environment::new(
1063 env_name,
1064 provider_config,
1065 ssh_credentials,
1066 ssh_port,
1067 chrono::Utc::now(),
1068 )
1069 }
1070
1071 /// Builds an Environment and returns the `TempDir`
1072 ///
1073 /// Use this when you need access to the temp directory in your test,
1074 /// for example to verify paths or create additional test files.
1075 #[allow(dead_code)]
1076 pub fn build_with_temp_dir(self) -> (Environment<Created>, TempDir) {
1077 let env_name = EnvironmentName::new(self.env_name).unwrap();
1078 let ssh_username = Username::new(self.ssh_username).unwrap();
1079 let temp_path = self.temp_dir.path();
1080
1081 let ssh_credentials = SshCredentials::new(
1082 temp_path.join(&self.ssh_key_name),
1083 temp_path.join(format!("{}.pub", &self.ssh_key_name)),
1084 ssh_username,
1085 );
1086
1087 let ssh_port = 22;
1088 let provider_config = ProviderConfig::Lxd(LxdConfig {
1089 profile_name: ProfileName::new(format!("lxd-{}", env_name.as_str())).unwrap(),
1090 });
1091 let environment = Environment::new(
1092 env_name,
1093 provider_config,
1094 ssh_credentials,
1095 ssh_port,
1096 chrono::Utc::now(),
1097 );
1098 (environment, self.temp_dir)
1099 }
1100
1101 /// Builds an Environment with custom paths
1102 ///
1103 /// Use this when you need full control over the data and build directories.
1104 /// Returns the environment, `data_dir`, `build_dir`, and `temp_dir`.
1105 pub fn build_with_custom_paths(self) -> (Environment<Created>, PathBuf, PathBuf, TempDir) {
1106 let temp_path = self.temp_dir.path();
1107 let data_dir = temp_path.join("data").join(&self.env_name);
1108 let build_dir = temp_path.join("build").join(&self.env_name);
1109
1110 let env_name = EnvironmentName::new(self.env_name).unwrap();
1111 let ssh_username = Username::new(self.ssh_username).unwrap();
1112 let ssh_credentials = SshCredentials::new(
1113 temp_path.join(&self.ssh_key_name),
1114 temp_path.join(format!("{}.pub", &self.ssh_key_name)),
1115 ssh_username,
1116 );
1117
1118 let profile_name = ProfileName::new(format!("lxd-{}", env_name.as_str())).unwrap();
1119 let provider_config = ProviderConfig::Lxd(LxdConfig { profile_name });
1120
1121 let user_inputs = UserInputs::with_tracker(
1122 &env_name,
1123 provider_config,
1124 ssh_credentials,
1125 22,
1126 TrackerConfig::default(),
1127 Some(PrometheusConfig::default()),
1128 Some(GrafanaConfig::default()),
1129 None,
1130 None, // No backup
1131 )
1132 .expect("Test UserInputs should always be valid with defaults");
1133
1134 let context = EnvironmentContext {
1135 user_inputs,
1136 internal_config: InternalConfig {
1137 data_dir: data_dir.clone(),
1138 build_dir: build_dir.clone(),
1139 },
1140 runtime_outputs: RuntimeOutputs::new(),
1141 created_at: chrono::Utc::now(),
1142 };
1143
1144 let environment = Environment {
1145 context,
1146 state: Created,
1147 };
1148
1149 (environment, data_dir, build_dir, self.temp_dir)
1150 }
1151
1152 /// Returns a reference to the temp directory path
1153 #[allow(dead_code)]
1154 pub fn temp_path(&self) -> &Path {
1155 self.temp_dir.path()
1156 }
1157 }
1158
1159 impl Default for EnvironmentTestBuilder {
1160 fn default() -> Self {
1161 Self::new()
1162 }
1163 }
1164
1165 // ============================================================================
1166 // Custom Assertion Helpers
1167 // ============================================================================
1168
1169 /// Asserts that the environment's paths are within the temp directory
1170 #[allow(dead_code)]
1171 fn assert_paths_in_temp_dir(env: &Environment<impl Clone>, temp_path: &Path, env_name: &str) {
1172 assert!(
1173 env.data_dir().starts_with(temp_path),
1174 "data_dir should be in temp: {:?} not in {:?}",
1175 env.data_dir(),
1176 temp_path
1177 );
1178 assert!(
1179 env.build_dir().starts_with(temp_path),
1180 "build_dir should be in temp: {:?} not in {:?}",
1181 env.build_dir(),
1182 temp_path
1183 );
1184 assert!(
1185 env.data_dir().to_string_lossy().contains(env_name),
1186 "data_dir should contain env name: {:?}",
1187 env.data_dir()
1188 );
1189 assert!(
1190 env.build_dir().to_string_lossy().contains(env_name),
1191 "build_dir should contain env name: {:?}",
1192 env.build_dir()
1193 );
1194 }
1195
1196 /// Asserts that SSH credentials match expected paths
1197 fn assert_ssh_credentials(
1198 env: &Environment<impl Clone>,
1199 expected_private: &Path,
1200 expected_public: &Path,
1201 ) {
1202 assert_eq!(
1203 env.ssh_private_key_path(),
1204 expected_private,
1205 "SSH private key path mismatch"
1206 );
1207 assert_eq!(
1208 env.ssh_public_key_path(),
1209 expected_public,
1210 "SSH public key path mismatch"
1211 );
1212 }
1213
1214 /// Asserts that the instance name matches the expected format
1215 fn assert_instance_name_format(env: &Environment<impl Clone>, env_name: &str) {
1216 let expected = format!("torrust-tracker-vm-{env_name}");
1217 assert_eq!(
1218 env.instance_name().as_str(),
1219 expected,
1220 "Instance name should match expected format"
1221 );
1222 }
1223
1224 /// Asserts that a path ends with the expected suffix
1225 #[allow(dead_code)]
1226 fn assert_path_ends_with(actual: &Path, expected_suffix: &str) {
1227 assert!(
1228 actual.to_string_lossy().ends_with(expected_suffix),
1229 "Path {actual:?} should end with {expected_suffix:?}"
1230 );
1231 }
1232
1233 // ============================================================================
1234 // Tests
1235 // ============================================================================
1236
1237 #[test]
1238 fn it_should_create_environment_with_auto_generated_paths() {
1239 // Arrange
1240 let (environment, data_dir, build_dir, temp_dir) = EnvironmentTestBuilder::new()
1241 .with_name("test-env")
1242 .with_ssh_key_name("testing_rsa")
1243 .build_with_custom_paths();
1244 let temp_path = temp_dir.path();
1245
1246 // Act & Assert: Check basic fields
1247 assert_eq!(environment.name().as_str(), "test-env");
1248 assert_eq!(environment.ssh_username().as_str(), "torrust");
1249
1250 // Assert: Check SSH credentials
1251 assert_ssh_credentials(
1252 &environment,
1253 &temp_path.join("testing_rsa"),
1254 &temp_path.join("testing_rsa.pub"),
1255 );
1256
1257 // Assert: Check paths are in temp directory
1258 assert_eq!(*environment.data_dir(), data_dir);
1259 assert_eq!(*environment.build_dir(), build_dir);
1260
1261 // Assert: Check instance name format
1262 assert_instance_name_format(&environment, "test-env");
1263 }
1264
1265 #[test]
1266 fn it_should_generate_correct_template_directories() {
1267 // Arrange
1268 let (environment, data_dir, _build_dir, _temp_dir) = EnvironmentTestBuilder::new()
1269 .with_name("test-production")
1270 .with_ssh_key_name("prod_rsa")
1271 .build_with_custom_paths();
1272
1273 // Act
1274 let templates_dir = environment.templates_dir();
1275 let ansible_dir = environment.ansible_templates_dir();
1276 let tofu_dir = environment.tofu_templates_dir();
1277
1278 // Assert
1279 assert_eq!(templates_dir, data_dir.join("templates"));
1280 assert_eq!(ansible_dir, data_dir.join("templates").join("ansible"));
1281 assert_eq!(tofu_dir, data_dir.join("templates").join("tofu"));
1282 }
1283
1284 #[test]
1285 fn it_should_generate_correct_build_directories() {
1286 // Arrange
1287 let (environment, _data_dir, build_dir, _temp_dir) = EnvironmentTestBuilder::new()
1288 .with_name("test-staging")
1289 .with_ssh_key_name("staging_rsa")
1290 .build_with_custom_paths();
1291
1292 // Act
1293 let ansible_dir = environment.ansible_build_dir();
1294 let tofu_dir = environment.tofu_build_dir();
1295
1296 // Assert
1297 assert_eq!(ansible_dir, build_dir.join("ansible"));
1298 assert_eq!(tofu_dir, build_dir.join("tofu").join("lxd"));
1299 }
1300
1301 #[test]
1302 fn it_should_handle_different_environment_names() {
1303 // Arrange: Test cases with environment names and expected instance names
1304 let test_cases = vec![
1305 ("test-dev", "torrust-tracker-vm-test-dev"),
1306 (
1307 "test-e2e-provision",
1308 "torrust-tracker-vm-test-e2e-provision",
1309 ),
1310 ("test-integration", "torrust-tracker-vm-test-integration"),
1311 ("test-release-v1-2", "torrust-tracker-vm-test-release-v1-2"),
1312 ];
1313
1314 for (env_name_str, expected_instance_name) in test_cases {
1315 // Arrange
1316 let (environment, data_dir, build_dir, _temp_dir) = EnvironmentTestBuilder::new()
1317 .with_name(env_name_str)
1318 .build_with_custom_paths();
1319
1320 // Act & Assert
1321 assert_eq!(environment.instance_name().as_str(), expected_instance_name);
1322 assert_eq!(*environment.data_dir(), data_dir);
1323 assert_eq!(*environment.build_dir(), build_dir);
1324 }
1325 }
1326
1327 #[test]
1328 fn it_should_be_serializable_to_json() {
1329 // Arrange
1330 let (environment, data_dir, build_dir, temp_dir) = EnvironmentTestBuilder::new()
1331 .with_name("test-serialization")
1332 .with_ssh_key_name("test_private_key")
1333 .build_with_custom_paths();
1334 let temp_path = temp_dir.path();
1335
1336 // Act: Serialize to JSON
1337 let json = serde_json::to_string(&environment).unwrap();
1338
1339 // Act: Deserialize back
1340 let deserialized: Environment = serde_json::from_str(&json).unwrap();
1341
1342 // Assert: Check that all fields are preserved
1343 assert_eq!(deserialized.name().as_str(), "test-serialization");
1344 assert_instance_name_format(&deserialized, "test-serialization");
1345 assert_ssh_credentials(
1346 &deserialized,
1347 &temp_path.join("test_private_key"),
1348 &temp_path.join("test_private_key.pub"),
1349 );
1350 assert_eq!(*deserialized.data_dir(), data_dir);
1351 assert_eq!(*deserialized.build_dir(), build_dir);
1352 }
1353
1354 #[test]
1355 fn it_should_support_common_e2e_environment_names() {
1356 // Arrange: Common E2E environment names
1357 let e2e_environments = vec!["test-e2e-config", "test-e2e-provision", "test-e2e-full"];
1358
1359 for env_name_str in e2e_environments {
1360 // Arrange
1361 let environment = EnvironmentTestBuilder::new()
1362 .with_name(env_name_str)
1363 .with_ssh_key_name("testing_rsa")
1364 .build();
1365
1366 // Act & Assert: Verify the environment is created successfully
1367 assert_eq!(environment.name().as_str(), env_name_str);
1368 assert!(environment
1369 .instance_name()
1370 .as_str()
1371 .starts_with("torrust-tracker-vm-"));
1372 assert!(environment
1373 .data_dir()
1374 .to_string_lossy()
1375 .contains(env_name_str));
1376 assert!(environment
1377 .build_dir()
1378 .to_string_lossy()
1379 .contains(env_name_str));
1380 }
1381 }
1382
1383 #[test]
1384 fn it_should_handle_dash_separated_environment_names() {
1385 // Arrange
1386 let (environment, data_dir, build_dir, _temp_dir) = EnvironmentTestBuilder::new()
1387 .with_name("test-feature-user-auth")
1388 .with_ssh_key_name("feature_rsa")
1389 .build_with_custom_paths();
1390
1391 // Act & Assert
1392 assert_instance_name_format(&environment, "test-feature-user-auth");
1393 assert_eq!(*environment.data_dir(), data_dir);
1394 assert_eq!(*environment.build_dir(), build_dir);
1395 assert_eq!(environment.templates_dir(), data_dir.join("templates"));
1396 }
1397
1398 // State transition tests
1399 mod state_transitions {
1400 use std::net::{IpAddr, Ipv4Addr};
1401
1402 use super::*;
1403
1404 /// Helper function to create a test environment for state transition tests
1405 fn create_test_environment() -> Environment<Created> {
1406 EnvironmentTestBuilder::new()
1407 .with_name("test-state")
1408 .build()
1409 }
1410
1411 #[test]
1412 fn it_should_transition_to_destroyed_from_created() {
1413 // Arrange
1414 let env = create_test_environment();
1415
1416 // Act
1417 let env = env.destroy();
1418
1419 // Assert
1420 assert_eq!(*env.state(), Destroyed);
1421 assert_eq!(env.name().as_str(), "test-state");
1422 }
1423
1424 #[test]
1425 fn it_should_complete_full_happy_path_transition() {
1426 // Arrange
1427 let env = create_test_environment();
1428
1429 // Act: Complete happy path: Created -> Running
1430 let env = env
1431 .start_provisioning()
1432 .provisioned(
1433 IpAddr::V4(Ipv4Addr::new(192, 168, 1, 100)),
1434 ProvisionMethod::Provisioned,
1435 )
1436 .start_configuring()
1437 .configured()
1438 .start_releasing()
1439 .released()
1440 .start_running();
1441
1442 // Assert
1443 assert_eq!(*env.state(), Running);
1444 assert_eq!(env.name().as_str(), "test-state");
1445
1446 // Act: Then destroy
1447 let env = env.destroy();
1448
1449 // Assert
1450 assert_eq!(*env.state(), Destroyed);
1451 }
1452
1453 #[test]
1454 fn it_should_preserve_all_fields_during_transitions() {
1455 // Arrange
1456 let env = create_test_environment();
1457 let initial_name = env.name().clone();
1458 let initial_instance_name = env.instance_name().clone();
1459 let initial_data_dir = env.data_dir().clone();
1460 let initial_build_dir = env.build_dir().clone();
1461
1462 // Act: Go through several transitions
1463 let env = env
1464 .start_provisioning()
1465 .provisioned(
1466 IpAddr::V4(Ipv4Addr::new(192, 168, 1, 100)),
1467 ProvisionMethod::Provisioned,
1468 )
1469 .start_configuring()
1470 .configured();
1471
1472 // Assert: Verify all fields are preserved
1473 assert_eq!(env.name(), &initial_name);
1474 assert_eq!(env.instance_name(), &initial_instance_name);
1475 assert_eq!(env.data_dir(), &initial_data_dir);
1476 assert_eq!(env.build_dir(), &initial_build_dir);
1477 }
1478
1479 // State transition logging tests
1480 mod logging {
1481 use super::*;
1482 use tracing_test::traced_test;
1483
1484 #[traced_test]
1485 #[test]
1486 fn it_should_log_state_transition_from_created_to_provisioning() {
1487 let env = create_test_environment();
1488
1489 let _provisioning = env.start_provisioning();
1490
1491 // Assert log contains expected fields
1492 assert!(logs_contain("Environment state transition"));
1493 assert!(logs_contain("environment_name=test-state"));
1494 assert!(logs_contain("from_state="));
1495 assert!(logs_contain("Created"));
1496 assert!(logs_contain("to_state="));
1497 assert!(logs_contain("Provisioning"));
1498 }
1499
1500 #[traced_test]
1501 #[test]
1502 fn it_should_log_state_transition_with_instance_name() {
1503 let env = create_test_environment();
1504
1505 let _provisioning = env.start_provisioning();
1506
1507 assert!(logs_contain("instance_name=torrust-tracker-vm-test-state"));
1508 }
1509
1510 #[traced_test]
1511 #[test]
1512 fn it_should_log_complete_state_transition_chain() {
1513 let env = create_test_environment();
1514
1515 let _env = env
1516 .start_provisioning()
1517 .provisioned(
1518 IpAddr::V4(Ipv4Addr::new(192, 168, 1, 100)),
1519 ProvisionMethod::Provisioned,
1520 )
1521 .start_configuring()
1522 .configured();
1523
1524 // Verify multiple transitions were logged
1525 assert!(logs_contain("Provisioning"));
1526 assert!(logs_contain("Provisioned"));
1527 assert!(logs_contain("Configuring"));
1528 assert!(logs_contain("Configured"));
1529 }
1530
1531 #[traced_test]
1532 #[test]
1533 fn it_should_log_destroy_transition_from_any_state() {
1534 let env = create_test_environment();
1535 let env = env.start_provisioning();
1536
1537 let _destroyed = env.destroy();
1538
1539 assert!(logs_contain("Destroyed"));
1540 }
1541 }
1542
1543 // Three-way split tests
1544 mod three_way_split {
1545 use super::*;
1546 use std::net::{IpAddr, Ipv4Addr};
1547
1548 #[test]
1549 fn it_should_separate_user_inputs_from_context() {
1550 let env = EnvironmentTestBuilder::new()
1551 .with_name("test-split")
1552 .build();
1553
1554 // Can access user inputs directly
1555 assert_eq!(env.context.user_inputs.name().as_str(), "test-split");
1556 assert_eq!(env.context.user_inputs.ssh_port(), 22);
1557 }
1558
1559 #[test]
1560 fn it_should_derive_internal_config_automatically() {
1561 let env = EnvironmentTestBuilder::new()
1562 .with_name("test-derived")
1563 .build();
1564
1565 // Internal config is derived from name
1566 let data_dir = &env.context.internal_config.data_dir;
1567 let build_dir = &env.context.internal_config.build_dir;
1568
1569 assert!(data_dir.to_string_lossy().contains("test-derived"));
1570 assert!(build_dir.to_string_lossy().contains("test-derived"));
1571 }
1572
1573 #[test]
1574 fn it_should_initialize_runtime_outputs_as_empty() {
1575 let env = EnvironmentTestBuilder::new()
1576 .with_name("test-runtime")
1577 .build();
1578
1579 // Runtime outputs start empty
1580 assert_eq!(env.context.runtime_outputs.instance_ip(), None);
1581 }
1582
1583 #[test]
1584 fn it_should_populate_runtime_outputs_during_operations() {
1585 let env = EnvironmentTestBuilder::new()
1586 .with_name("test-populate")
1587 .build();
1588
1589 // Simulate provisioning operation setting the IP
1590 let ip = IpAddr::V4(Ipv4Addr::new(10, 0, 0, 1));
1591 let env = env.with_instance_ip(ip);
1592
1593 assert_eq!(env.context.runtime_outputs.instance_ip(), Some(ip));
1594 }
1595
1596 #[test]
1597 fn it_should_serialize_with_semantic_structure() {
1598 let env = EnvironmentTestBuilder::new()
1599 .with_name("test-serialize")
1600 .build();
1601
1602 let json = serde_json::to_value(&env.context).unwrap();
1603
1604 // Verify JSON has three top-level keys
1605 assert!(json.get("user_inputs").is_some());
1606 assert!(json.get("internal_config").is_some());
1607 assert!(json.get("runtime_outputs").is_some());
1608 }
1609
1610 #[test]
1611 fn it_should_provide_accessor_methods_for_backward_compatibility() {
1612 let env = EnvironmentTestBuilder::new()
1613 .with_name("test-accessors")
1614 .build();
1615
1616 // Accessor methods should work through the context
1617 assert_eq!(env.name().as_str(), "test-accessors");
1618 assert_eq!(env.ssh_port(), 22);
1619 assert_eq!(env.instance_ip(), None);
1620 }
1621 }
1622 }
1623}