diff --git a/project-words.txt b/project-words.txt index 45c2d4ef..229216a4 100644 --- a/project-words.txt +++ b/project-words.txt @@ -92,6 +92,7 @@ frontends getent getopt handleable +hashset hetznercloud hexdigit hexdump diff --git a/src/application/command_handlers/create/config/errors.rs b/src/application/command_handlers/create/config/errors.rs index 6063951c..292f5961 100644 --- a/src/application/command_handlers/create/config/errors.rs +++ b/src/application/command_handlers/create/config/errors.rs @@ -8,6 +8,7 @@ use std::path::PathBuf; use thiserror::Error; use crate::domain::EnvironmentNameError; +use crate::domain::ProfileNameError; use crate::shared::UsernameError; /// Errors that can occur during configuration validation @@ -24,6 +25,10 @@ pub enum CreateConfigError { #[error("Invalid SSH username: {0}")] InvalidUsername(#[from] UsernameError), + /// Invalid profile name format + #[error("Invalid profile name: {0}")] + InvalidProfileName(#[from] ProfileNameError), + /// SSH private key file not found #[error("SSH private key file not found: {path}")] PrivateKeyNotFound { path: PathBuf }, @@ -110,6 +115,19 @@ impl CreateConfigError { \n\ Fix: Update the SSH username in your configuration to follow Linux username requirements." } + Self::InvalidProfileName(_) => { + "LXD profile name validation failed.\n\ + \n\ + Valid profile names must:\n\ + - Be 1-63 characters long\n\ + - Contain only ASCII letters, numbers, and dashes\n\ + - Not start with a digit or dash\n\ + - Not end with a dash\n\ + \n\ + Examples: 'torrust-profile', 'default', 'dev-profile'\n\ + \n\ + Fix: Update the profile_name in your provider configuration to follow these rules." + } Self::PrivateKeyNotFound { .. } => { "SSH private key file not found.\n\ \n\ diff --git a/src/application/command_handlers/create/config/mod.rs b/src/application/command_handlers/create/config/mod.rs index a4882854..5d0c491b 100644 --- a/src/application/command_handlers/create/config/mod.rs +++ b/src/application/command_handlers/create/config/mod.rs @@ -23,6 +23,13 @@ //! - `SshCredentialsConfig` - SSH credentials configuration (config layer) //! - `EnvironmentSection` - Environment-specific settings //! +//! ### Provider Configuration +//! +//! Provider configuration is organized in the `provider` submodule: +//! - `ProviderSection` - Tagged enum for provider-specific settings +//! - `LxdProviderSection` - LXD provider configuration +//! - `HetznerProviderSection` - Hetzner provider configuration +//! //! Note: `SshCredentialsConfig` (config layer) is distinct from //! `adapters::ssh::SshCredentials` (adapter layer). The config version uses //! strings for paths and usernames, while the adapter version uses domain types. @@ -95,9 +102,11 @@ pub mod environment_config; pub mod errors; +pub mod provider; pub mod ssh_credentials_config; // Re-export commonly used types for convenience pub use environment_config::{EnvironmentCreationConfig, EnvironmentSection}; pub use errors::CreateConfigError; +pub use provider::{HetznerProviderSection, LxdProviderSection, ProviderSection}; pub use ssh_credentials_config::SshCredentialsConfig; diff --git a/src/application/command_handlers/create/config/provider/hetzner.rs b/src/application/command_handlers/create/config/provider/hetzner.rs new file mode 100644 index 00000000..fb0c0ef7 --- /dev/null +++ b/src/application/command_handlers/create/config/provider/hetzner.rs @@ -0,0 +1,83 @@ +//! Hetzner Provider Configuration Section (Application Layer) +//! +//! This module contains the configuration section for the Hetzner provider. +//! Uses raw `String` fields for JSON deserialization, which are then validated +//! when converting to domain types. + +use serde::{Deserialize, Serialize}; + +/// Hetzner-specific configuration section +/// +/// Uses raw `String` fields for JSON deserialization. Convert to domain +/// `HetznerConfig` via `ProviderSection::to_provider_config()`. +/// +/// # Examples +/// +/// ```rust +/// use torrust_tracker_deployer_lib::application::command_handlers::create::config::HetznerProviderSection; +/// +/// let section = HetznerProviderSection { +/// api_token: "your-api-token".to_string(), +/// server_type: "cx22".to_string(), +/// location: "nbg1".to_string(), +/// }; +/// ``` +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct HetznerProviderSection { + /// Hetzner API token (raw string). + pub api_token: String, + + /// Hetzner server type (e.g., "cx22", "cx32", "cpx11"). + pub server_type: String, + + /// Hetzner datacenter location (e.g., "fsn1", "nbg1", "hel1"). + pub location: String, +} + +#[cfg(test)] +mod tests { + use super::*; + + fn create_hetzner_section() -> HetznerProviderSection { + HetznerProviderSection { + api_token: "token".to_string(), + server_type: "cx22".to_string(), + location: "nbg1".to_string(), + } + } + + #[test] + fn it_should_serialize_to_json() { + let section = create_hetzner_section(); + let json = serde_json::to_string(§ion).unwrap(); + assert!(json.contains("\"api_token\":\"token\"")); + assert!(json.contains("\"server_type\":\"cx22\"")); + assert!(json.contains("\"location\":\"nbg1\"")); + } + + #[test] + fn it_should_deserialize_from_json() { + let json = r#"{"api_token":"token","server_type":"cx22","location":"nbg1"}"#; + let section: HetznerProviderSection = serde_json::from_str(json).unwrap(); + assert_eq!(section.api_token, "token"); + assert_eq!(section.server_type, "cx22"); + assert_eq!(section.location, "nbg1"); + } + + #[test] + fn it_should_be_cloneable() { + let section = create_hetzner_section(); + let cloned = section.clone(); + assert_eq!(section, cloned); + } + + #[test] + fn it_should_implement_debug_trait() { + let section = create_hetzner_section(); + let debug = format!("{section:?}"); + assert!(debug.contains("HetznerProviderSection")); + assert!(debug.contains("api_token")); + assert!(debug.contains("server_type")); + assert!(debug.contains("location")); + } +} diff --git a/src/application/command_handlers/create/config/provider/lxd.rs b/src/application/command_handlers/create/config/provider/lxd.rs new file mode 100644 index 00000000..e227dfea --- /dev/null +++ b/src/application/command_handlers/create/config/provider/lxd.rs @@ -0,0 +1,67 @@ +//! LXD Provider Configuration Section (Application Layer) +//! +//! This module contains the configuration section for the LXD provider. +//! Uses raw `String` for JSON deserialization, which is then validated +//! when converting to domain types. + +use serde::{Deserialize, Serialize}; + +/// LXD-specific configuration section +/// +/// Uses raw `String` for JSON deserialization. Convert to domain `LxdConfig` +/// via `ProviderSection::to_provider_config()`. +/// +/// # Examples +/// +/// ```rust +/// use torrust_tracker_deployer_lib::application::command_handlers::create::config::LxdProviderSection; +/// +/// let section = LxdProviderSection { +/// profile_name: "torrust-profile-dev".to_string(), +/// }; +/// ``` +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct LxdProviderSection { + /// LXD profile name (raw string - validated on conversion). + pub profile_name: String, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn it_should_serialize_to_json() { + let section = LxdProviderSection { + profile_name: "test".to_string(), + }; + let json = serde_json::to_string(§ion).unwrap(); + assert!(json.contains("\"profile_name\":\"test\"")); + } + + #[test] + fn it_should_deserialize_from_json() { + let json = r#"{"profile_name":"torrust-profile"}"#; + let section: LxdProviderSection = serde_json::from_str(json).unwrap(); + assert_eq!(section.profile_name, "torrust-profile"); + } + + #[test] + fn it_should_be_cloneable() { + let section = LxdProviderSection { + profile_name: "test".to_string(), + }; + let cloned = section.clone(); + assert_eq!(section, cloned); + } + + #[test] + fn it_should_implement_debug_trait() { + let section = LxdProviderSection { + profile_name: "test".to_string(), + }; + let debug = format!("{section:?}"); + assert!(debug.contains("LxdProviderSection")); + assert!(debug.contains("profile_name")); + } +} diff --git a/src/application/command_handlers/create/config/provider/mod.rs b/src/application/command_handlers/create/config/provider/mod.rs new file mode 100644 index 00000000..56f5bce8 --- /dev/null +++ b/src/application/command_handlers/create/config/provider/mod.rs @@ -0,0 +1,309 @@ +//! Provider Configuration Types (Application Layer) +//! +//! This module contains configuration types for provider-specific configuration. +//! These types are used for deserializing external configuration (JSON files) and +//! contain **raw primitives** (e.g., `String`). +//! +//! After deserialization, use `to_provider_config()` to convert to domain types +//! with validation. +//! +//! # Module Structure +//! +//! Each provider has its own submodule for extensibility: +//! - `lxd` - LXD provider configuration section +//! - `hetzner` - Hetzner provider configuration section +//! +//! # Layer Separation +//! +//! - **These config types** (this module): Raw primitives for JSON parsing +//! - **Domain types** (`domain::provider`): Validated types for business logic +//! +//! # Examples +//! +//! ```rust +//! use torrust_tracker_deployer_lib::application::command_handlers::create::config::{ +//! ProviderSection, LxdProviderSection +//! }; +//! +//! // Deserialize from JSON +//! let json = r#"{"provider": "lxd", "profile_name": "torrust-profile"}"#; +//! let section: ProviderSection = serde_json::from_str(json).unwrap(); +//! +//! // Convert to domain type with validation +//! let config = section.to_provider_config().unwrap(); +//! assert_eq!(config.provider_name(), "lxd"); +//! ``` + +mod hetzner; +mod lxd; + +pub use hetzner::HetznerProviderSection; +pub use lxd::LxdProviderSection; + +use serde::{Deserialize, Serialize}; + +use crate::application::command_handlers::create::config::CreateConfigError; +use crate::domain::provider::{HetznerConfig, LxdConfig, Provider, ProviderConfig}; +use crate::domain::ProfileName; + +/// Provider-specific configuration section +/// +/// Each variant contains the configuration fields specific to that provider +/// using **raw primitives** (`String`) for JSON deserialization. +/// +/// This is a tagged enum that deserializes based on the `"provider"` field in JSON. +/// +/// # Conversion +/// +/// Use `to_provider_config()` to validate and convert to domain types. +/// +/// # Examples +/// +/// ```rust +/// use torrust_tracker_deployer_lib::application::command_handlers::create::config::{ +/// ProviderSection, LxdProviderSection +/// }; +/// +/// let section = ProviderSection::Lxd(LxdProviderSection { +/// profile_name: "torrust-profile-dev".to_string(), +/// }); +/// +/// let config = section.to_provider_config().unwrap(); +/// assert_eq!(config.provider_name(), "lxd"); +/// ``` +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "provider")] +pub enum ProviderSection { + /// LXD provider configuration + #[serde(rename = "lxd")] + Lxd(LxdProviderSection), + + /// Hetzner provider configuration + #[serde(rename = "hetzner")] + Hetzner(HetznerProviderSection), +} + +impl ProviderSection { + /// Returns the provider type (no validation needed). + /// + /// # Examples + /// + /// ```rust + /// use torrust_tracker_deployer_lib::application::command_handlers::create::config::{ + /// ProviderSection, LxdProviderSection + /// }; + /// use torrust_tracker_deployer_lib::domain::provider::Provider; + /// + /// let section = ProviderSection::Lxd(LxdProviderSection { + /// profile_name: "test".to_string(), + /// }); + /// assert_eq!(section.provider(), Provider::Lxd); + /// ``` + #[must_use] + pub fn provider(&self) -> Provider { + match self { + Self::Lxd(_) => Provider::Lxd, + Self::Hetzner(_) => Provider::Hetzner, + } + } + + /// Converts the config to a validated domain `ProviderConfig`. + /// + /// This method validates raw string fields and converts them to + /// domain types with proper validation. + /// + /// # Errors + /// + /// Returns `CreateConfigError` if validation fails: + /// - `InvalidProfileName` - if the LXD profile name is invalid + /// + /// # Examples + /// + /// ```rust + /// use torrust_tracker_deployer_lib::application::command_handlers::create::config::{ + /// ProviderSection, LxdProviderSection + /// }; + /// + /// // Valid conversion + /// let section = ProviderSection::Lxd(LxdProviderSection { + /// profile_name: "torrust-profile-dev".to_string(), + /// }); + /// let config = section.to_provider_config().unwrap(); + /// assert_eq!(config.provider_name(), "lxd"); + /// + /// // Invalid profile name causes error + /// let invalid = ProviderSection::Lxd(LxdProviderSection { + /// profile_name: "".to_string(), // Empty is invalid + /// }); + /// assert!(invalid.to_provider_config().is_err()); + /// ``` + pub fn to_provider_config(self) -> Result { + match self { + Self::Lxd(lxd) => { + let profile_name = ProfileName::new(lxd.profile_name)?; + Ok(ProviderConfig::Lxd(LxdConfig { profile_name })) + } + Self::Hetzner(hetzner) => { + // Note: Future improvement could add validation for these fields + Ok(ProviderConfig::Hetzner(HetznerConfig { + api_token: hetzner.api_token, + server_type: hetzner.server_type, + location: hetzner.location, + })) + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn create_lxd_section() -> ProviderSection { + ProviderSection::Lxd(LxdProviderSection { + profile_name: "torrust-profile".to_string(), + }) + } + + fn create_hetzner_section() -> ProviderSection { + ProviderSection::Hetzner(HetznerProviderSection { + api_token: "test-token".to_string(), + server_type: "cx22".to_string(), + location: "nbg1".to_string(), + }) + } + + #[test] + fn it_should_return_lxd_provider_when_section_is_lxd() { + let section = create_lxd_section(); + assert_eq!(section.provider(), Provider::Lxd); + } + + #[test] + fn it_should_return_hetzner_provider_when_section_is_hetzner() { + let section = create_hetzner_section(); + assert_eq!(section.provider(), Provider::Hetzner); + } + + #[test] + fn it_should_deserialize_lxd_section_from_json() { + let json = r#"{"provider": "lxd", "profile_name": "torrust-profile"}"#; + let section: ProviderSection = serde_json::from_str(json).unwrap(); + + assert_eq!(section.provider(), Provider::Lxd); + if let ProviderSection::Lxd(lxd) = section { + assert_eq!(lxd.profile_name, "torrust-profile"); + } else { + panic!("Expected LXD section"); + } + } + + #[test] + fn it_should_deserialize_hetzner_section_from_json() { + let json = r#"{ + "provider": "hetzner", + "api_token": "token123", + "server_type": "cx32", + "location": "fsn1" + }"#; + let section: ProviderSection = serde_json::from_str(json).unwrap(); + + assert_eq!(section.provider(), Provider::Hetzner); + if let ProviderSection::Hetzner(hetzner) = section { + assert_eq!(hetzner.api_token, "token123"); + assert_eq!(hetzner.server_type, "cx32"); + assert_eq!(hetzner.location, "fsn1"); + } else { + panic!("Expected Hetzner section"); + } + } + + #[test] + fn it_should_serialize_lxd_section_to_json() { + let section = create_lxd_section(); + let json = serde_json::to_string(§ion).unwrap(); + + assert!(json.contains("\"provider\":\"lxd\"")); + assert!(json.contains("\"profile_name\":\"torrust-profile\"")); + } + + #[test] + fn it_should_serialize_hetzner_section_to_json() { + let section = create_hetzner_section(); + let json = serde_json::to_string(§ion).unwrap(); + + assert!(json.contains("\"provider\":\"hetzner\"")); + assert!(json.contains("\"api_token\":\"test-token\"")); + assert!(json.contains("\"server_type\":\"cx22\"")); + assert!(json.contains("\"location\":\"nbg1\"")); + } + + #[test] + fn it_should_convert_lxd_section_to_domain_config() { + let section = create_lxd_section(); + let config = section.to_provider_config().unwrap(); + + assert_eq!(config.provider(), Provider::Lxd); + assert_eq!(config.provider_name(), "lxd"); + assert_eq!( + config.as_lxd().unwrap().profile_name.as_str(), + "torrust-profile" + ); + } + + #[test] + fn it_should_convert_hetzner_section_to_domain_config() { + let section = create_hetzner_section(); + let config = section.to_provider_config().unwrap(); + + assert_eq!(config.provider(), Provider::Hetzner); + assert_eq!(config.provider_name(), "hetzner"); + + let hetzner = config.as_hetzner().unwrap(); + assert_eq!(hetzner.api_token, "test-token"); + assert_eq!(hetzner.server_type, "cx22"); + assert_eq!(hetzner.location, "nbg1"); + } + + #[test] + fn it_should_fail_conversion_when_lxd_profile_name_is_empty() { + let section = ProviderSection::Lxd(LxdProviderSection { + profile_name: String::new(), // Empty is invalid + }); + let result = section.to_provider_config(); + assert!(result.is_err()); + } + + #[test] + fn it_should_fail_conversion_when_lxd_profile_name_starts_with_dash() { + let section = ProviderSection::Lxd(LxdProviderSection { + profile_name: "-invalid".to_string(), + }); + let result = section.to_provider_config(); + assert!(result.is_err()); + } + + #[test] + fn it_should_fail_conversion_when_lxd_profile_name_ends_with_dash() { + let section = ProviderSection::Lxd(LxdProviderSection { + profile_name: "invalid-".to_string(), + }); + let result = section.to_provider_config(); + assert!(result.is_err()); + } + + #[test] + fn it_should_be_cloneable() { + let section = create_lxd_section(); + let cloned = section.clone(); + assert_eq!(section, cloned); + } + + #[test] + fn it_should_implement_debug_trait() { + let section = create_lxd_section(); + let debug = format!("{section:?}"); + assert!(debug.contains("Lxd")); + assert!(debug.contains("profile_name")); + } +} diff --git a/src/domain/mod.rs b/src/domain/mod.rs index 49a9036b..a80627e7 100644 --- a/src/domain/mod.rs +++ b/src/domain/mod.rs @@ -10,11 +10,13 @@ //! - `environment::state` - State marker types and type erasure for environment state machine //! - `instance_name` - LXD instance name validation and management //! - `profile_name` - LXD profile name validation and management +//! - `provider` - Infrastructure provider types (LXD, Hetzner) and configuration //! - `template` - Core template domain models and business logic pub mod environment; pub mod instance_name; pub mod profile_name; +pub mod provider; pub mod template; // Re-export commonly used domain types for convenience @@ -25,4 +27,5 @@ pub use environment::{ }; pub use instance_name::{InstanceName, InstanceNameError}; pub use profile_name::{ProfileName, ProfileNameError}; +pub use provider::{HetznerConfig, LxdConfig, Provider, ProviderConfig}; pub use template::{TemplateEngine, TemplateEngineError, TemplateManager, TemplateManagerError}; diff --git a/src/domain/provider/config.rs b/src/domain/provider/config.rs new file mode 100644 index 00000000..25fb9976 --- /dev/null +++ b/src/domain/provider/config.rs @@ -0,0 +1,263 @@ +//! Provider Configuration Domain Types +//! +//! This module contains the `ProviderConfig` enum that aggregates all +//! provider-specific configurations. Individual provider configurations +//! are defined in their own modules (`lxd`, `hetzner`). +//! +//! These types use validated domain types (like `ProfileName`) and represent +//! the semantic meaning of provider configuration. +//! +//! For config types used in JSON deserialization, see +//! `application::command_handlers::create::config::provider`. +//! +//! # Layer Separation +//! +//! - **Domain types** (this module): `ProviderConfig`, `LxdConfig`, `HetznerConfig` +//! - Use validated domain types (e.g., `ProfileName`) +//! - Represent semantic meaning of configuration +//! +//! - **Application config types** (`application::command_handlers::create::config::provider`): +//! - `ProviderSection`, `LxdProviderSection`, `HetznerProviderSection` +//! - Use raw primitives (e.g., `String`) +//! - Handle JSON deserialization and conversion to domain types + +use serde::{Deserialize, Serialize}; + +use super::hetzner::HetznerConfig; +use super::lxd::LxdConfig; +use super::Provider; + +/// Provider-specific configuration (Domain Type) +/// +/// Each variant contains the configuration fields specific to that provider +/// using **validated domain types** (e.g., `ProfileName` instead of `String`). +/// +/// This is a tagged enum that serializes/deserializes based on the `"provider"` field. +/// +/// # Note on Layer Placement +/// +/// This is a **domain type** with validated fields. For JSON deserialization, +/// use `ProviderSection` in the application layer, then convert to this type. +/// +/// # Examples +/// +/// ```rust +/// use torrust_tracker_deployer_lib::domain::provider::{ProviderConfig, LxdConfig, Provider}; +/// use torrust_tracker_deployer_lib::domain::ProfileName; +/// +/// let lxd_config = ProviderConfig::Lxd(LxdConfig { +/// profile_name: ProfileName::new("torrust-profile").unwrap(), +/// }); +/// +/// assert_eq!(lxd_config.provider(), Provider::Lxd); +/// assert_eq!(lxd_config.provider_name(), "lxd"); +/// ``` +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "provider")] +pub enum ProviderConfig { + /// LXD provider configuration + #[serde(rename = "lxd")] + Lxd(LxdConfig), + + /// Hetzner provider configuration + #[serde(rename = "hetzner")] + Hetzner(HetznerConfig), +} + +impl ProviderConfig { + /// Returns the provider type. + /// + /// # Examples + /// + /// ```rust + /// use torrust_tracker_deployer_lib::domain::provider::{ProviderConfig, LxdConfig, Provider}; + /// use torrust_tracker_deployer_lib::domain::ProfileName; + /// + /// let config = ProviderConfig::Lxd(LxdConfig { + /// profile_name: ProfileName::new("test").unwrap(), + /// }); + /// assert_eq!(config.provider(), Provider::Lxd); + /// ``` + #[must_use] + pub fn provider(&self) -> Provider { + match self { + Self::Lxd(_) => Provider::Lxd, + Self::Hetzner(_) => Provider::Hetzner, + } + } + + /// Returns the provider name as used in directory paths. + /// + /// This is a convenience method that delegates to `self.provider().as_str()`. + /// + /// # Examples + /// + /// ```rust + /// use torrust_tracker_deployer_lib::domain::provider::{ProviderConfig, LxdConfig}; + /// use torrust_tracker_deployer_lib::domain::ProfileName; + /// + /// let config = ProviderConfig::Lxd(LxdConfig { + /// profile_name: ProfileName::new("test").unwrap(), + /// }); + /// assert_eq!(config.provider_name(), "lxd"); + /// ``` + #[must_use] + pub fn provider_name(&self) -> &'static str { + self.provider().as_str() + } + + /// Returns a reference to the LXD configuration if this is an LXD provider. + /// + /// # Returns + /// + /// - `Some(&LxdConfig)` if the provider is LXD + /// - `None` otherwise + /// + /// # Examples + /// + /// ```rust + /// use torrust_tracker_deployer_lib::domain::provider::{ProviderConfig, LxdConfig, HetznerConfig}; + /// use torrust_tracker_deployer_lib::domain::ProfileName; + /// + /// let lxd_config = ProviderConfig::Lxd(LxdConfig { + /// profile_name: ProfileName::new("test").unwrap(), + /// }); + /// assert!(lxd_config.as_lxd().is_some()); + /// + /// let hetzner_config = ProviderConfig::Hetzner(HetznerConfig { + /// api_token: "token".to_string(), + /// server_type: "cx22".to_string(), + /// location: "nbg1".to_string(), + /// }); + /// assert!(hetzner_config.as_lxd().is_none()); + /// ``` + #[must_use] + pub fn as_lxd(&self) -> Option<&LxdConfig> { + match self { + Self::Lxd(config) => Some(config), + Self::Hetzner(_) => None, + } + } + + /// Returns a reference to the Hetzner configuration if this is a Hetzner provider. + /// + /// # Returns + /// + /// - `Some(&HetznerConfig)` if the provider is Hetzner + /// - `None` otherwise + #[must_use] + pub fn as_hetzner(&self) -> Option<&HetznerConfig> { + match self { + Self::Lxd(_) => None, + Self::Hetzner(config) => Some(config), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::domain::ProfileName; + + fn create_lxd_config() -> ProviderConfig { + ProviderConfig::Lxd(LxdConfig { + profile_name: ProfileName::new("torrust-profile").unwrap(), + }) + } + + fn create_hetzner_config() -> ProviderConfig { + ProviderConfig::Hetzner(HetznerConfig { + api_token: "test-token".to_string(), + server_type: "cx22".to_string(), + location: "nbg1".to_string(), + }) + } + + #[test] + fn it_should_return_lxd_provider_when_lxd_config_queried() { + let config = create_lxd_config(); + assert_eq!(config.provider(), Provider::Lxd); + assert_eq!(config.provider_name(), "lxd"); + } + + #[test] + fn it_should_return_hetzner_provider_when_hetzner_config_queried() { + let config = create_hetzner_config(); + assert_eq!(config.provider(), Provider::Hetzner); + assert_eq!(config.provider_name(), "hetzner"); + } + + #[test] + fn it_should_return_some_lxd_config_when_as_lxd_called_on_lxd_variant() { + let config = create_lxd_config(); + assert!(config.as_lxd().is_some()); + assert!(config.as_hetzner().is_none()); + } + + #[test] + fn it_should_return_some_hetzner_config_when_as_hetzner_called_on_hetzner_variant() { + let config = create_hetzner_config(); + assert!(config.as_hetzner().is_some()); + assert!(config.as_lxd().is_none()); + } + + #[test] + fn it_should_serialize_lxd_config_to_json_with_provider_tag() { + let config = create_lxd_config(); + let json = serde_json::to_string(&config).unwrap(); + + assert!(json.contains("\"provider\":\"lxd\"")); + assert!(json.contains("\"profile_name\":\"torrust-profile\"")); + } + + #[test] + fn it_should_serialize_hetzner_config_to_json_with_provider_tag() { + let config = create_hetzner_config(); + let json = serde_json::to_string(&config).unwrap(); + + assert!(json.contains("\"provider\":\"hetzner\"")); + assert!(json.contains("\"api_token\":\"test-token\"")); + assert!(json.contains("\"server_type\":\"cx22\"")); + assert!(json.contains("\"location\":\"nbg1\"")); + } + + #[test] + fn it_should_deserialize_lxd_config_from_json_with_provider_tag() { + let json = r#"{"provider":"lxd","profile_name":"torrust-profile"}"#; + let config: ProviderConfig = serde_json::from_str(json).unwrap(); + + assert_eq!(config.provider(), Provider::Lxd); + assert_eq!( + config.as_lxd().unwrap().profile_name.as_str(), + "torrust-profile" + ); + } + + #[test] + fn it_should_deserialize_hetzner_config_from_json_with_provider_tag() { + let json = + r#"{"provider":"hetzner","api_token":"token","server_type":"cx22","location":"nbg1"}"#; + let config: ProviderConfig = serde_json::from_str(json).unwrap(); + + assert_eq!(config.provider(), Provider::Hetzner); + let hetzner = config.as_hetzner().unwrap(); + assert_eq!(hetzner.api_token, "token"); + assert_eq!(hetzner.server_type, "cx22"); + assert_eq!(hetzner.location, "nbg1"); + } + + #[test] + fn it_should_be_cloneable_when_cloned() { + let config = create_lxd_config(); + let cloned = config.clone(); + assert_eq!(config, cloned); + } + + #[test] + fn it_should_implement_debug_trait_when_formatted() { + let config = create_lxd_config(); + let debug = format!("{config:?}"); + assert!(debug.contains("Lxd")); + assert!(debug.contains("profile_name")); + } +} diff --git a/src/domain/provider/hetzner.rs b/src/domain/provider/hetzner.rs new file mode 100644 index 00000000..2208062e --- /dev/null +++ b/src/domain/provider/hetzner.rs @@ -0,0 +1,109 @@ +//! Hetzner Provider Domain Types +//! +//! This module contains domain types specific to the Hetzner provider. +//! Hetzner is used for production deployments, providing cost-effective +//! cloud infrastructure with good European presence. + +use serde::{Deserialize, Serialize}; + +/// Hetzner-specific configuration (Domain Type) +/// +/// Hetzner is used for production deployments. It provides cost-effective +/// cloud infrastructure with good European presence. +/// +/// Note: This struct is defined for enum completeness but will be +/// fully implemented in Phase 2 (Add Hetzner Provider task). +/// +/// # Examples +/// +/// ```rust +/// use torrust_tracker_deployer_lib::domain::provider::HetznerConfig; +/// +/// let config = HetznerConfig { +/// api_token: "your-api-token".to_string(), +/// server_type: "cx22".to_string(), +/// location: "nbg1".to_string(), +/// }; +/// ``` +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct HetznerConfig { + /// Hetzner API token for authentication. + /// + /// This should be kept secure and not committed to version control. + /// Note: Future improvement could use a validated `ApiToken` type. + pub api_token: String, + + /// Hetzner server type (e.g., "cx22", "cx32", "cpx11"). + /// + /// Determines the VM specifications (CPU, RAM, storage). + /// Note: Future improvement could use a validated `ServerType` type. + pub server_type: String, + + /// Hetzner datacenter location (e.g., "fsn1", "nbg1", "hel1"). + /// + /// Determines where the VM will be physically located. + /// Note: Future improvement could use a validated `Location` type. + pub location: String, +} + +#[cfg(test)] +mod tests { + use super::*; + + fn create_hetzner_config() -> HetznerConfig { + HetznerConfig { + api_token: "test-token".to_string(), + server_type: "cx22".to_string(), + location: "nbg1".to_string(), + } + } + + #[test] + fn it_should_store_all_fields_when_created() { + let config = HetznerConfig { + api_token: "token123".to_string(), + server_type: "cx32".to_string(), + location: "fsn1".to_string(), + }; + assert_eq!(config.api_token, "token123"); + assert_eq!(config.server_type, "cx32"); + assert_eq!(config.location, "fsn1"); + } + + #[test] + fn it_should_serialize_to_json_when_valid_config_exists() { + let config = create_hetzner_config(); + let json = serde_json::to_string(&config).unwrap(); + + assert!(json.contains("\"api_token\":\"test-token\"")); + assert!(json.contains("\"server_type\":\"cx22\"")); + assert!(json.contains("\"location\":\"nbg1\"")); + } + + #[test] + fn it_should_deserialize_from_json_when_valid_json_provided() { + let json = r#"{"api_token":"token","server_type":"cx22","location":"nbg1"}"#; + let config: HetznerConfig = serde_json::from_str(json).unwrap(); + + assert_eq!(config.api_token, "token"); + assert_eq!(config.server_type, "cx22"); + assert_eq!(config.location, "nbg1"); + } + + #[test] + fn it_should_be_cloneable_when_cloned() { + let config = create_hetzner_config(); + let cloned = config.clone(); + assert_eq!(config, cloned); + } + + #[test] + fn it_should_implement_debug_trait_when_formatted() { + let config = create_hetzner_config(); + let debug = format!("{config:?}"); + assert!(debug.contains("HetznerConfig")); + assert!(debug.contains("api_token")); + assert!(debug.contains("server_type")); + assert!(debug.contains("location")); + } +} diff --git a/src/domain/provider/lxd.rs b/src/domain/provider/lxd.rs new file mode 100644 index 00000000..eb52a604 --- /dev/null +++ b/src/domain/provider/lxd.rs @@ -0,0 +1,87 @@ +//! LXD Provider Domain Types +//! +//! This module contains domain types specific to the LXD provider. +//! LXD is used for local development and testing, providing fast VM creation +//! with no cloud costs, ideal for E2E tests and CI environments. + +use serde::{Deserialize, Serialize}; + +use crate::domain::ProfileName; + +/// LXD-specific configuration (Domain Type) +/// +/// LXD is used for local development and testing. It provides fast VM creation +/// with no cloud costs, making it ideal for E2E tests and CI environments. +/// +/// Uses validated domain types (e.g., `ProfileName`). +/// +/// # Examples +/// +/// ```rust +/// use torrust_tracker_deployer_lib::domain::provider::LxdConfig; +/// use torrust_tracker_deployer_lib::domain::ProfileName; +/// +/// let config = LxdConfig { +/// profile_name: ProfileName::new("torrust-profile-dev").unwrap(), +/// }; +/// assert_eq!(config.profile_name.as_str(), "torrust-profile-dev"); +/// ``` +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct LxdConfig { + /// LXD profile name for the instance (validated domain type). + /// + /// This profile must exist in LXD and typically configures + /// networking, storage, and resource limits. + pub profile_name: ProfileName, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn it_should_store_validated_profile_name_when_created() { + let profile_name = ProfileName::new("test-profile").unwrap(); + let config = LxdConfig { + profile_name: profile_name.clone(), + }; + assert_eq!(config.profile_name, profile_name); + } + + #[test] + fn it_should_serialize_to_json_when_valid_config_exists() { + let config = LxdConfig { + profile_name: ProfileName::new("torrust-profile").unwrap(), + }; + let json = serde_json::to_string(&config).unwrap(); + + assert!(json.contains("\"profile_name\":\"torrust-profile\"")); + } + + #[test] + fn it_should_deserialize_from_json_when_valid_json_provided() { + let json = r#"{"profile_name":"torrust-profile"}"#; + let config: LxdConfig = serde_json::from_str(json).unwrap(); + + assert_eq!(config.profile_name.as_str(), "torrust-profile"); + } + + #[test] + fn it_should_be_cloneable_when_cloned() { + let config = LxdConfig { + profile_name: ProfileName::new("test").unwrap(), + }; + let cloned = config.clone(); + assert_eq!(config, cloned); + } + + #[test] + fn it_should_implement_debug_trait_when_formatted() { + let config = LxdConfig { + profile_name: ProfileName::new("test").unwrap(), + }; + let debug = format!("{config:?}"); + assert!(debug.contains("LxdConfig")); + assert!(debug.contains("profile_name")); + } +} diff --git a/src/domain/provider/mod.rs b/src/domain/provider/mod.rs new file mode 100644 index 00000000..d3e6e4e7 --- /dev/null +++ b/src/domain/provider/mod.rs @@ -0,0 +1,47 @@ +//! Infrastructure provider types +//! +//! This module defines the `Provider` enum and provider-specific configuration +//! domain types. These are core business concepts used throughout the codebase. +//! +//! # Module Structure +//! +//! Each provider has its own submodule for extensibility: +//! - `lxd` - LXD local development provider configuration +//! - `hetzner` - Hetzner cloud production provider configuration +//! +//! # Layer Separation +//! +//! - **Domain types** (this module): `Provider`, `ProviderConfig`, `LxdConfig`, `HetznerConfig` +//! - Use validated domain types (e.g., `ProfileName`) +//! - Represent semantic meaning of configuration +//! +//! - **Application config types** (`application::command_handlers::create::config::provider`): +//! - `ProviderSection`, `LxdProviderSection`, `HetznerProviderSection` +//! - Use raw primitives (e.g., `String`) +//! - Handle JSON deserialization and conversion to domain types +//! +//! # Usage +//! +//! ```rust +//! use torrust_tracker_deployer_lib::domain::provider::{Provider, ProviderConfig, LxdConfig}; +//! use torrust_tracker_deployer_lib::domain::ProfileName; +//! +//! // Create a provider configuration +//! let config = ProviderConfig::Lxd(LxdConfig { +//! profile_name: ProfileName::new("torrust-profile").unwrap(), +//! }); +//! +//! // Access provider information +//! assert_eq!(config.provider(), Provider::Lxd); +//! assert_eq!(config.provider_name(), "lxd"); +//! ``` + +mod config; +mod hetzner; +mod lxd; +mod provider_type; + +pub use config::ProviderConfig; +pub use hetzner::HetznerConfig; +pub use lxd::LxdConfig; +pub use provider_type::Provider; diff --git a/src/domain/provider/provider_type.rs b/src/domain/provider/provider_type.rs new file mode 100644 index 00000000..fdce97e1 --- /dev/null +++ b/src/domain/provider/provider_type.rs @@ -0,0 +1,120 @@ +//! Provider enum representing available infrastructure providers. +//! +//! This module defines the `Provider` enum which represents the available +//! infrastructure providers for deploying Torrust Tracker environments. + +use serde::{Deserialize, Serialize}; + +/// Supported infrastructure providers +/// +/// This enum represents the available infrastructure providers for deploying +/// Torrust Tracker environments. It's a domain concept used throughout the +/// codebase to determine provider-specific behavior. +/// +/// # Providers +/// +/// - **LXD**: Local development and testing provider. Fast VM creation with no +/// cloud costs, ideal for E2E tests and CI environments. +/// - **Hetzner**: Production cloud provider. Cost-effective with good European +/// presence, suitable for production deployments. +/// +/// # Examples +/// +/// ```rust +/// use torrust_tracker_deployer_lib::domain::provider::Provider; +/// +/// let provider = Provider::Lxd; +/// assert_eq!(provider.as_str(), "lxd"); +/// assert_eq!(provider.to_string(), "lxd"); +/// ``` +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum Provider { + /// LXD - Local development and testing + Lxd, + /// Hetzner Cloud - Production deployments + Hetzner, +} + +impl Provider { + /// Returns the provider name as used in directory paths. + /// + /// This is used to construct paths like `templates/tofu/{provider}/`. + /// + /// # Examples + /// + /// ```rust + /// use torrust_tracker_deployer_lib::domain::provider::Provider; + /// + /// assert_eq!(Provider::Lxd.as_str(), "lxd"); + /// assert_eq!(Provider::Hetzner.as_str(), "hetzner"); + /// ``` + #[must_use] + pub fn as_str(&self) -> &'static str { + match self { + Self::Lxd => "lxd", + Self::Hetzner => "hetzner", + } + } +} + +impl std::fmt::Display for Provider { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.as_str()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn it_should_return_lowercase_string_when_as_str_called() { + assert_eq!(Provider::Lxd.as_str(), "lxd"); + assert_eq!(Provider::Hetzner.as_str(), "hetzner"); + } + + #[test] + fn it_should_return_lowercase_string_when_displayed() { + assert_eq!(format!("{}", Provider::Lxd), "lxd"); + assert_eq!(format!("{}", Provider::Hetzner), "hetzner"); + } + + #[test] + fn it_should_serialize_to_lowercase_json_string() { + let lxd_json = serde_json::to_string(&Provider::Lxd).unwrap(); + let hetzner_json = serde_json::to_string(&Provider::Hetzner).unwrap(); + + assert_eq!(lxd_json, "\"lxd\""); + assert_eq!(hetzner_json, "\"hetzner\""); + } + + #[test] + fn it_should_deserialize_from_lowercase_json_string() { + let lxd: Provider = serde_json::from_str("\"lxd\"").unwrap(); + let hetzner: Provider = serde_json::from_str("\"hetzner\"").unwrap(); + + assert_eq!(lxd, Provider::Lxd); + assert_eq!(hetzner, Provider::Hetzner); + } + + #[test] + fn it_should_be_copy_and_clone_when_assigned() { + let provider = Provider::Lxd; + let copied = provider; // Copy happens implicitly + assert_eq!(provider, copied); + } + + #[test] + fn it_should_be_hashable_when_inserted_in_hashset() { + use std::collections::HashSet; + + let mut set = HashSet::new(); + set.insert(Provider::Lxd); + set.insert(Provider::Hetzner); + + assert!(set.contains(&Provider::Lxd)); + assert!(set.contains(&Provider::Hetzner)); + assert_eq!(set.len(), 2); + } +}