From 68d788139b2f349d47da2a308905c15eaa48ffd8 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 25 Oct 2025 14:45:50 +0000 Subject: [PATCH 1/2] Initial plan From fea50f452841aeb742de9c3746e251d13613b90d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 25 Oct 2025 15:02:25 +0000 Subject: [PATCH 2/2] feat: [#35] implement core configuration infrastructure Co-authored-by: josecelano <58816+josecelano@users.noreply.github.com> --- src/domain/config/environment_config.rs | 422 ++++++++++++++++++++ src/domain/config/errors.rs | 217 ++++++++++ src/domain/config/mod.rs | 102 +++++ src/domain/config/ssh_credentials_config.rs | 319 +++++++++++++++ src/domain/mod.rs | 2 + 5 files changed, 1062 insertions(+) create mode 100644 src/domain/config/environment_config.rs create mode 100644 src/domain/config/errors.rs create mode 100644 src/domain/config/mod.rs create mode 100644 src/domain/config/ssh_credentials_config.rs diff --git a/src/domain/config/environment_config.rs b/src/domain/config/environment_config.rs new file mode 100644 index 00000000..98ada66a --- /dev/null +++ b/src/domain/config/environment_config.rs @@ -0,0 +1,422 @@ +//! Environment creation configuration value object +//! +//! This module provides the `EnvironmentCreationConfig` type which represents +//! all configuration needed to create a deployment environment. It handles +//! deserialization from configuration sources and conversion to domain types. + +use serde::{Deserialize, Serialize}; + +use crate::adapters::ssh::SshCredentials; +use crate::domain::EnvironmentName; + +use super::errors::CreateConfigError; +use super::ssh_credentials_config::SshCredentialsConfig; + +/// Configuration for creating a deployment environment +/// +/// This is the top-level configuration object that contains all information +/// needed to create a new deployment environment. It deserializes from JSON +/// configuration and provides type-safe conversion to domain parameters. +/// +/// # Examples +/// +/// ```rust +/// use torrust_tracker_deployer_lib::domain::config::{EnvironmentCreationConfig, EnvironmentSection}; +/// +/// let json = r#"{ +/// "environment": { +/// "name": "dev" +/// }, +/// "ssh_credentials": { +/// "private_key_path": "fixtures/testing_rsa", +/// "public_key_path": "fixtures/testing_rsa.pub" +/// } +/// }"#; +/// +/// let config: EnvironmentCreationConfig = serde_json::from_str(json)?; +/// # Ok::<(), Box>(()) +/// ``` +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct EnvironmentCreationConfig { + /// Environment-specific settings + pub environment: EnvironmentSection, + + /// SSH credentials configuration + pub ssh_credentials: SshCredentialsConfig, +} + +/// Environment-specific configuration section +/// +/// Contains configuration specific to the environment being created. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct EnvironmentSection { + /// Name of the environment to create + /// + /// Must follow environment naming rules: + /// - Lowercase letters and numbers only + /// - Dashes as word separators + /// - Cannot start or end with separators + /// - Cannot start with numbers + pub name: String, +} + +impl EnvironmentCreationConfig { + /// Creates a new environment creation configuration + /// + /// # Examples + /// + /// ```rust + /// use torrust_tracker_deployer_lib::domain::config::{ + /// EnvironmentCreationConfig, EnvironmentSection, SshCredentialsConfig + /// }; + /// + /// let config = EnvironmentCreationConfig::new( + /// EnvironmentSection { + /// name: "dev".to_string(), + /// }, + /// SshCredentialsConfig::new( + /// "fixtures/testing_rsa".to_string(), + /// "fixtures/testing_rsa.pub".to_string(), + /// "torrust".to_string(), + /// 22, + /// ), + /// ); + /// ``` + #[must_use] + pub fn new(environment: EnvironmentSection, ssh_credentials: SshCredentialsConfig) -> Self { + Self { + environment, + ssh_credentials, + } + } + + /// Converts configuration to domain parameters for `Environment::new()` + /// + /// This method validates all configuration values and converts them to + /// strongly-typed domain objects that can be used to create an Environment. + /// + /// # Returns + /// + /// Returns a tuple of `(EnvironmentName, SshCredentials, u16)` that matches + /// the signature of `Environment::new()`. + /// + /// # Validation + /// + /// - Environment name must follow naming rules (see `EnvironmentName`) + /// - SSH username must follow Linux username requirements (see `Username`) + /// - SSH key files must exist and be accessible + /// + /// # Errors + /// + /// Returns `CreateConfigError` if: + /// - Environment name is invalid + /// - SSH username is invalid + /// - SSH private key file does not exist + /// - SSH public key file does not exist + /// + /// # Examples + /// + /// ```rust + /// use torrust_tracker_deployer_lib::domain::config::{ + /// EnvironmentCreationConfig, EnvironmentSection, SshCredentialsConfig + /// }; + /// use torrust_tracker_deployer_lib::domain::Environment; + /// + /// let config = EnvironmentCreationConfig::new( + /// EnvironmentSection { + /// name: "dev".to_string(), + /// }, + /// SshCredentialsConfig::new( + /// "fixtures/testing_rsa".to_string(), + /// "fixtures/testing_rsa.pub".to_string(), + /// "torrust".to_string(), + /// 22, + /// ), + /// ); + /// + /// let (name, credentials, port) = config.to_environment_params()?; + /// let environment = Environment::new(name, credentials, port); + /// # Ok::<(), Box>(()) + /// ``` + pub fn to_environment_params( + self, + ) -> Result<(EnvironmentName, SshCredentials, u16), CreateConfigError> { + // Convert environment name string to domain type + let environment_name = EnvironmentName::new(&self.environment.name)?; + + // Get SSH port before consuming ssh_credentials + let ssh_port = self.ssh_credentials.port; + + // Convert SSH credentials config to domain type + let ssh_credentials = self.ssh_credentials.to_ssh_credentials()?; + + Ok((environment_name, ssh_credentials, ssh_port)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_create_environment_creation_config() { + let config = EnvironmentCreationConfig::new( + EnvironmentSection { + name: "dev".to_string(), + }, + SshCredentialsConfig::new( + "fixtures/testing_rsa".to_string(), + "fixtures/testing_rsa.pub".to_string(), + "torrust".to_string(), + 22, + ), + ); + + assert_eq!(config.environment.name, "dev"); + assert_eq!( + config.ssh_credentials.private_key_path, + "fixtures/testing_rsa" + ); + assert_eq!(config.ssh_credentials.username, "torrust"); + assert_eq!(config.ssh_credentials.port, 22); + } + + #[test] + fn test_deserialize_from_json() { + let json = r#"{ + "environment": { + "name": "e2e-config" + }, + "ssh_credentials": { + "private_key_path": "fixtures/testing_rsa", + "public_key_path": "fixtures/testing_rsa.pub" + } + }"#; + + let config: EnvironmentCreationConfig = serde_json::from_str(json).unwrap(); + + assert_eq!(config.environment.name, "e2e-config"); + assert_eq!( + config.ssh_credentials.private_key_path, + "fixtures/testing_rsa" + ); + assert_eq!( + config.ssh_credentials.public_key_path, + "fixtures/testing_rsa.pub" + ); + assert_eq!(config.ssh_credentials.username, "torrust"); // default + assert_eq!(config.ssh_credentials.port, 22); // default + } + + #[test] + fn test_deserialize_from_json_with_custom_values() { + let json = r#"{ + "environment": { + "name": "production" + }, + "ssh_credentials": { + "private_key_path": "keys/prod_key", + "public_key_path": "keys/prod_key.pub", + "username": "ubuntu", + "port": 2222 + } + }"#; + + let config: EnvironmentCreationConfig = serde_json::from_str(json).unwrap(); + + assert_eq!(config.environment.name, "production"); + assert_eq!(config.ssh_credentials.private_key_path, "keys/prod_key"); + assert_eq!(config.ssh_credentials.public_key_path, "keys/prod_key.pub"); + assert_eq!(config.ssh_credentials.username, "ubuntu"); + assert_eq!(config.ssh_credentials.port, 2222); + } + + #[test] + fn test_serialize_environment_creation_config() { + let config = EnvironmentCreationConfig::new( + EnvironmentSection { + name: "staging".to_string(), + }, + SshCredentialsConfig::new( + "keys/stage_key".to_string(), + "keys/stage_key.pub".to_string(), + "deploy".to_string(), + 22, + ), + ); + + let json = serde_json::to_string(&config).unwrap(); + let deserialized: EnvironmentCreationConfig = serde_json::from_str(&json).unwrap(); + + assert_eq!(config, deserialized); + } + + #[test] + fn test_convert_to_environment_params_success() { + let config = EnvironmentCreationConfig::new( + EnvironmentSection { + name: "dev".to_string(), + }, + SshCredentialsConfig::new( + "fixtures/testing_rsa".to_string(), + "fixtures/testing_rsa.pub".to_string(), + "torrust".to_string(), + 22, + ), + ); + + let result = config.to_environment_params(); + assert!(result.is_ok(), "Expected successful conversion"); + + let (name, credentials, port) = result.unwrap(); + + assert_eq!(name.as_str(), "dev"); + assert_eq!(credentials.ssh_username.as_str(), "torrust"); + assert_eq!(port, 22); + } + + #[test] + fn test_convert_to_environment_params_invalid_environment_name() { + let config = EnvironmentCreationConfig::new( + EnvironmentSection { + name: "Invalid_Name".to_string(), // uppercase - invalid + }, + SshCredentialsConfig::new( + "fixtures/testing_rsa".to_string(), + "fixtures/testing_rsa.pub".to_string(), + "torrust".to_string(), + 22, + ), + ); + + let result = config.to_environment_params(); + assert!(result.is_err()); + + match result.unwrap_err() { + CreateConfigError::InvalidEnvironmentName(_) => { + // Expected error + } + other => panic!("Expected InvalidEnvironmentName error, got: {other:?}"), + } + } + + #[test] + fn test_convert_to_environment_params_invalid_username() { + let config = EnvironmentCreationConfig::new( + EnvironmentSection { + name: "dev".to_string(), + }, + SshCredentialsConfig::new( + "fixtures/testing_rsa".to_string(), + "fixtures/testing_rsa.pub".to_string(), + "123invalid".to_string(), // starts with number - invalid + 22, + ), + ); + + let result = config.to_environment_params(); + assert!(result.is_err()); + + match result.unwrap_err() { + CreateConfigError::InvalidUsername(_) => { + // Expected error + } + other => panic!("Expected InvalidUsername error, got: {other:?}"), + } + } + + #[test] + fn test_convert_to_environment_params_private_key_not_found() { + let config = EnvironmentCreationConfig::new( + EnvironmentSection { + name: "dev".to_string(), + }, + SshCredentialsConfig::new( + "/nonexistent/key".to_string(), + "fixtures/testing_rsa.pub".to_string(), + "torrust".to_string(), + 22, + ), + ); + + let result = config.to_environment_params(); + assert!(result.is_err()); + + match result.unwrap_err() { + CreateConfigError::PrivateKeyNotFound { .. } => { + // Expected error + } + other => panic!("Expected PrivateKeyNotFound error, got: {other:?}"), + } + } + + #[test] + fn test_convert_to_environment_params_public_key_not_found() { + let config = EnvironmentCreationConfig::new( + EnvironmentSection { + name: "dev".to_string(), + }, + SshCredentialsConfig::new( + "fixtures/testing_rsa".to_string(), + "/nonexistent/key.pub".to_string(), + "torrust".to_string(), + 22, + ), + ); + + let result = config.to_environment_params(); + assert!(result.is_err()); + + match result.unwrap_err() { + CreateConfigError::PublicKeyNotFound { .. } => { + // Expected error + } + other => panic!("Expected PublicKeyNotFound error, got: {other:?}"), + } + } + + #[test] + fn test_integration_with_environment_new() { + // This test verifies that the converted parameters work with Environment::new() + use crate::domain::Environment; + + let config = EnvironmentCreationConfig::new( + EnvironmentSection { + name: "test-env".to_string(), + }, + SshCredentialsConfig::new( + "fixtures/testing_rsa".to_string(), + "fixtures/testing_rsa.pub".to_string(), + "torrust".to_string(), + 22, + ), + ); + + let (name, credentials, port) = config.to_environment_params().unwrap(); + let environment = Environment::new(name, credentials, port); + + assert_eq!(environment.name().as_str(), "test-env"); + assert_eq!(environment.ssh_username().as_str(), "torrust"); + assert_eq!(environment.ssh_port(), 22); + } + + #[test] + fn test_round_trip_serialization() { + let original = EnvironmentCreationConfig::new( + EnvironmentSection { + name: "dev".to_string(), + }, + SshCredentialsConfig::new( + "fixtures/testing_rsa".to_string(), + "fixtures/testing_rsa.pub".to_string(), + "torrust".to_string(), + 22, + ), + ); + + let json = serde_json::to_string_pretty(&original).unwrap(); + let deserialized: EnvironmentCreationConfig = serde_json::from_str(&json).unwrap(); + + assert_eq!(original, deserialized); + } +} diff --git a/src/domain/config/errors.rs b/src/domain/config/errors.rs new file mode 100644 index 00000000..9efb8229 --- /dev/null +++ b/src/domain/config/errors.rs @@ -0,0 +1,217 @@ +//! Configuration validation errors with actionable help messages +//! +//! This module defines error types for configuration validation failures. +//! All errors follow the project's error handling principles by providing +//! clear, contextual, and actionable error messages with `.help()` methods. + +use std::path::PathBuf; +use thiserror::Error; + +use crate::domain::EnvironmentNameError; +use crate::shared::UsernameError; + +/// Errors that can occur during configuration validation +/// +/// These errors follow the project's error handling principles by providing +/// clear, contextual, and actionable error messages through the `.help()` method. +#[derive(Debug, Error)] +pub enum CreateConfigError { + /// Invalid environment name format + #[error("Invalid environment name: {0}")] + InvalidEnvironmentName(#[from] EnvironmentNameError), + + /// Invalid SSH username format + #[error("Invalid SSH username: {0}")] + InvalidUsername(#[from] UsernameError), + + /// SSH private key file not found + #[error("SSH private key file not found: {path}")] + PrivateKeyNotFound { path: PathBuf }, + + /// SSH public key file not found + #[error("SSH public key file not found: {path}")] + PublicKeyNotFound { path: PathBuf }, + + /// Invalid SSH port (must be 1-65535) + #[error("Invalid SSH port: {port} (must be between 1 and 65535)")] + InvalidPort { port: u16 }, +} + +impl CreateConfigError { + /// Provides detailed troubleshooting guidance for configuration errors + /// + /// Returns context-specific help text that guides users toward resolving + /// the configuration issue. This implements the project's tiered help + /// system pattern for actionable error messages. + /// + /// # Examples + /// + /// ```rust + /// use torrust_tracker_deployer_lib::domain::config::CreateConfigError; + /// use std::path::PathBuf; + /// + /// let error = CreateConfigError::PrivateKeyNotFound { + /// path: PathBuf::from("/home/user/.ssh/missing_key"), + /// }; + /// + /// let help = error.help(); + /// assert!(help.contains("private key file")); + /// assert!(help.contains("Check that the file path is correct")); + /// ``` + #[must_use] + pub fn help(&self) -> &'static str { + match self { + Self::InvalidEnvironmentName(_) => { + "Environment name validation failed.\n\ + \n\ + Valid environment names must:\n\ + - Contain only lowercase letters (a-z) and numbers (0-9)\n\ + - Use dashes (-) as word separators\n\ + - Not start or end with separators\n\ + - Not start with numbers\n\ + \n\ + Examples: 'dev', 'staging', 'e2e-config', 'production'\n\ + \n\ + Fix: Update the environment name in your configuration to follow these rules." + } + Self::InvalidUsername(_) => { + "SSH username validation failed.\n\ + \n\ + Valid usernames must:\n\ + - Be 1-32 characters long\n\ + - Start with a letter (a-z, A-Z) or underscore (_)\n\ + - Contain only letters, digits, underscores, and hyphens\n\ + \n\ + Common usernames: 'ubuntu', 'torrust', 'deploy', 'admin'\n\ + \n\ + Fix: Update the SSH username in your configuration to follow Linux username requirements." + } + Self::PrivateKeyNotFound { .. } => { + "SSH private key file not found.\n\ + \n\ + The specified private key file does not exist or is not accessible.\n\ + \n\ + Common causes:\n\ + - Incorrect file path in configuration\n\ + - File was moved or deleted\n\ + - Insufficient permissions to access the file\n\ + \n\ + Fix:\n\ + 1. Check that the file path is correct in your configuration\n\ + 2. Verify the file exists: ls -la \n\ + 3. Ensure you have read permissions on the file\n\ + 4. Generate a new SSH key pair if needed: ssh-keygen -t rsa -b 4096" + } + Self::PublicKeyNotFound { .. } => { + "SSH public key file not found.\n\ + \n\ + The specified public key file does not exist or is not accessible.\n\ + \n\ + Common causes:\n\ + - Incorrect file path in configuration\n\ + - File was moved or deleted\n\ + - Insufficient permissions to access the file\n\ + \n\ + Fix:\n\ + 1. Check that the file path is correct in your configuration\n\ + 2. Verify the file exists: ls -la \n\ + 3. Ensure you have read permissions on the file\n\ + 4. Generate public key from private key if needed: ssh-keygen -y -f > " + } + Self::InvalidPort { .. } => { + "Invalid SSH port number.\n\ + \n\ + SSH port must be between 1 and 65535.\n\ + \n\ + Common SSH ports:\n\ + - 22 (standard SSH port)\n\ + - 2222 (common alternative)\n\ + \n\ + Fix: Update the SSH port in your configuration to a valid port number (1-65535)." + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::domain::EnvironmentName; + use crate::shared::Username; + + #[test] + fn test_invalid_environment_name_error() { + let result = EnvironmentName::new("Invalid_Name"); + assert!(result.is_err()); + + let error = CreateConfigError::from(result.unwrap_err()); + assert!(error.to_string().contains("Invalid environment name")); + assert!(error.help().contains("lowercase letters")); + assert!(error.help().contains("dashes")); + } + + #[test] + fn test_invalid_username_error() { + let result = Username::new("123invalid"); + assert!(result.is_err()); + + let error = CreateConfigError::from(result.unwrap_err()); + assert!(error.to_string().contains("Invalid SSH username")); + assert!(error.help().contains("Start with a letter")); + assert!(error.help().contains("1-32 characters")); + } + + #[test] + fn test_private_key_not_found_error() { + let error = CreateConfigError::PrivateKeyNotFound { + path: PathBuf::from("/nonexistent/key"), + }; + assert!(error.to_string().contains("private key file not found")); + assert!(error.to_string().contains("/nonexistent/key")); + assert!(error.help().contains("Check that the file path is correct")); + assert!(error.help().contains("ssh-keygen")); + } + + #[test] + fn test_public_key_not_found_error() { + let error = CreateConfigError::PublicKeyNotFound { + path: PathBuf::from("/nonexistent/key.pub"), + }; + assert!(error.to_string().contains("public key file not found")); + assert!(error.to_string().contains("/nonexistent/key.pub")); + assert!(error.help().contains("Check that the file path is correct")); + assert!(error.help().contains("ssh-keygen -y")); + } + + #[test] + fn test_invalid_port_error() { + let error = CreateConfigError::InvalidPort { port: 0 }; + assert!(error.to_string().contains("Invalid SSH port")); + assert!(error.to_string().contains("must be between 1 and 65535")); + assert!(error.help().contains("22")); + assert!(error.help().contains("2222")); + } + + #[test] + fn test_all_errors_have_help() { + // Verify all error variants have help text + let errors = vec![ + CreateConfigError::PrivateKeyNotFound { + path: PathBuf::from("/test"), + }, + CreateConfigError::PublicKeyNotFound { + path: PathBuf::from("/test"), + }, + CreateConfigError::InvalidPort { port: 0 }, + ]; + + for error in errors { + let help = error.help(); + assert!(!help.is_empty(), "Help text should not be empty"); + assert!( + help.contains("Fix:") || help.contains("Common"), + "Help should contain actionable guidance" + ); + } + } +} diff --git a/src/domain/config/mod.rs b/src/domain/config/mod.rs new file mode 100644 index 00000000..0ea1ff99 --- /dev/null +++ b/src/domain/config/mod.rs @@ -0,0 +1,102 @@ +//! Configuration Domain Module +//! +//! This module provides configuration value objects and validation for creating +//! deployment environments. It sits at the boundary between external configuration +//! sources (JSON files, CLI arguments, etc.) and the internal domain model. +//! +//! ## Architecture +//! +//! The configuration layer is distinct from both the adapters and domain layers: +//! +//! - **Configuration Layer** (`domain::config`): String-based configuration objects +//! that deserialize from external sources (JSON, TOML, CLI) +//! - **Domain Layer** (`domain::environment`): Strongly-typed domain entities +//! with business validation +//! - **Adapter Layer** (`adapters::ssh`): Infrastructure-specific implementations +//! +//! ## Key Components +//! +//! ### Value Objects +//! +//! - `EnvironmentCreationConfig` - Top-level configuration for environment creation +//! - `SshCredentialsConfig` - SSH credentials configuration (config layer) +//! - `EnvironmentSection` - Environment-specific settings +//! +//! 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. +//! +//! ### Type Conversion +//! +//! Configuration objects provide `to_*` methods that convert string-based +//! configuration to strongly-typed domain objects: +//! +//! - String environment name → `EnvironmentName` +//! - String username → `Username` +//! - String paths → `PathBuf` +//! +//! ### Error Handling +//! +//! All errors implement the `.help()` method following the project's tiered +//! help system pattern, providing actionable guidance for resolving issues. +//! +//! ## Usage Example +//! +//! ```rust +//! use torrust_tracker_deployer_lib::domain::config::{ +//! EnvironmentCreationConfig, EnvironmentSection, SshCredentialsConfig +//! }; +//! use torrust_tracker_deployer_lib::domain::Environment; +//! +//! // Deserialize configuration from JSON +//! let json = r#"{ +//! "environment": { +//! "name": "dev" +//! }, +//! "ssh_credentials": { +//! "private_key_path": "fixtures/testing_rsa", +//! "public_key_path": "fixtures/testing_rsa.pub" +//! } +//! }"#; +//! +//! let config: EnvironmentCreationConfig = serde_json::from_str(json)?; +//! +//! // Convert to domain parameters +//! let (name, credentials, port) = config.to_environment_params()?; +//! +//! // Create domain entity +//! let environment = Environment::new(name, credentials, port); +//! +//! # Ok::<(), Box>(()) +//! ``` +//! +//! ## Validation Strategy +//! +//! Validation occurs in two phases: +//! +//! 1. **Format Validation** (during conversion): +//! - Environment name format (via `EnvironmentName::new()`) +//! - Username format (via `Username::new()`) +//! - Path string to `PathBuf` conversion +//! +//! 2. **Business Validation** (during conversion): +//! - SSH key file existence +//! - SSH key file accessibility +//! - Domain-specific business rules +//! +//! ## Design Principles +//! +//! - **Type Safety**: String-based config → strongly-typed domain objects +//! - **Single Responsibility**: Config objects only handle deserialization and conversion +//! - **Explicit Errors**: All validation errors are explicit enum variants with context +//! - **Actionable Feedback**: All errors provide `.help()` with troubleshooting steps +//! - **Clean Separation**: Config layer is distinct from domain and adapter layers + +pub mod environment_config; +pub mod errors; +pub mod ssh_credentials_config; + +// Re-export commonly used types for convenience +pub use environment_config::{EnvironmentCreationConfig, EnvironmentSection}; +pub use errors::CreateConfigError; +pub use ssh_credentials_config::SshCredentialsConfig; diff --git a/src/domain/config/ssh_credentials_config.rs b/src/domain/config/ssh_credentials_config.rs new file mode 100644 index 00000000..a6b59dd0 --- /dev/null +++ b/src/domain/config/ssh_credentials_config.rs @@ -0,0 +1,319 @@ +//! SSH credentials configuration value object +//! +//! This module provides the `SshCredentialsConfig` type which represents +//! SSH credentials in the configuration layer (distinct from the adapter layer). +//! It handles string-based paths and usernames that will be converted to domain types. + +use serde::{Deserialize, Serialize}; +use std::path::PathBuf; + +use crate::adapters::ssh::SshCredentials; +use crate::shared::Username; + +use super::errors::CreateConfigError; + +/// Default SSH username for remote connections +const DEFAULT_SSH_USERNAME: &str = "torrust"; + +/// Default SSH port for remote connections +const DEFAULT_SSH_PORT: u16 = 22; + +/// SSH credentials configuration for remote instance authentication +/// +/// This is a configuration-layer value object that uses strings for paths +/// and username. It is distinct from `adapters::ssh::SshCredentials` which +/// uses domain types (`PathBuf`, `Username`). +/// +/// # Examples +/// +/// ```rust +/// use torrust_tracker_deployer_lib::domain::config::SshCredentialsConfig; +/// +/// let config = SshCredentialsConfig { +/// private_key_path: "fixtures/testing_rsa".to_string(), +/// public_key_path: "fixtures/testing_rsa.pub".to_string(), +/// username: "torrust".to_string(), +/// port: 22, +/// }; +/// ``` +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct SshCredentialsConfig { + /// Path to the SSH private key file (as string in config) + pub private_key_path: String, + + /// Path to the SSH public key file (as string in config) + pub public_key_path: String, + + /// SSH username (as string in config) + /// + /// Defaults to "torrust" if not specified in configuration. + #[serde(default = "default_ssh_username")] + pub username: String, + + /// SSH port for remote connections + /// + /// Defaults to 22 (standard SSH port) if not specified in configuration. + #[serde(default = "default_ssh_port")] + pub port: u16, +} + +impl SshCredentialsConfig { + /// Creates a new SSH credentials configuration with explicit values + /// + /// # Examples + /// + /// ```rust + /// use torrust_tracker_deployer_lib::domain::config::SshCredentialsConfig; + /// + /// let config = SshCredentialsConfig::new( + /// "fixtures/testing_rsa".to_string(), + /// "fixtures/testing_rsa.pub".to_string(), + /// "ubuntu".to_string(), + /// 2222, + /// ); + /// ``` + #[must_use] + pub fn new( + private_key_path: String, + public_key_path: String, + username: String, + port: u16, + ) -> Self { + Self { + private_key_path, + public_key_path, + username, + port, + } + } + + /// Converts configuration to domain SSH credentials + /// + /// This method validates the configuration and converts string-based + /// configuration values to strongly-typed domain objects. + /// + /// # Validation + /// + /// - Username must follow Linux username requirements + /// - Private key file must exist and be accessible + /// - Public key file must exist and be accessible + /// - Port must be valid (validated by caller, typically 1-65535) + /// + /// # Errors + /// + /// Returns `CreateConfigError` if: + /// - Username is invalid (see `Username` validation rules) + /// - Private key file does not exist + /// - Public key file does not exist + /// + /// # Examples + /// + /// ```rust + /// use torrust_tracker_deployer_lib::domain::config::SshCredentialsConfig; + /// + /// let config = SshCredentialsConfig::new( + /// "fixtures/testing_rsa".to_string(), + /// "fixtures/testing_rsa.pub".to_string(), + /// "torrust".to_string(), + /// 22, + /// ); + /// + /// let credentials = config.to_ssh_credentials()?; + /// # Ok::<(), Box>(()) + /// ``` + pub fn to_ssh_credentials(self) -> Result { + // Convert string username to domain Username type + let username = Username::new(&self.username)?; + + // Convert string paths to PathBuf + let private_key_path = PathBuf::from(&self.private_key_path); + let public_key_path = PathBuf::from(&self.public_key_path); + + // Validate SSH key files exist + if !private_key_path.exists() { + return Err(CreateConfigError::PrivateKeyNotFound { + path: private_key_path, + }); + } + + if !public_key_path.exists() { + return Err(CreateConfigError::PublicKeyNotFound { + path: public_key_path, + }); + } + + // Create domain credentials object + Ok(SshCredentials::new( + private_key_path, + public_key_path, + username, + )) + } +} + +/// Default SSH username for serde deserialization +fn default_ssh_username() -> String { + DEFAULT_SSH_USERNAME.to_string() +} + +/// Default SSH port for serde deserialization +fn default_ssh_port() -> u16 { + DEFAULT_SSH_PORT +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_create_ssh_credentials_config() { + let config = SshCredentialsConfig::new( + "fixtures/testing_rsa".to_string(), + "fixtures/testing_rsa.pub".to_string(), + "torrust".to_string(), + 22, + ); + + assert_eq!(config.private_key_path, "fixtures/testing_rsa"); + assert_eq!(config.public_key_path, "fixtures/testing_rsa.pub"); + assert_eq!(config.username, "torrust"); + assert_eq!(config.port, 22); + } + + #[test] + fn test_deserialize_with_defaults() { + let json = r#"{ + "private_key_path": "fixtures/testing_rsa", + "public_key_path": "fixtures/testing_rsa.pub" + }"#; + + let config: SshCredentialsConfig = serde_json::from_str(json).unwrap(); + + assert_eq!(config.private_key_path, "fixtures/testing_rsa"); + assert_eq!(config.public_key_path, "fixtures/testing_rsa.pub"); + assert_eq!(config.username, "torrust"); // default + assert_eq!(config.port, 22); // default + } + + #[test] + fn test_deserialize_with_explicit_values() { + let json = r#"{ + "private_key_path": "path/to/key", + "public_key_path": "path/to/key.pub", + "username": "ubuntu", + "port": 2222 + }"#; + + let config: SshCredentialsConfig = serde_json::from_str(json).unwrap(); + + assert_eq!(config.private_key_path, "path/to/key"); + assert_eq!(config.public_key_path, "path/to/key.pub"); + assert_eq!(config.username, "ubuntu"); + assert_eq!(config.port, 2222); + } + + #[test] + fn test_serialize_ssh_credentials_config() { + let config = SshCredentialsConfig::new( + "fixtures/testing_rsa".to_string(), + "fixtures/testing_rsa.pub".to_string(), + "deploy".to_string(), + 2222, + ); + + let json = serde_json::to_string(&config).unwrap(); + let deserialized: SshCredentialsConfig = serde_json::from_str(&json).unwrap(); + + assert_eq!(config, deserialized); + } + + #[test] + fn test_convert_to_ssh_credentials_success() { + let config = SshCredentialsConfig::new( + "fixtures/testing_rsa".to_string(), + "fixtures/testing_rsa.pub".to_string(), + "torrust".to_string(), + 22, + ); + + let result = config.to_ssh_credentials(); + assert!(result.is_ok(), "Expected successful conversion"); + + let credentials = result.unwrap(); + assert_eq!( + credentials.ssh_priv_key_path, + PathBuf::from("fixtures/testing_rsa") + ); + assert_eq!( + credentials.ssh_pub_key_path, + PathBuf::from("fixtures/testing_rsa.pub") + ); + assert_eq!(credentials.ssh_username.as_str(), "torrust"); + } + + #[test] + fn test_convert_to_ssh_credentials_invalid_username() { + let config = SshCredentialsConfig::new( + "fixtures/testing_rsa".to_string(), + "fixtures/testing_rsa.pub".to_string(), + "123invalid".to_string(), // starts with number - invalid + 22, + ); + + let result = config.to_ssh_credentials(); + assert!(result.is_err()); + + match result.unwrap_err() { + CreateConfigError::InvalidUsername(_) => { + // Expected error + } + other => panic!("Expected InvalidUsername error, got: {other:?}"), + } + } + + #[test] + fn test_convert_to_ssh_credentials_private_key_not_found() { + let config = SshCredentialsConfig::new( + "/nonexistent/private_key".to_string(), + "fixtures/testing_rsa.pub".to_string(), + "torrust".to_string(), + 22, + ); + + let result = config.to_ssh_credentials(); + assert!(result.is_err()); + + match result.unwrap_err() { + CreateConfigError::PrivateKeyNotFound { path } => { + assert_eq!(path, PathBuf::from("/nonexistent/private_key")); + } + other => panic!("Expected PrivateKeyNotFound error, got: {other:?}"), + } + } + + #[test] + fn test_convert_to_ssh_credentials_public_key_not_found() { + let config = SshCredentialsConfig::new( + "fixtures/testing_rsa".to_string(), + "/nonexistent/public_key.pub".to_string(), + "torrust".to_string(), + 22, + ); + + let result = config.to_ssh_credentials(); + assert!(result.is_err()); + + match result.unwrap_err() { + CreateConfigError::PublicKeyNotFound { path } => { + assert_eq!(path, PathBuf::from("/nonexistent/public_key.pub")); + } + other => panic!("Expected PublicKeyNotFound error, got: {other:?}"), + } + } + + #[test] + fn test_default_functions() { + assert_eq!(default_ssh_username(), "torrust"); + assert_eq!(default_ssh_port(), 22); + } +} diff --git a/src/domain/mod.rs b/src/domain/mod.rs index 49a9036b..8a9235e4 100644 --- a/src/domain/mod.rs +++ b/src/domain/mod.rs @@ -5,6 +5,7 @@ //! //! ## Components //! +//! - `config` - Configuration value objects and validation for environment creation //! - `environment` - Environment module with entity, name validation, and state management //! - `environment::name` - Environment name validation and management //! - `environment::state` - State marker types and type erasure for environment state machine @@ -12,6 +13,7 @@ //! - `profile_name` - LXD profile name validation and management //! - `template` - Core template domain models and business logic +pub mod config; pub mod environment; pub mod instance_name; pub mod profile_name;