diff --git a/docs/e2e-testing/manual/grafana-verification.md b/docs/e2e-testing/manual/grafana-verification.md index da0f1967..8a19a6ba 100644 --- a/docs/e2e-testing/manual/grafana-verification.md +++ b/docs/e2e-testing/manual/grafana-verification.md @@ -641,8 +641,8 @@ docker exec env | grep GF_SECURITY_ADMIN_PASSWORD This was a bug where the configured password wasn't being passed from the environment config to the `.env` file. It was fixed by updating: - `UserInputs::with_tracker()` to accept optional Prometheus/Grafana configs -- `EnvironmentContext::with_working_dir_and_tracker()` to pass configs through -- `Environment::with_working_dir_and_tracker()` to accept configs +- `EnvironmentContext::create()` to pass configs through +- `Environment::create()` to accept configs - Create handler to pass configs instead of using defaults **Solution:** diff --git a/docs/issues/281-strengthen-domain-invariant-enforcement.md b/docs/issues/281-strengthen-domain-invariant-enforcement.md index f26ce56f..00b86c5a 100644 --- a/docs/issues/281-strengthen-domain-invariant-enforcement.md +++ b/docs/issues/281-strengthen-domain-invariant-enforcement.md @@ -2,7 +2,7 @@ **Issue**: [#281](https://github.com/torrust/torrust-tracker-deployer/issues/281) **Type**: Refactor -**Status**: In Progress +**Status**: ✅ Completed ## Overview @@ -34,27 +34,28 @@ See: [`docs/refactors/plans/strengthen-domain-invariant-enforcement.md`](../refa - [x] `TryFrom for HttpApiConfig` - [x] Documentation and ADRs -### Phase 1: Tracker Configuration Types +### Phase 1: Tracker Configuration Types ✅ -- [ ] `UdpTrackerConfig` - validated constructor, private fields, getters -- [ ] `HttpTrackerConfig` - validated constructor, private fields, getters -- [ ] `HealthCheckApiConfig` - validated constructor, private fields, getters -- [ ] `TryFrom` implementations for each DTO section +- [x] `UdpTrackerConfig` - validated constructor, private fields, getters +- [x] `HttpTrackerConfig` - validated constructor, private fields, getters +- [x] `HealthCheckApiConfig` - validated constructor, private fields, getters +- [x] `TryFrom` implementations for each DTO section -### Phase 2: Cross-Cutting Invariants +### Phase 2: Cross-Cutting Invariants ✅ -- [ ] `TrackerCoreConfig` - database configuration validation -- [ ] `TrackerConfig` - validates at construction (socket conflicts) -- [ ] `UserInputs` - validated constructor (Grafana requires Prometheus) +- [x] `TrackerCoreConfig` - database configuration validation +- [x] `TrackerConfig` - validates at construction (socket conflicts) +- [x] `UserInputs` - validated constructor (Grafana requires Prometheus) +- [x] `HttpsConfig` - validated constructor (email validation) ## Acceptance Criteria -- [ ] All domain configuration types use validated constructors -- [ ] All fields are private with getter methods -- [ ] All types implement custom `Deserialize` with validation -- [ ] All DTO→Domain conversions use `TryFrom` trait -- [ ] Validation logic moved from application to domain layer -- [ ] Pre-commit checks pass: `./scripts/pre-commit.sh` +- [x] All domain configuration types use validated constructors +- [x] All fields are private with getter methods +- [x] All types implement custom `Deserialize` with validation +- [x] All DTO→Domain conversions use `TryFrom` trait +- [x] Validation logic moved from application to domain layer +- [x] Pre-commit checks pass: `./scripts/pre-commit.sh` ## Contributing Guide diff --git a/docs/refactors/plans/strengthen-domain-invariant-enforcement.md b/docs/refactors/plans/strengthen-domain-invariant-enforcement.md index e2261a07..7059da3c 100644 --- a/docs/refactors/plans/strengthen-domain-invariant-enforcement.md +++ b/docs/refactors/plans/strengthen-domain-invariant-enforcement.md @@ -32,15 +32,15 @@ This refactoring plan addresses DDD violations where domain types fail to enforc **Total Active Proposals**: 6 **Total Postponed**: 0 **Total Discarded**: 0 -**Completed**: 1 +**Completed**: 6 **In Progress**: 0 -**Not Started**: 5 +**Not Started**: 0 ### Phase Summary -- **Phase 0 - Foundation (High Impact, Low Effort)**: ⏳ 1/2 completed (50%) -- **Phase 1 - Tracker Config Hardening (High Impact, Medium Effort)**: ⏳ 0/2 completed (0%) -- **Phase 2 - Aggregate Invariants (Medium Impact, Low Effort)**: ⏳ 0/2 completed (0%) +- **Phase 0 - Foundation (High Impact, Low Effort)**: ✅ 2/2 completed (100%) +- **Phase 1 - Tracker Config Hardening (High Impact, Medium Effort)**: ✅ 2/2 completed (100%) +- **Phase 2 - Aggregate Invariants (Medium Impact, Low Effort)**: ✅ 2/2 completed (100%) ### Discarded Proposals @@ -330,7 +330,7 @@ impl TryFrom for HttpApiConfig { ### Proposal #1: Apply Same Pattern to UdpTrackerConfig, HttpTrackerConfig, HealthCheckApiConfig -**Status**: ⏳ Not Started +**Status**: ✅ Completed **Impact**: 🟢🟢🟢 High **Effort**: 🔵🔵 Medium **Priority**: P0 @@ -416,44 +416,44 @@ Consistent pattern across all tracker configuration types. #### Implementation Checklist -- [ ] **UdpTrackerConfig**: - - [ ] Create `UdpTrackerConfigError` enum with `help()` method - - [ ] Create `UdpTrackerConfigRaw` struct for deserialization - - [ ] Make fields private - - [ ] Add `UdpTrackerConfig::new()` with port validation - - [ ] Add getter methods - - [ ] Implement custom `Deserialize` - - [ ] Implement `TryFrom for UdpTrackerConfig` (standard trait conversion) - - [ ] Add `From` for `CreateConfigError` - - [ ] Update tests - -- [ ] **HttpTrackerConfig**: - - [ ] Create `HttpTrackerConfigError` enum (same variants as HttpApiConfigError minus admin_token) - - [ ] Create `HttpTrackerConfigRaw` struct - - [ ] Make fields private - - [ ] Add `HttpTrackerConfig::new()` with full validation - - [ ] Add getter methods - - [ ] Implement custom `Deserialize` - - [ ] Implement `TryFrom for HttpTrackerConfig` (standard trait conversion) - - [ ] Add `From` for `CreateConfigError` - - [ ] Update tests - -- [ ] **HealthCheckApiConfig**: - - [ ] Create `HealthCheckApiConfigError` enum - - [ ] Create `HealthCheckApiConfigRaw` struct - - [ ] Make fields private - - [ ] Add `HealthCheckApiConfig::new()` with full validation - - [ ] Add getter methods - - [ ] Implement custom `Deserialize` - - [ ] Implement `TryFrom for HealthCheckApiConfig` (standard trait conversion) - - [ ] Add `From` for `CreateConfigError` - - [ ] Update tests - -- [ ] **Final verification**: - - [ ] Remove redundant localhost+TLS tests from `TrackerConfig::validate()` tests - - [ ] Verify all tests pass - - [ ] Run clippy and fix any issues - - [ ] Run doc tests +- [x] **UdpTrackerConfig**: + - [x] Create `UdpTrackerConfigError` enum with `help()` method + - [x] Create `UdpTrackerConfigRaw` struct for deserialization + - [x] Make fields private + - [x] Add `UdpTrackerConfig::new()` with port validation + - [x] Add getter methods + - [x] Implement custom `Deserialize` + - [x] Implement `TryFrom for UdpTrackerConfig` (standard trait conversion) + - [x] Add `From` for `CreateConfigError` + - [x] Update tests + +- [x] **HttpTrackerConfig**: + - [x] Create `HttpTrackerConfigError` enum (same variants as HttpApiConfigError minus admin_token) + - [x] Create `HttpTrackerConfigRaw` struct + - [x] Make fields private + - [x] Add `HttpTrackerConfig::new()` with full validation + - [x] Add getter methods + - [x] Implement custom `Deserialize` + - [x] Implement `TryFrom for HttpTrackerConfig` (standard trait conversion) + - [x] Add `From` for `CreateConfigError` + - [x] Update tests + +- [x] **HealthCheckApiConfig**: + - [x] Create `HealthCheckApiConfigError` enum + - [x] Create `HealthCheckApiConfigRaw` struct + - [x] Make fields private + - [x] Add `HealthCheckApiConfig::new()` with full validation + - [x] Add getter methods + - [x] Implement custom `Deserialize` + - [x] Implement `TryFrom for HealthCheckApiConfig` (standard trait conversion) + - [x] Add `From` for `CreateConfigError` + - [x] Update tests + +- [x] **Final verification**: + - [x] Remove redundant localhost+TLS tests from `TrackerConfig::validate()` tests + - [x] Verify all tests pass + - [x] Run clippy and fix any issues + - [x] Run doc tests #### Estimated Effort @@ -472,6 +472,34 @@ Based on Proposal #0 experience: 4. Check that `TrackerConfig::validate()` still catches aggregate-level issues 5. Integration tests remain unchanged (behavior preserved) +#### Implementation Notes (Completed) + +**Key Files Modified:** + +- `src/domain/tracker/config/udp.rs` - Rewritten with validated constructor pattern +- `src/domain/tracker/config/http.rs` - Rewritten with validated constructor pattern +- `src/domain/tracker/config/health_check_api.rs` - Rewritten with validated constructor pattern +- `src/domain/tracker/config/mod.rs` - Updated `TrackerConfig::default()` to use `::new()` constructors, updated tests with helper functions, removed redundant localhost+TLS tests +- `src/application/command_handlers/create/config/tracker/udp_tracker_section.rs` - Added `TryFrom` impl +- `src/application/command_handlers/create/config/tracker/http_tracker_section.rs` - Added `TryFrom` impl +- `src/application/command_handlers/create/config/tracker/health_check_api_section.rs` - Added `TryFrom` impl +- `src/application/command_handlers/create/config/tracker/tracker_section.rs` - Updated to use `.try_into()` for conversions +- `src/application/command_handlers/create/config/errors.rs` - Added three new error variants with `From` implementations +- Multiple infrastructure test files updated to use `::new()` constructors + +**Key Decisions:** + +1. Custom `Deserialize` implementation via serde `deserialize_with` attribute (same pattern as HttpApiConfig) +2. Test helper functions added: `test_udp_tracker_config()`, `test_http_tracker_config()`, `test_health_check_api_config()` (and TLS variants) +3. Localhost+TLS validation tests removed from `TrackerConfig::validate()` tests since these invariants are now enforced at construction time by individual config types +4. All field accesses updated to use getter methods (`.bind_address()`, `.domain()`, `.use_tls_proxy()`) + +**Lessons Learned:** + +- Bulk replacement of field accesses (`.field` → `.field()`) required updates across many files +- Test code changes were more extensive than production code changes +- The `HasBindAddress` trait implementations needed fully-qualified method calls to avoid ambiguity + --- ## Phase 1: Tracker Config Hardening (High Priority) @@ -480,7 +508,7 @@ Strengthen the aggregate root TrackerConfig. ### Proposal #2: TrackerConfig Validates at Construction -**Status**: ⏳ Not Started +**Status**: ✅ Completed **Impact**: 🟢🟢🟢 High **Effort**: 🔵🔵 Medium **Priority**: P1 @@ -572,25 +600,92 @@ impl TrackerConfig { #### Implementation Checklist -- [ ] Make TrackerConfig fields private -- [ ] Create `TrackerConfig::new()` with aggregate validation -- [ ] Remove public `validate()` method (internalize it) -- [ ] Add getter methods for all fields -- [ ] Implement `TryFrom for TrackerConfig` (standard trait conversion) -- [ ] Update all tests -- [ ] Verify all tests pass -- [ ] Run linter and fix any issues +- [x] Make TrackerConfig fields private +- [x] Create `TrackerConfig::new()` with aggregate validation +- [x] Remove public `validate()` method (internalize it) +- [x] Add getter methods for all fields +- [x] Implement `TryFrom for TrackerConfig` (standard trait conversion) +- [x] Update all tests +- [x] Verify all tests pass +- [x] Run linter and fix any issues + +#### Implementation Notes (Completed) + +**Changes Made:** + +1. **Made all fields private** in `TrackerConfig`: + - `core`, `udp_trackers`, `http_trackers`, `http_api`, `health_check_api` + +2. **Added `TrackerConfig::new()` validated constructor**: + - Accepts all component configs (already validated by their own constructors) + - Performs aggregate-level validation: socket address conflict detection + - Returns `Result` + +3. **Added getter methods**: + - `core() -> &TrackerCoreConfig` + - `udp_trackers() -> &[UdpTrackerConfig]` + - `http_trackers() -> &[HttpTrackerConfig]` + - `http_api() -> &HttpApiConfig` + - `health_check_api() -> &HealthCheckApiConfig` + +4. **Removed `LocalhostWithTls` error variant**: + - This validation is now handled by child config constructors (Phase 0) + - `TrackerConfigError` only contains aggregate-level errors + +5. **Internalized `validate()` as `check_socket_address_conflicts()`**: + - Private method called during construction + - No separate validation step needed + +6. **Added custom `Deserialize` implementation**: + - Uses `TrackerConfigRaw` intermediate struct + - Calls `TrackerConfig::new()` to ensure deserialized data is validated + +7. **Updated `Default` implementation**: + - Now uses `TrackerConfig::new()` to ensure defaults are validated + +**Files Modified:** + +- `src/domain/tracker/config/mod.rs` - Main implementation +- `src/domain/tracker/mod.rs` - Updated doc example +- `src/domain/environment/context.rs` - Field access → getter methods +- `src/domain/environment/runtime_outputs.rs` - Field access → getter methods +- `src/application/command_handlers/create/config/tracker/tracker_section.rs` +- `src/application/command_handlers/show/info/tracker.rs` +- `src/application/command_handlers/test/handler.rs` +- `src/application/steps/rendering/docker_compose_templates.rs` +- `src/infrastructure/templating/prometheus/template/renderer/project_generator.rs` +- `src/infrastructure/templating/ansible/template/wrappers/variables/context.rs` +- `src/infrastructure/templating/tracker/template/wrapper/tracker_config/context.rs` +- `src/infrastructure/templating/tracker/template/renderer/project_generator.rs` + +**Observations:** + +- Test code changes were more extensive than production code due to struct literal usage +- The pattern of using getter methods is consistent with Phase 0 types +- Aggregate-level validation (socket conflicts) remains the only `TrackerConfig`-specific check +- Child-level validation (ports, TLS rules) correctly delegated to child constructors --- ### Proposal #3: TrackerCoreConfig and DatabaseConfig Validation -**Status**: ⏳ Not Started +**Status**: ✅ COMPLETED **Impact**: 🟢🟢 Medium **Effort**: 🔵 Low **Priority**: P1 **Depends On**: None +#### Implementation Summary + +Completed in commit series on `281-strengthen-domain-invariant-enforcement` branch: + +- **SqliteConfig**: Made `database_name` private, added `new()` constructor with validation (empty check), added getter, custom `Deserialize` via `SqliteConfigRaw`, `SqliteConfigError` enum with `help()` method +- **MysqlConfig**: Made all 5 fields private (`host`, `port`, `database_name`, `username`, `password`), added `new()` constructor with validation (empty checks for host/database_name/username, port 0 check), added 5 getters, custom `Deserialize` via `MysqlConfigRaw`, `MysqlConfigError` enum with `help()` method +- **TrackerCoreConfig**: Made `database` and `private` fields private, added `new()` constructor, added `database()` and `private()` getters, custom `Deserialize` via `TrackerCoreConfigRaw` +- Updated application DTO layer (`TrackerCoreSection`) to use domain constructors +- Updated `CreateConfigError` with new variants for database config errors +- Updated all usages across production and test code to use constructors and getters + #### Problem `TrackerCoreConfig` and `DatabaseConfig` have public fields without validation: @@ -676,7 +771,7 @@ Move cross-cutting validation to domain aggregates. ### Proposal #4: Add Validated Constructor to UserInputs -**Status**: ⏳ Not Started +**Status**: ✅ Completed **Impact**: 🟢🟢 Medium **Effort**: 🔵 Low **Priority**: P2 @@ -720,7 +815,7 @@ EnvironmentCreationConfig → Build partial domain objects: ↓ UserInputs validates cross-cutting invariants ↓ - Environment::with_working_dir_and_tracker() + Environment::create() creates the complete aggregate ``` @@ -820,7 +915,7 @@ Then update `EnvironmentContext` to use the validated constructor: ```rust impl EnvironmentContext { - pub fn with_working_dir_and_tracker( + pub fn create( // ... params ... ) -> Result { let user_inputs = UserInputs::new( @@ -862,21 +957,56 @@ impl EnvironmentContext { #### Implementation Checklist -- [ ] Create `UserInputsError` enum with `help()` method in `user_inputs.rs` -- [ ] Change `UserInputs::new()` to return `Result` -- [ ] Add cross-service validation to `UserInputs::new()` -- [ ] Update `EnvironmentContext::new()` and `with_working_dir_and_tracker()` to propagate errors -- [ ] Update `Environment::new()` and `with_working_dir_and_tracker()` to return `Result` -- [ ] Add `From` impl for `CreateConfigError` in application layer -- [ ] Remove duplicate validation from `EnvironmentCreationConfig::to_environment_params()` -- [ ] Update all call sites that create `UserInputs` directly -- [ ] Update tests -- [ ] Verify all tests pass -- [ ] Run linter and fix any issues +- [x] Create `UserInputsError` enum with `help()` method in `user_inputs.rs` +- [x] Change `UserInputs::new()` to return `Result` +- [x] Add cross-service validation to `UserInputs::with_tracker()` +- [x] Update `EnvironmentContext::create()` to propagate errors +- [x] Update `Environment::create()` to return `Result` +- [x] Add `From` impl for `CreateConfigError` in application layer +- [x] Remove duplicate validation from `EnvironmentCreationConfig::to_environment_params()` +- [x] Update all call sites that create `UserInputs` directly +- [x] Update tests +- [x] Verify all tests pass +- [x] Run linter and fix any issues + +#### Completion Notes + +**Completed in commit:** `refactor: [#281] UserInputs validated constructor (Proposal #4)` + +**Summary of changes:** + +1. **Created `UserInputsError` enum** with 3 variants: + - `GrafanaRequiresPrometheus` - Grafana configured without Prometheus + - `HttpsSectionWithoutTlsServices` - HTTPS section exists but no service uses TLS + - `TlsServicesWithoutHttpsSection` - Service has TLS but no HTTPS section + +2. **Made `UserInputs` fields private** and added getter methods: + - `name()`, `instance_name()`, `ssh_credentials()`, `ssh_port()`, `tracker()`, `prometheus()`, `grafana()`, `https()` + +3. **Added validated constructors**: + - `UserInputs::new()` returns `Result` (uses defaults that always pass) + - `UserInputs::with_tracker()` validates cross-service invariants + +4. **Updated `EnvironmentContext`**: + - `new()` uses `.expect()` since defaults always pass validation + - `create()` returns `Result` + +5. **Added `TrackerConfig::has_any_tls_configured()`** method to check TLS status + +6. **Removed duplicate validation from app layer**: + - Removed `GrafanaRequiresPrometheus` check from `to_environment_params()` + - Simplified `validate_https_config()` to only check HTTPS section details (admin email) + - Removed unused `TlsWithoutHttpsSection` and `HttpsSectionWithoutTls` error variants + +7. **Updated all field access patterns** to use getter methods across: + - `ansible_template_service.rs` + - `release/handler.rs` + - `caddy_templates.rs`, `docker_compose_templates.rs`, `grafana_templates.rs`, etc. + - `environment/context.rs`, `environment/state/mod.rs` #### Migration Notes -This change makes `Environment::with_working_dir_and_tracker()` fallible, which is a breaking change. However: +This change makes `Environment::create()` fallible, which is a breaking change. However: 1. The function is primarily called from `CreateCommandHandler::execute()` which already handles errors 2. Test helpers may need updating to use `.expect()` or propagate errors @@ -886,18 +1016,19 @@ This change makes `Environment::with_working_dir_and_tracker()` fallible, which ### Proposal #5: Move HTTPS Validation to Domain -**Status**: ⏳ Not Started +**Status**: ✅ COMPLETED **Impact**: 🟢🟢 Medium **Effort**: 🔵 Low **Priority**: P2 **Depends On**: Proposal #4 +**Completed**: January 21, 2026 #### Problem -`HttpsConfig` doesn't validate the email at construction: +`HttpsConfig` didn't validate the email at construction: ```rust -// Current: HttpsConfig accepts any string +// Before: HttpsConfig accepted any string impl HttpsConfig { pub fn new(admin_email: impl Into, use_staging: bool) -> Self { Self { @@ -908,57 +1039,52 @@ impl HttpsConfig { } ``` -Email validation happens in the application layer. +Email validation happened in the application layer. -#### Proposed Solution +#### Implemented Solution -Add email validation to `HttpsConfig`: +Changed `HttpsConfig::new()` to return `Result` with email validation: ```rust impl HttpsConfig { pub fn new( - admin_email: &Email, // Already validated Email type - use_staging: bool, - ) -> Self { - Self { - admin_email: admin_email.to_string(), - use_staging, - } - } - - // Alternative: validate string directly - pub fn from_string( admin_email: impl Into, use_staging: bool, ) -> Result { let email_str = admin_email.into(); - let _ = Email::new(&email_str) - .map_err(|_| HttpsConfigError::InvalidEmail(email_str.clone()))?; + let email = Email::new(&email_str)?; Ok(Self { - admin_email: email_str, + admin_email: email.to_string(), use_staging, }) } } + +pub enum HttpsConfigError { + InvalidEmail { email: String, reason: String }, +} ``` #### Rationale If domain stores an email, it should ensure it's valid. -#### Benefits +#### Benefits Realized - ✅ HttpsConfig is always valid - ✅ Email validation in domain - ✅ Consistent with other validated types +- ✅ `from_validated_email()` remains infallible for pre-validated emails #### Implementation Checklist -- [ ] Add email validation to `HttpsConfig::new()` or create `from_validated_email` -- [ ] Create `HttpsConfigError` if needed -- [ ] Update application layer -- [ ] Update tests -- [ ] Verify all tests pass +- [x] Add email validation to `HttpsConfig::new()` +- [x] Create `HttpsConfigError` with `InvalidEmail` variant +- [x] Add `help()` method with actionable guidance +- [x] Update application layer (`HttpsConfigInvalid` variant in `CreateConfigError`) +- [x] Remove duplicate validation from `HttpsSection::validate()` +- [x] Update tests in domain and application layers +- [x] Verify all tests pass --- @@ -1274,4 +1400,4 @@ The proposals should be implemented in order due to dependencies: **Created**: January 21, 2026 **Last Updated**: January 21, 2026 -**Status**: 🚧 In Progress (Phase 0) +**Status**: ✅ COMPLETED (All phases complete) diff --git a/src/application/command_handlers/create/config/README.md b/src/application/command_handlers/create/config/README.md index ec01f5e2..130c7c60 100644 --- a/src/application/command_handlers/create/config/README.md +++ b/src/application/command_handlers/create/config/README.md @@ -27,7 +27,7 @@ User JSON → [Config DTOs] → validate/transform → [Domain Types] | **Layer** | Application | Domain | | **Purpose** | JSON parsing | Business logic | | **Types** | Raw primitives (`String`, `u32`) | Validated newtypes (`NonZeroU32`, `ProfileName`) | -| **Validation** | Deferred to `to_*_config()` | Enforced at construction | +| **Validation** | Deferred to `TryFrom` conversion | Enforced at construction | | **Serde** | Heavy (`Deserialize`, `Serialize`, `JsonSchema`) | Minimal | ## Module Structure @@ -53,18 +53,24 @@ config/ ## Conversion Pattern -Each DTO provides a `to_*_config()` method that validates and converts to domain types: +Each DTO implements `TryFrom` for idiomatic Rust conversion to domain types. +This follows the pattern documented in `docs/decisions/tryfrom-for-dto-to-domain-conversion.md`. ```rust // Example: PrometheusSection → PrometheusConfig -impl PrometheusSection { - pub fn to_prometheus_config(&self) -> Result { +impl TryFrom for PrometheusConfig { + type Error = CreateConfigError; + + fn try_from(section: PrometheusSection) -> Result { // Validates: scrape_interval must be > 0 - let interval = NonZeroU32::new(self.scrape_interval_in_secs) + let interval = NonZeroU32::new(section.scrape_interval_in_secs) .ok_or_else(|| CreateConfigError::InvalidPrometheusConfig(...))?; Ok(PrometheusConfig::new(interval)) } } + +// Usage: +let config: PrometheusConfig = section.try_into()?; ``` ## Constraints Expressed in Rust (Not in JSON Schema) @@ -77,7 +83,7 @@ The Rust types express constraints that JSON Schema cannot fully capture: | Mutually exclusive options | Tagged enums with `#[serde(tag = "...")]` | `oneOf` is complex and error-prone | | Path validation | `PathBuf` with existence checks | No file system awareness | | Format validation | Newtype constructors (`ProfileName::new()`) | Regex patterns are limited | -| Cross-field validation | Custom `to_*_config()` logic | No support | +| Cross-field validation | Custom `TryFrom` implementation logic | No support | | Secret handling | `Password`, `ApiToken` wrappers | No security semantics | ## For AI Agents @@ -86,7 +92,7 @@ When generating environment configuration: 1. **Reference these Rust types** for accurate constraint information 2. **Follow the structure** in `EnvironmentCreationConfig` as the root type -3. **Check validation logic** in `to_*_config()` methods for business rules +3. **Check validation logic** in `TryFrom` implementations for business rules 4. **Use JSON schema** (`schemas/environment-config.json`) for basic structure, but trust Rust types for constraints ## Related Documentation diff --git a/src/application/command_handlers/create/config/environment_config.rs b/src/application/command_handlers/create/config/environment_config.rs index 6372e9a8..d9dd27f0 100644 --- a/src/application/command_handlers/create/config/environment_config.rs +++ b/src/application/command_handlers/create/config/environment_config.rs @@ -7,13 +7,7 @@ use schemars::JsonSchema; use serde::{Deserialize, Serialize}; -use crate::adapters::ssh::SshCredentials; -use crate::domain::grafana::GrafanaConfig; -use crate::domain::https::HttpsConfig; -use crate::domain::prometheus::PrometheusConfig; -use crate::domain::provider::{Provider, ProviderConfig}; -use crate::domain::tracker::TrackerConfig; -use crate::domain::{EnvironmentName, InstanceName}; +use crate::domain::provider::Provider; use super::errors::CreateConfigError; use super::grafana::GrafanaSection; @@ -97,20 +91,20 @@ pub struct EnvironmentCreationConfig { /// Provider-specific configuration (LXD, Hetzner, etc.) /// /// Uses `ProviderSection` for JSON parsing with raw primitives. - /// Converted to domain `ProviderConfig` via `to_environment_params()`. + /// Converted to domain `ProviderConfig` via `TryInto`. pub provider: ProviderSection, /// Tracker deployment configuration /// /// Uses `TrackerSection` for JSON parsing with String primitives. - /// Converted to domain `TrackerConfig` via `to_environment_params()`. + /// Converted to domain `TrackerConfig` via `TryInto`. pub tracker: TrackerSection, /// Prometheus monitoring configuration (optional) /// /// When present, Prometheus will be deployed to monitor the tracker. /// Uses `PrometheusSection` for JSON parsing with String primitives. - /// Converted to domain `PrometheusConfig` via `to_environment_params()`. + /// Converted to domain `PrometheusConfig` via `TryInto`. #[serde(default)] pub prometheus: Option, @@ -121,7 +115,7 @@ pub struct EnvironmentCreationConfig { /// Prometheus as its data source. /// /// Uses `GrafanaSection` for JSON parsing with String primitives. - /// Converted to domain `GrafanaConfig` via `to_environment_params()`. + /// Converted to domain `GrafanaConfig` via `TryInto`. #[serde(default)] pub grafana: Option, @@ -217,174 +211,6 @@ impl EnvironmentCreationConfig { } } - /// 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 domain types. - /// - /// # Validation - /// - /// - Environment name must follow naming rules (see `EnvironmentName`) - /// - Instance name (if provided) must follow instance naming rules (see `InstanceName`) - /// - Provider config must be valid (e.g., valid profile name for LXD) - /// - SSH username must follow Linux username requirements (see `Username`) - /// - SSH key files must exist and be accessible - /// - Grafana requires Prometheus (dependency validation) - /// - HTTPS configuration must be consistent (section present iff services have TLS) - /// - /// # Instance Name Auto-Generation - /// - /// If `instance_name` is not provided in the configuration, it will be - /// auto-generated using the format: `torrust-tracker-vm-{env_name}` - /// - /// # Errors - /// - /// Returns `CreateConfigError` if: - /// - Environment name is invalid - /// - Instance name is invalid (when provided) - /// - Provider configuration is invalid (e.g., invalid profile name) - /// - SSH username is invalid - /// - SSH private key file does not exist - /// - SSH public key file does not exist - /// - Grafana is configured but Prometheus is not (dependency violation) - /// - HTTPS section is defined but no service has TLS configured - /// - A service has TLS configured but HTTPS section is missing - /// - HTTPS admin email is invalid - /// - /// # Examples - /// - /// ```no_run - /// use torrust_tracker_deployer_lib::application::command_handlers::create::config::{ - /// EnvironmentCreationConfig, EnvironmentSection, SshCredentialsConfig, - /// ProviderSection, LxdProviderSection - /// }; - /// use torrust_tracker_deployer_lib::application::command_handlers::create::config::tracker::TrackerSection; - /// use torrust_tracker_deployer_lib::domain::Environment; - /// - /// let config = EnvironmentCreationConfig::new( - /// EnvironmentSection { - /// name: "dev".to_string(), - /// instance_name: None, - /// }, - /// SshCredentialsConfig::new( - /// "fixtures/testing_rsa".to_string(), - /// "fixtures/testing_rsa.pub".to_string(), - /// "torrust".to_string(), - /// 22, - /// ), - /// ProviderSection::Lxd(LxdProviderSection { - /// profile_name: "torrust-profile-dev".to_string(), - /// }), - /// TrackerSection::default(), - /// None, - /// None, - /// None, // HTTPS configuration - /// ); - /// - /// let result = config.to_environment_params()?; - /// - /// // Instance name auto-generated from environment name - /// assert_eq!(result.1.as_str(), "torrust-tracker-vm-dev"); - /// # Ok::<(), Box>(()) - /// ``` - #[allow(clippy::type_complexity)] - pub fn to_environment_params( - self, - ) -> Result< - ( - EnvironmentName, - InstanceName, - ProviderConfig, - SshCredentials, - u16, - TrackerConfig, - Option, - Option, - Option, - ), - CreateConfigError, - > { - // Validate HTTPS configuration consistency before any other conversion - self.validate_https_config()?; - - // Convert environment name string to domain type - let environment_name = EnvironmentName::new(&self.environment.name)?; - - // Instance name: use provided or auto-generate from environment name - let instance_name = match &self.environment.instance_name { - Some(name_str) => InstanceName::new(name_str.clone()).map_err(|e| { - CreateConfigError::InvalidInstanceName { - name: name_str.clone(), - reason: e.to_string(), - } - })?, - None => Self::generate_instance_name(&environment_name), - }; - - // Convert ProviderSection (DTO) to domain ProviderConfig (validates profile_name, etc.) - let provider_config = self.provider.to_provider_config()?; - - // 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()?; - - // Convert TrackerSection (DTO) to domain TrackerConfig (validates bind addresses, etc.) - let tracker_config = self.tracker.to_tracker_config()?; - - // Convert Prometheus and Grafana sections to domain types - let prometheus_config = self - .prometheus - .map(|section| section.to_prometheus_config()) - .transpose()?; - - let grafana_config = self - .grafana - .map(|section| section.to_grafana_config()) - .transpose()?; - - // Validate Grafana-Prometheus dependency - if grafana_config.is_some() && prometheus_config.is_none() { - return Err(CreateConfigError::GrafanaRequiresPrometheus); - } - - // Convert HTTPS section to domain type (already validated above) - let https_config = self - .https - .map(|section| HttpsConfig::new(section.admin_email, section.use_staging)); - - Ok(( - environment_name, - instance_name, - provider_config, - ssh_credentials, - ssh_port, - tracker_config, - prometheus_config, - grafana_config, - https_config, - )) - } - - /// Generates an instance name from the environment name - /// - /// Format: `torrust-tracker-vm-{env_name}` - /// - /// # Panics - /// - /// This function does not panic. The generated instance name is guaranteed - /// to be valid for any valid environment name. - fn generate_instance_name(env_name: &EnvironmentName) -> InstanceName { - let instance_name_str = format!("torrust-tracker-vm-{}", env_name.as_str()); - InstanceName::new(instance_name_str) - .expect("Generated instance name should always be valid for valid environment names") - } - /// Checks if any service has TLS configured /// /// Returns `true` if at least one of the following services has TLS: @@ -418,32 +244,9 @@ impl EnvironmentCreationConfig { false } - /// Validates HTTPS configuration consistency - /// - /// Validates that: - /// - If any service has TLS configured, the HTTPS section must be present - /// - If HTTPS section is present, at least one service must have TLS configured - /// - If HTTPS section is present, the admin email must be valid - /// - /// # Errors - /// - /// Returns `CreateConfigError::TlsWithoutHttpsSection` if a service has TLS but no HTTPS section. - /// Returns `CreateConfigError::HttpsSectionWithoutTls` if HTTPS section exists but no service has TLS. - /// Returns `CreateConfigError::InvalidAdminEmail` if the admin email format is invalid. - pub fn validate_https_config(&self) -> Result<(), CreateConfigError> { - let has_tls = self.has_any_tls_configured(); - - match (&self.https, has_tls) { - // TLS on services but no HTTPS section - error - (None, true) => Err(CreateConfigError::TlsWithoutHttpsSection), - // HTTPS section but no TLS on any service - error - (Some(_), false) => Err(CreateConfigError::HttpsSectionWithoutTls), - // HTTPS section with TLS on services - validate the section - (Some(https_section), true) => https_section.validate(), - // No HTTPS section and no TLS - valid (HTTP-only setup) - (None, false) => Ok(()), - } - } + // Note: validate_https_config() method has been removed. + // Email validation now happens in domain layer (HttpsConfig::new()) + // Cross-service validation (TLS/HTTPS consistency) happens in UserInputs::with_tracker() /// Creates a template instance with placeholder values for a specific provider /// @@ -601,6 +404,7 @@ mod tests { use super::*; use crate::application::command_handlers::create::config::provider::LxdProviderSection; use crate::application::command_handlers::create::config::tracker::TrackerSection; + use crate::domain::environment::EnvironmentParams; use crate::domain::provider::Provider; /// Helper to create a default LXD provider section for tests @@ -807,26 +611,16 @@ mod tests { None, ); - let result = config.to_environment_params(); + let result: Result = config.try_into(); assert!(result.is_ok(), "Expected successful conversion"); - let ( - name, - instance_name, - provider_config, - credentials, - port, - _tracker, - _prometheus, - _grafana, - _https, - ) = result.unwrap(); - - assert_eq!(name.as_str(), "dev"); - assert_eq!(instance_name.as_str(), "torrust-tracker-vm-dev"); // Auto-generated - assert_eq!(provider_config.provider(), Provider::Lxd); - assert_eq!(credentials.ssh_username.as_str(), "torrust"); - assert_eq!(port, 22); + let params = result.unwrap(); + + assert_eq!(params.environment_name.as_str(), "dev"); + assert_eq!(params.instance_name.as_str(), "torrust-tracker-vm-dev"); // Auto-generated + assert_eq!(params.provider_config.provider(), Provider::Lxd); + assert_eq!(params.ssh_credentials.ssh_username.as_str(), "torrust"); + assert_eq!(params.ssh_port, 22); } #[test] @@ -850,23 +644,13 @@ mod tests { None, ); - let result = config.to_environment_params(); + let result: Result = config.try_into(); assert!(result.is_ok(), "Expected successful conversion"); - let ( - name, - instance_name, - _provider_config, - _credentials, - _port, - _tracker, - _prometheus, - _grafana, - _https, - ) = result.unwrap(); - - assert_eq!(name.as_str(), "prod"); - assert_eq!(instance_name.as_str(), "my-custom-instance"); // Custom provided + let params = result.unwrap(); + + assert_eq!(params.environment_name.as_str(), "prod"); + assert_eq!(params.instance_name.as_str(), "my-custom-instance"); // Custom provided } #[test] @@ -890,7 +674,7 @@ mod tests { None, ); - let result = config.to_environment_params(); + let result: Result = config.try_into(); assert!(result.is_err()); match result.unwrap_err() { @@ -922,7 +706,7 @@ mod tests { None, ); - let result = config.to_environment_params(); + let result: Result = config.try_into(); assert!(result.is_err()); match result.unwrap_err() { @@ -957,7 +741,7 @@ mod tests { None, ); - let result = config.to_environment_params(); + let result: Result = config.try_into(); assert!(result.is_err()); match result.unwrap_err() { @@ -994,7 +778,7 @@ mod tests { None, ); - let result = config.to_environment_params(); + let result: Result = config.try_into(); assert!(result.is_err()); match result.unwrap_err() { @@ -1030,7 +814,7 @@ mod tests { None, ); - let result = config.to_environment_params(); + let result: Result = config.try_into(); assert!(result.is_err()); match result.unwrap_err() { @@ -1066,7 +850,7 @@ mod tests { None, ); - let result = config.to_environment_params(); + let result: Result = config.try_into(); assert!(result.is_err()); match result.unwrap_err() { @@ -1100,24 +884,11 @@ mod tests { None, ); - let ( - name, - _instance_name, - provider_config, - credentials, - port, - _tracker, - _prometheus, - _grafana, - _https, - ) = config.to_environment_params().unwrap(); - let environment = Environment::new( - name.clone(), - provider_config, - credentials, - port, - chrono::Utc::now(), - ); + let params: EnvironmentParams = config.try_into().unwrap(); + + // Create environment using the factory pattern with all required parameters + let working_dir = std::path::Path::new("/tmp/test-env"); + let environment = Environment::create(params, working_dir, chrono::Utc::now()).unwrap(); assert_eq!(environment.name().as_str(), "test-env"); assert_eq!(environment.ssh_username().as_str(), "torrust"); @@ -1446,18 +1217,19 @@ mod tests { } #[test] - fn it_should_pass_validation_when_no_https_and_no_tls() { + fn it_should_pass_conversion_when_no_https_section() { + use std::env; + + let project_root = env::var("CARGO_MANIFEST_DIR").expect("CARGO_MANIFEST_DIR not set"); + let private_key_path = format!("{project_root}/fixtures/testing_rsa"); + let public_key_path = format!("{project_root}/fixtures/testing_rsa.pub"); + let config = EnvironmentCreationConfig::new( EnvironmentSection { name: "dev".to_string(), instance_name: None, }, - SshCredentialsConfig::new( - "fixtures/testing_rsa".to_string(), - "fixtures/testing_rsa.pub".to_string(), - "torrust".to_string(), - 22, - ), + SshCredentialsConfig::new(private_key_path, public_key_path, "torrust".to_string(), 22), default_lxd_provider("torrust-profile-dev"), TrackerSection::default(), None, @@ -1465,107 +1237,95 @@ mod tests { None, ); - assert!(config.validate_https_config().is_ok()); + // Config with no HTTPS section should convert successfully + let result: Result = config.try_into(); + assert!(result.is_ok(), "Expected Ok but got: {:?}", result.err()); } + // Note: Tests for TLS/HTTPS cross-service validation have been moved to domain layer. + // See UserInputs::with_tracker() tests in src/domain/environment/user_inputs.rs + // - it_should_reject_tls_services_without_https_section + // - it_should_reject_https_section_without_tls_services + #[test] - fn it_should_fail_validation_when_tls_without_https_section() { - use crate::application::command_handlers::create::config::tracker::{ - DatabaseSection, HealthCheckApiSection, HttpApiSection, HttpTrackerSection, - TrackerCoreSection, TrackerSection, UdpTrackerSection, - }; + fn it_should_pass_conversion_when_https_section_has_valid_email() { + use crate::application::command_handlers::create::config::https::HttpsSection; + use std::env; - let tracker_section = TrackerSection { - core: TrackerCoreSection { - database: DatabaseSection::Sqlite { - database_name: "tracker.db".to_string(), - }, - private: false, - }, - udp_trackers: vec![UdpTrackerSection { - bind_address: "0.0.0.0:6969".to_string(), - domain: None, - }], - http_trackers: vec![HttpTrackerSection { - bind_address: "0.0.0.0:7070".to_string(), - domain: Some("tracker.example.com".to_string()), - use_tls_proxy: Some(true), - }], - http_api: HttpApiSection { - bind_address: "0.0.0.0:1212".to_string(), - admin_token: "MyAccessToken".to_string(), - domain: None, - use_tls_proxy: None, - }, - health_check_api: HealthCheckApiSection::default(), - }; + let project_root = env::var("CARGO_MANIFEST_DIR").expect("CARGO_MANIFEST_DIR not set"); + let private_key_path = format!("{project_root}/fixtures/testing_rsa"); + let public_key_path = format!("{project_root}/fixtures/testing_rsa.pub"); + // Email validation now happens in domain layer (HttpsConfig::new()) + // This test verifies that valid emails pass through TryInto let config = EnvironmentCreationConfig::new( EnvironmentSection { name: "dev".to_string(), instance_name: None, }, - SshCredentialsConfig::new( - "fixtures/testing_rsa".to_string(), - "fixtures/testing_rsa.pub".to_string(), - "torrust".to_string(), - 22, - ), + SshCredentialsConfig::new(private_key_path, public_key_path, "torrust".to_string(), 22), default_lxd_provider("torrust-profile-dev"), - tracker_section, + TrackerSection::default(), None, None, - None, // No HTTPS section + Some(HttpsSection { + admin_email: "admin@example.com".to_string(), + use_staging: false, + }), ); - let result = config.validate_https_config(); - assert!(result.is_err()); - assert!(matches!( - result.unwrap_err(), - CreateConfigError::TlsWithoutHttpsSection - )); + // HTTPS section with valid email should convert successfully + // (actual cross-service TLS validation happens in domain layer) + let result: Result = config.try_into(); + assert!(result.is_ok(), "Expected Ok but got: {:?}", result.err()); } #[test] - fn it_should_fail_validation_when_https_section_without_any_tls() { + fn it_should_reject_invalid_email_in_https_section() { use crate::application::command_handlers::create::config::https::HttpsSection; + use crate::domain::https::HttpsConfigError; + use std::env; + + let project_root = env::var("CARGO_MANIFEST_DIR").expect("CARGO_MANIFEST_DIR not set"); + let private_key_path = format!("{project_root}/fixtures/testing_rsa"); + let public_key_path = format!("{project_root}/fixtures/testing_rsa.pub"); let config = EnvironmentCreationConfig::new( EnvironmentSection { name: "dev".to_string(), instance_name: None, }, - SshCredentialsConfig::new( - "fixtures/testing_rsa".to_string(), - "fixtures/testing_rsa.pub".to_string(), - "torrust".to_string(), - 22, - ), + SshCredentialsConfig::new(private_key_path, public_key_path, "torrust".to_string(), 22), default_lxd_provider("torrust-profile-dev"), - TrackerSection::default(), // No TLS on any service + TrackerSection::default(), None, None, Some(HttpsSection { - admin_email: "admin@example.com".to_string(), + admin_email: "invalid-email".to_string(), // Invalid email use_staging: false, }), ); - let result = config.validate_https_config(); + let result: Result = config.try_into(); assert!(result.is_err()); assert!(matches!( result.unwrap_err(), - CreateConfigError::HttpsSectionWithoutTls + CreateConfigError::HttpsConfigInvalid(HttpsConfigError::InvalidEmail { .. }) )); } #[test] - fn it_should_pass_validation_when_https_section_with_tls() { + fn it_should_pass_conversion_when_https_section_with_tls() { use crate::application::command_handlers::create::config::https::HttpsSection; use crate::application::command_handlers::create::config::tracker::{ DatabaseSection, HealthCheckApiSection, HttpApiSection, HttpTrackerSection, TrackerCoreSection, TrackerSection, UdpTrackerSection, }; + use std::env; + + let project_root = env::var("CARGO_MANIFEST_DIR").expect("CARGO_MANIFEST_DIR not set"); + let private_key_path = format!("{project_root}/fixtures/testing_rsa"); + let public_key_path = format!("{project_root}/fixtures/testing_rsa.pub"); let tracker_section = TrackerSection { core: TrackerCoreSection { @@ -1597,12 +1357,7 @@ mod tests { name: "dev".to_string(), instance_name: None, }, - SshCredentialsConfig::new( - "fixtures/testing_rsa".to_string(), - "fixtures/testing_rsa.pub".to_string(), - "torrust".to_string(), - 22, - ), + SshCredentialsConfig::new(private_key_path, public_key_path, "torrust".to_string(), 22), default_lxd_provider("torrust-profile-dev"), tracker_section, None, @@ -1613,6 +1368,10 @@ mod tests { }), ); - assert!(config.validate_https_config().is_ok()); + // Note: Email validation now happens in domain layer (HttpsConfig::new()) + // Cross-service TLS/HTTPS validation happens in domain layer (UserInputs) + // This test verifies the DTO can convert to environment params + let result: Result = config.try_into(); + assert!(result.is_ok(), "Expected Ok but got: {:?}", result.err()); } } diff --git a/src/application/command_handlers/create/config/errors.rs b/src/application/command_handlers/create/config/errors.rs index cacb792a..a6530fb6 100644 --- a/src/application/command_handlers/create/config/errors.rs +++ b/src/application/command_handlers/create/config/errors.rs @@ -7,7 +7,10 @@ use std::path::PathBuf; use thiserror::Error; -use crate::domain::tracker::{HttpApiConfigError, TrackerConfigError}; +use crate::domain::tracker::{ + HealthCheckApiConfigError, HttpApiConfigError, HttpTrackerConfigError, MysqlConfigError, + SqliteConfigError, TrackerConfigError, UdpTrackerConfigError, +}; use crate::domain::EnvironmentNameError; use crate::domain::ProfileNameError; use crate::shared::UsernameError; @@ -118,14 +121,47 @@ pub enum CreateConfigError { #[error("HTTP API configuration invalid: {0}")] HttpApiConfigInvalid(#[from] HttpApiConfigError), - /// Invalid admin email format for HTTPS configuration - #[error("Invalid admin email '{email}': {reason}")] - InvalidAdminEmail { - /// The invalid email that was provided - email: String, - /// The reason why the email is invalid - reason: String, - }, + /// UDP tracker configuration validation failed (domain invariant violation) + /// + /// This error wraps domain-level validation errors from `UdpTrackerConfig::new()`, + /// providing a bridge between domain errors and application-level error handling. + #[error("UDP tracker configuration invalid: {0}")] + UdpTrackerConfigInvalid(#[from] UdpTrackerConfigError), + + /// HTTP tracker configuration validation failed (domain invariant violation) + /// + /// This error wraps domain-level validation errors from `HttpTrackerConfig::new()`, + /// providing a bridge between domain errors and application-level error handling. + #[error("HTTP tracker configuration invalid: {0}")] + HttpTrackerConfigInvalid(#[from] HttpTrackerConfigError), + + /// Health Check API configuration validation failed (domain invariant violation) + /// + /// This error wraps domain-level validation errors from `HealthCheckApiConfig::new()`, + /// providing a bridge between domain errors and application-level error handling. + #[error("Health Check API configuration invalid: {0}")] + HealthCheckApiConfigInvalid(#[from] HealthCheckApiConfigError), + + /// `SQLite` database configuration validation failed (domain invariant violation) + /// + /// This error wraps domain-level validation errors from `SqliteConfig::new()`, + /// providing a bridge between domain errors and application-level error handling. + #[error("SQLite database configuration invalid: {0}")] + SqliteConfigInvalid(#[from] SqliteConfigError), + + /// `MySQL` database configuration validation failed (domain invariant violation) + /// + /// This error wraps domain-level validation errors from `MysqlConfig::new()`, + /// providing a bridge between domain errors and application-level error handling. + #[error("MySQL database configuration invalid: {0}")] + MysqlConfigInvalid(#[from] MysqlConfigError), + + /// HTTPS configuration validation failed (domain invariant violation) + /// + /// This error wraps domain-level validation errors from `HttpsConfig::new()`, + /// such as invalid admin email format. + #[error("HTTPS configuration invalid: {0}")] + HttpsConfigInvalid(#[from] crate::domain::https::HttpsConfigError), /// Invalid domain name format for TLS configuration #[error("Invalid domain '{domain}': {reason}")] @@ -136,14 +172,10 @@ pub enum CreateConfigError { reason: String, }, - /// TLS configured for services but HTTPS section missing - #[error("TLS configured for services but 'https' section is missing")] - TlsWithoutHttpsSection, - - /// HTTPS section provided but no services have TLS configured - #[error("HTTPS section provided but no services have TLS configured")] - HttpsSectionWithoutTls, - + // Note: TLS/HTTPS cross-service validation errors (TlsWithoutHttpsSection, HttpsSectionWithoutTls) + // have been moved to domain layer. See UserInputsError variants: + // - TlsServicesWithoutHttpsSection + // - HttpsSectionWithoutTlsServices /// TLS proxy enabled but domain not specified #[error("TLS proxy enabled for {service_type} '{bind_address}' but domain is missing")] TlsProxyWithoutDomain { @@ -152,6 +184,13 @@ pub enum CreateConfigError { /// The bind address of the service bind_address: String, }, + + /// Cross-service invariant validation failed + /// + /// This error wraps domain-level cross-service validation errors from `UserInputs`, + /// such as Grafana requiring Prometheus or HTTPS/TLS configuration mismatches. + #[error("Cross-service configuration validation failed: {0}")] + CrossServiceValidation(#[from] crate::domain::environment::UserInputsError), } impl CreateConfigError { @@ -496,31 +535,29 @@ impl CreateConfigError { // Delegate to domain error's help method for detailed guidance inner.help() } - Self::InvalidAdminEmail { .. } => { - "Invalid admin email format for HTTPS configuration.\n\ - \n\ - The admin email is used for Let's Encrypt certificate notifications:\n\ - - Certificate expiration warnings (30 days before expiry)\n\ - - Certificate renewal failure notifications\n\ - - Important Let's Encrypt service announcements\n\ - \n\ - Requirements:\n\ - - Must contain '@' with content on both sides\n\ - - Must have a valid domain part (with at least one dot)\n\ - \n\ - Valid examples:\n\ - - admin@example.com\n\ - - certificates@my-company.org\n\ - - alerts+ssl@subdomain.example.com\n\ - \n\ - Fix:\n\ - Update the admin_email in your https configuration:\n\ - \n\ - \"https\": {\n\ - \"admin_email\": \"admin@yourdomain.com\"\n\ - }\n\ - \n\ - Note: This email may be visible in certificate transparency logs." + Self::UdpTrackerConfigInvalid(inner) => { + // Delegate to domain error's help method for detailed guidance + inner.help() + } + Self::HttpTrackerConfigInvalid(inner) => { + // Delegate to domain error's help method for detailed guidance + inner.help() + } + Self::HealthCheckApiConfigInvalid(inner) => { + // Delegate to domain error's help method for detailed guidance + inner.help() + } + Self::SqliteConfigInvalid(inner) => { + // Delegate to domain error's help method for detailed guidance + inner.help() + } + Self::MysqlConfigInvalid(inner) => { + // Delegate to domain error's help method for detailed guidance + inner.help() + } + Self::HttpsConfigInvalid(inner) => { + // Delegate to domain error's help method for detailed guidance + inner.help() } Self::InvalidDomain { .. } => { "Invalid domain name format for TLS configuration.\n\ @@ -554,63 +591,9 @@ impl CreateConfigError { \n\ Note: The domain must point to your server's IP before certificate acquisition." } - Self::TlsWithoutHttpsSection => { - "TLS configured for services but 'https' section is missing.\n\ - \n\ - You have configured TLS for one or more services but haven't provided\n\ - the required HTTPS configuration section with admin_email.\n\ - \n\ - The admin_email is required because:\n\ - - Let's Encrypt requires an email for certificate management\n\ - - You'll receive expiration warnings and renewal failure notifications\n\ - \n\ - Fix:\n\ - Add an 'https' section to your environment configuration:\n\ - \n\ - \"https\": {\n\ - \"admin_email\": \"admin@yourdomain.com\",\n\ - \"use_staging\": false // optional, defaults to false\n\ - }\n\ - \n\ - Note: Set use_staging to true for testing (avoids rate limits, but\n\ - certificates will show browser warnings)." - } - Self::HttpsSectionWithoutTls => { - "HTTPS section provided but no services have TLS configured.\n\ - \n\ - You have provided an 'https' section with admin_email but no services\n\ - have TLS enabled. This is likely a configuration error.\n\ - \n\ - To enable HTTPS for a service, add a 'tls' section to it:\n\ - \n\ - For Tracker API:\n\ - \"http_api\": {\n\ - \"bind_address\": \"0.0.0.0:1212\",\n\ - \"admin_token\": \"MyAccessToken\",\n\ - \"tls\": {\n\ - \"domain\": \"api.example.com\"\n\ - }\n\ - }\n\ - \n\ - For HTTP Tracker:\n\ - \"http_trackers\": [{\n\ - \"bind_address\": \"0.0.0.0:7070\",\n\ - \"tls\": {\n\ - \"domain\": \"tracker.example.com\"\n\ - }\n\ - }]\n\ - \n\ - For Grafana:\n\ - \"grafana\": {\n\ - \"admin_user\": \"admin\",\n\ - \"admin_password\": \"admin\",\n\ - \"tls\": {\n\ - \"domain\": \"grafana.example.com\"\n\ - }\n\ - }\n\ - \n\ - Alternatively, remove the 'https' section entirely if you don't want HTTPS." - } + // Note: TLS/HTTPS cross-service validation errors now use UserInputsError + // variants (TlsServicesWithoutHttpsSection, HttpsSectionWithoutTlsServices) + // which are wrapped via CrossServiceValidation variant below. Self::TlsProxyWithoutDomain { .. } => { "TLS proxy enabled but domain is missing.\n\ \n\ @@ -634,6 +617,7 @@ impl CreateConfigError { Alternatively, if you don't want HTTPS for this service,\n\ remove or set use_tls_proxy to false." } + Self::CrossServiceValidation(e) => e.help(), } } } diff --git a/src/application/command_handlers/create/config/grafana.rs b/src/application/command_handlers/create/config/grafana.rs index 3a18bfd7..d1c5e09d 100644 --- a/src/application/command_handlers/create/config/grafana.rs +++ b/src/application/command_handlers/create/config/grafana.rs @@ -3,6 +3,13 @@ //! This module contains the DTO type for Grafana configuration used in //! environment creation. This type uses raw primitives (String) for JSON //! deserialization and converts to the rich domain type (`GrafanaConfig`). +//! +//! It follows the **`TryFrom` pattern** for DTO to domain conversion, delegating +//! all business validation to the domain layer. +//! +//! See `docs/decisions/tryfrom-for-dto-to-domain-conversion.md` for rationale. + +use std::convert::TryFrom; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; @@ -82,23 +89,20 @@ impl Default for GrafanaSection { } } -impl GrafanaSection { - /// Converts this DTO to a domain `GrafanaConfig` - /// - /// This method performs validation and type conversion from the - /// string-based DTO to the strongly-typed domain model with secrecy - /// protection for the password. - /// - /// # Errors - /// - /// Returns `CreateConfigError::InvalidDomain` if the domain is invalid. - /// Returns `CreateConfigError::TlsProxyWithoutDomain` if `use_tls_proxy` - /// is true but no domain is provided. - pub fn to_grafana_config(&self) -> Result { - let use_tls_proxy = self.use_tls_proxy.unwrap_or(false); +/// Converts from application DTO to domain type using `TryFrom` trait +/// +/// This follows the idiomatic Rust pattern for fallible type +/// conversions, enabling use of `.try_into()` and `TryFrom::try_from()`. +/// +/// See `docs/decisions/tryfrom-for-dto-to-domain-conversion.md` for rationale. +impl TryFrom for GrafanaConfig { + type Error = CreateConfigError; + + fn try_from(section: GrafanaSection) -> Result { + let use_tls_proxy = section.use_tls_proxy.unwrap_or(false); // Validate: use_tls_proxy requires domain - if use_tls_proxy && self.domain.is_none() { + if use_tls_proxy && section.domain.is_none() { return Err(CreateConfigError::TlsProxyWithoutDomain { service_type: "Grafana".to_string(), bind_address: "N/A (hardcoded port 3000)".to_string(), @@ -107,7 +111,7 @@ impl GrafanaSection { // Parse domain if present let domain = - match &self.domain { + match §ion.domain { Some(domain_str) => Some(DomainName::new(domain_str).map_err(|e| { CreateConfigError::InvalidDomain { domain: domain_str.clone(), @@ -118,8 +122,8 @@ impl GrafanaSection { }; Ok(GrafanaConfig::new( - self.admin_user.clone(), - self.admin_password.clone(), + section.admin_user, + section.admin_password, domain, use_tls_proxy, )) @@ -148,7 +152,7 @@ mod tests { use_tls_proxy: None, }; - let result = section.to_grafana_config(); + let result: Result = section.try_into(); assert!(result.is_ok()); let config = result.unwrap(); @@ -159,7 +163,7 @@ mod tests { #[test] fn it_should_convert_default_section_to_default_config() { let section = GrafanaSection::default(); - let result = section.to_grafana_config(); + let result: Result = section.try_into(); assert!(result.is_ok()); let config = result.unwrap(); @@ -175,7 +179,7 @@ mod tests { use_tls_proxy: None, }; - let config = section.to_grafana_config().unwrap(); + let config: GrafanaConfig = section.try_into().unwrap(); let debug_output = format!("{config:?}"); // Password should be redacted in debug output @@ -192,7 +196,7 @@ mod tests { use_tls_proxy: Some(true), }; - let result = section.to_grafana_config(); + let result: Result = section.try_into(); assert!(result.is_ok()); let config = result.unwrap(); @@ -209,7 +213,7 @@ mod tests { use_tls_proxy: Some(false), }; - let result = section.to_grafana_config(); + let result: Result = section.try_into(); assert!(result.is_ok()); let config = result.unwrap(); @@ -229,7 +233,7 @@ mod tests { use_tls_proxy: Some(true), }; - let result = section.to_grafana_config(); + let result: Result = section.try_into(); assert!(result.is_err()); let err = result.unwrap_err(); @@ -248,7 +252,7 @@ mod tests { use_tls_proxy: Some(true), }; - let result = section.to_grafana_config(); + let result: Result = section.try_into(); assert!(result.is_err()); let err = result.unwrap_err(); diff --git a/src/application/command_handlers/create/config/https.rs b/src/application/command_handlers/create/config/https.rs index de06ccad..00f460d4 100644 --- a/src/application/command_handlers/create/config/https.rs +++ b/src/application/command_handlers/create/config/https.rs @@ -32,8 +32,8 @@ use schemars::JsonSchema; use serde::{Deserialize, Serialize}; -use super::errors::CreateConfigError; -use crate::shared::Email; +// Note: Email import and CreateConfigError import removed. +// Email validation now happens in domain layer (HttpsConfig::new()) /// Common HTTPS configuration (top-level) /// @@ -108,22 +108,8 @@ impl HttpsSection { } } - /// Validates the HTTPS configuration - /// - /// Uses the domain-level `Email` type for RFC-compliant validation via - /// the `email_address` crate. - /// - /// # Errors - /// - /// Returns `CreateConfigError::InvalidAdminEmail` if the email format is invalid. - pub fn validate(&self) -> Result<(), CreateConfigError> { - // Validate email using the domain type for RFC-compliant validation - Email::new(&self.admin_email).map_err(|e| CreateConfigError::InvalidAdminEmail { - email: self.admin_email.clone(), - reason: e.to_string(), - })?; - Ok(()) - } + // Note: validate() method has been removed. + // Email validation now happens in domain layer (HttpsConfig::new()) } #[cfg(test)] @@ -146,35 +132,8 @@ mod tests { assert!(section.use_staging); } - #[test] - fn it_should_validate_valid_email() { - let section = HttpsSection::new("admin@example.com".to_string(), false); - assert!(section.validate().is_ok()); - } - - #[test] - fn it_should_reject_email_without_at_symbol() { - let section = HttpsSection::new("invalid-email".to_string(), false); - let result = section.validate(); - assert!(result.is_err()); - if let Err(CreateConfigError::InvalidAdminEmail { email, .. }) = result { - assert_eq!(email, "invalid-email"); - } else { - panic!("Expected InvalidAdminEmail error"); - } - } - - #[test] - fn it_should_reject_email_with_empty_local_part() { - let section = HttpsSection::new("@example.com".to_string(), false); - assert!(section.validate().is_err()); - } - - #[test] - fn it_should_reject_email_with_empty_domain_part() { - let section = HttpsSection::new("admin@".to_string(), false); - assert!(section.validate().is_err()); - } + // Note: Email validation tests have been moved to domain layer. + // See HttpsConfig tests in src/domain/https/config.rs #[test] fn it_should_deserialize_from_json() { @@ -192,27 +151,6 @@ mod tests { } } - /// Tests for email validation in HTTPS context - /// - /// Note: Comprehensive email format validation tests are in `src/shared/email.rs`. - /// These tests verify the integration of the `Email` type with `HttpsSection`. - mod email_validation_integration_tests { - use super::*; - - #[test] - fn it_should_accept_rfc_compliant_email() { - let section = HttpsSection::new("user@example.com".to_string(), false); - assert!(section.validate().is_ok()); - } - - #[test] - fn it_should_reject_rfc_non_compliant_email() { - let section = HttpsSection::new("invalid-email".to_string(), false); - let result = section.validate(); - assert!(matches!( - result, - Err(CreateConfigError::InvalidAdminEmail { .. }) - )); - } - } + // Note: Email validation integration tests have been moved to domain layer. + // See HttpsConfig tests in src/domain/https/config.rs } diff --git a/src/application/command_handlers/create/config/mod.rs b/src/application/command_handlers/create/config/mod.rs index 62e95283..341ada2d 100644 --- a/src/application/command_handlers/create/config/mod.rs +++ b/src/application/command_handlers/create/config/mod.rs @@ -55,6 +55,7 @@ //! EnvironmentCreationConfig, EnvironmentSection, SshCredentialsConfig, //! ProviderSection, LxdProviderSection //! }; +//! use torrust_tracker_deployer_lib::domain::environment::EnvironmentParams; //! use torrust_tracker_deployer_lib::domain::Environment; //! use chrono::{TimeZone, Utc}; //! @@ -98,12 +99,13 @@ //! //! let config: EnvironmentCreationConfig = serde_json::from_str(json)?; //! -//! // Convert to domain parameters -//! let (name, instance_name, provider_config, credentials, port, tracker, _prometheus, _grafana, _https) = config.to_environment_params()?; +//! // Convert to validated domain parameters using TryInto +//! let params: EnvironmentParams = config.try_into()?; //! -//! // Create domain entity - Environment::new() will use the provider_config +//! // Create domain entity using the factory pattern //! let created_at = Utc.with_ymd_and_hms(2025, 1, 1, 0, 0, 0).unwrap(); -//! let environment = Environment::new(name, provider_config, credentials, port, created_at); +//! let working_dir = std::path::Path::new("/tmp/my-env"); +//! let environment = Environment::create(params, working_dir, created_at)?; //! //! # Ok::<(), Box>(()) //! ``` @@ -138,6 +140,7 @@ pub mod prometheus; pub mod provider; pub mod ssh_credentials_config; pub mod tracker; +pub mod validated_params; // Re-export commonly used types for convenience pub use environment_config::{EnvironmentCreationConfig, EnvironmentSection}; @@ -147,3 +150,6 @@ pub use https::HttpsSection; pub use prometheus::PrometheusSection; pub use provider::{HetznerProviderSection, LxdProviderSection, ProviderSection}; pub use ssh_credentials_config::SshCredentialsConfig; + +// Note: EnvironmentParams is now in domain layer (crate::domain::environment::EnvironmentParams) +// The validated_params module provides TryFrom for EnvironmentParams diff --git a/src/application/command_handlers/create/config/prometheus.rs b/src/application/command_handlers/create/config/prometheus.rs index 4be15e3c..abfa3de6 100644 --- a/src/application/command_handlers/create/config/prometheus.rs +++ b/src/application/command_handlers/create/config/prometheus.rs @@ -3,7 +3,13 @@ //! This module contains the DTO type for Prometheus configuration used in //! environment creation. This type uses raw primitives (u32) for JSON //! deserialization and converts to the rich domain type (`PrometheusConfig`). +//! +//! # Conversion Pattern +//! +//! Uses `TryFrom` for idiomatic Rust conversion from DTO to domain type. +//! See ADR: `docs/decisions/tryfrom-for-dto-to-domain-conversion.md` +use std::convert::TryFrom; use std::num::NonZeroU32; use schemars::JsonSchema; @@ -41,20 +47,14 @@ impl Default for PrometheusSection { } } -impl PrometheusSection { - /// Converts this DTO to a domain `PrometheusConfig` - /// - /// This method performs validation and type conversion from the - /// u32 DTO to the strongly-typed domain model with `NonZeroU32`. - /// - /// # Errors - /// - /// Returns error if scrape interval is 0 - pub fn to_prometheus_config(&self) -> Result { - let interval = NonZeroU32::new(self.scrape_interval_in_secs).ok_or_else(|| { +impl TryFrom for PrometheusConfig { + type Error = CreateConfigError; + + fn try_from(section: PrometheusSection) -> Result { + let interval = NonZeroU32::new(section.scrape_interval_in_secs).ok_or_else(|| { CreateConfigError::InvalidPrometheusConfig(format!( "Scrape interval must be greater than 0, got: {}", - self.scrape_interval_in_secs + section.scrape_interval_in_secs )) })?; Ok(PrometheusConfig::new(interval)) @@ -77,14 +77,14 @@ mod tests { scrape_interval_in_secs: 30, }; - let config = section.to_prometheus_config().expect("Valid config"); + let config: PrometheusConfig = section.try_into().expect("Valid config"); assert_eq!(config.scrape_interval_in_secs(), 30); } #[test] fn it_should_convert_default_section_to_default_config() { let section = PrometheusSection::default(); - let config = section.to_prometheus_config().expect("Valid config"); + let config: PrometheusConfig = section.try_into().expect("Valid config"); assert_eq!(config, PrometheusConfig::default()); } @@ -95,7 +95,7 @@ mod tests { scrape_interval_in_secs: 0, }; - let result = section.to_prometheus_config(); + let result: Result = section.try_into(); assert!(result.is_err()); } } diff --git a/src/application/command_handlers/create/config/provider/mod.rs b/src/application/command_handlers/create/config/provider/mod.rs index 16b0c5c6..15c59bd8 100644 --- a/src/application/command_handlers/create/config/provider/mod.rs +++ b/src/application/command_handlers/create/config/provider/mod.rs @@ -4,8 +4,8 @@ //! 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. +//! After deserialization, use `try_into()` or `ProviderConfig::try_from()` to convert +//! to domain types with validation. //! //! # Module Structure //! @@ -18,19 +18,26 @@ //! - **These config types** (this module): Raw primitives for JSON parsing //! - **Domain types** (`domain::provider`): Validated types for business logic //! +//! # Conversion Pattern +//! +//! Uses `TryFrom` for idiomatic Rust conversion from DTO to domain type. +//! See ADR: `docs/decisions/tryfrom-for-dto-to-domain-conversion.md` +//! //! # Examples //! //! ```rust //! use torrust_tracker_deployer_lib::application::command_handlers::create::config::{ //! ProviderSection, LxdProviderSection //! }; +//! use torrust_tracker_deployer_lib::domain::provider::ProviderConfig; +//! use std::convert::TryInto; //! //! // 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(); +//! let config: ProviderConfig = section.try_into().unwrap(); //! assert_eq!(config.provider_name(), "lxd"); //! ``` @@ -40,6 +47,8 @@ mod lxd; pub use hetzner::HetznerProviderSection; pub use lxd::LxdProviderSection; +use std::convert::TryFrom; + use schemars::JsonSchema; use serde::{Deserialize, Serialize}; @@ -57,7 +66,7 @@ use crate::shared::ApiToken; /// /// # Conversion /// -/// Use `to_provider_config()` to validate and convert to domain types. +/// Use `try_into()` or `ProviderConfig::try_from()` to validate and convert to domain types. /// /// # Examples /// @@ -65,12 +74,14 @@ use crate::shared::ApiToken; /// use torrust_tracker_deployer_lib::application::command_handlers::create::config::{ /// ProviderSection, LxdProviderSection /// }; +/// use torrust_tracker_deployer_lib::domain::provider::ProviderConfig; +/// use std::convert::TryInto; /// /// let section = ProviderSection::Lxd(LxdProviderSection { /// profile_name: "torrust-profile-dev".to_string(), /// }); /// -/// let config = section.to_provider_config().unwrap(); +/// let config: ProviderConfig = section.try_into().unwrap(); /// assert_eq!(config.provider_name(), "lxd"); /// ``` #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] @@ -108,46 +119,20 @@ impl ProviderSection { 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) => { +impl TryFrom for ProviderConfig { + type Error = CreateConfigError; + + fn try_from(section: ProviderSection) -> Result { + match section { + ProviderSection::Lxd(lxd) => { let profile_name = ProfileName::new(lxd.profile_name)?; - Ok(ProviderConfig::Lxd(LxdConfig { profile_name })) + Ok(Self::Lxd(LxdConfig { profile_name })) } - Self::Hetzner(hetzner) => { + ProviderSection::Hetzner(hetzner) => { // Note: Future improvement could add validation for these fields - Ok(ProviderConfig::Hetzner(HetznerConfig { + Ok(Self::Hetzner(HetznerConfig { api_token: ApiToken::from(hetzner.api_token), server_type: hetzner.server_type, location: hetzner.location, @@ -248,7 +233,7 @@ mod tests { #[test] fn it_should_convert_lxd_section_to_domain_config() { let section = create_lxd_section(); - let config = section.to_provider_config().unwrap(); + let config: ProviderConfig = section.try_into().unwrap(); assert_eq!(config.provider(), Provider::Lxd); assert_eq!(config.provider_name(), "lxd"); @@ -261,7 +246,7 @@ mod tests { #[test] fn it_should_convert_hetzner_section_to_domain_config() { let section = create_hetzner_section(); - let config = section.to_provider_config().unwrap(); + let config: ProviderConfig = section.try_into().unwrap(); assert_eq!(config.provider(), Provider::Hetzner); assert_eq!(config.provider_name(), "hetzner"); @@ -278,7 +263,7 @@ mod tests { let section = ProviderSection::Lxd(LxdProviderSection { profile_name: String::new(), // Empty is invalid }); - let result = section.to_provider_config(); + let result: Result = section.try_into(); assert!(result.is_err()); } @@ -287,7 +272,7 @@ mod tests { let section = ProviderSection::Lxd(LxdProviderSection { profile_name: "-invalid".to_string(), }); - let result = section.to_provider_config(); + let result: Result = section.try_into(); assert!(result.is_err()); } @@ -296,7 +281,7 @@ mod tests { let section = ProviderSection::Lxd(LxdProviderSection { profile_name: "invalid-".to_string(), }); - let result = section.to_provider_config(); + let result: Result = section.try_into(); assert!(result.is_err()); } diff --git a/src/application/command_handlers/create/config/ssh_credentials_config.rs b/src/application/command_handlers/create/config/ssh_credentials_config.rs index a928fdec..c3823b4d 100644 --- a/src/application/command_handlers/create/config/ssh_credentials_config.rs +++ b/src/application/command_handlers/create/config/ssh_credentials_config.rs @@ -3,7 +3,13 @@ //! 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. +//! +//! # Conversion Pattern +//! +//! Uses `TryFrom` for idiomatic Rust conversion from DTO to domain type. +//! See ADR: `docs/decisions/tryfrom-for-dto-to-domain-conversion.md` +use std::convert::TryFrom; use std::path::PathBuf; use schemars::JsonSchema; @@ -88,48 +94,18 @@ impl SshCredentialsConfig { 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 - /// - /// ```no_run - /// use torrust_tracker_deployer_lib::application::command_handlers::create::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 { +impl TryFrom for SshCredentials { + type Error = CreateConfigError; + + fn try_from(config: SshCredentialsConfig) -> Result { // Convert string username to domain Username type - let username = Username::new(&self.username)?; + let username = Username::new(&config.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); + let private_key_path = PathBuf::from(&config.private_key_path); + let public_key_path = PathBuf::from(&config.public_key_path); // Validate paths are absolute if !private_key_path.is_absolute() { @@ -258,7 +234,7 @@ mod tests { 22, ); - let result = config.to_ssh_credentials(); + let result: Result = config.try_into(); assert!(result.is_ok(), "Expected successful conversion"); let credentials = result.unwrap(); @@ -288,7 +264,7 @@ mod tests { 22, ); - let result = config.to_ssh_credentials(); + let result: Result = config.try_into(); assert!(result.is_err()); match result.unwrap_err() { @@ -313,7 +289,7 @@ mod tests { 22, ); - let result = config.to_ssh_credentials(); + let result: Result = config.try_into(); assert!(result.is_err()); match result.unwrap_err() { @@ -338,7 +314,7 @@ mod tests { 22, ); - let result = config.to_ssh_credentials(); + let result: Result = config.try_into(); assert!(result.is_err()); match result.unwrap_err() { @@ -364,7 +340,7 @@ mod tests { 22, ); - let result = config.to_ssh_credentials(); + let result: Result = config.try_into(); assert!(result.is_err()); match result.unwrap_err() { @@ -385,7 +361,7 @@ mod tests { 22, ); - let result = config.to_ssh_credentials(); + let result: Result = config.try_into(); assert!(result.is_err()); match result.unwrap_err() { @@ -406,7 +382,7 @@ mod tests { 22, ); - let result = config.to_ssh_credentials(); + let result: Result = config.try_into(); assert!(result.is_err()); // Should fail on private key first (checked first in validation) @@ -444,7 +420,7 @@ mod tests { 22, ); - let result = config.to_ssh_credentials(); + let result: Result = config.try_into(); assert!( result.is_ok(), "Expected successful conversion with absolute paths" @@ -471,7 +447,7 @@ mod tests { 22, ); - let result = config.to_ssh_credentials(); + let result: Result = config.try_into(); assert!(result.is_err()); let error = result.unwrap_err(); diff --git a/src/application/command_handlers/create/config/tracker/health_check_api_section.rs b/src/application/command_handlers/create/config/tracker/health_check_api_section.rs index 57213d30..9e8f4baf 100644 --- a/src/application/command_handlers/create/config/tracker/health_check_api_section.rs +++ b/src/application/command_handlers/create/config/tracker/health_check_api_section.rs @@ -1,3 +1,19 @@ +//! Health Check API section DTO +//! +//! This module contains the application layer DTO for Health Check API configuration. +//! It follows the **`TryFrom` pattern** for DTO to domain conversion, delegating +//! all business validation to the domain layer. +//! +//! ## Conversion Pattern +//! +//! The `TryFrom for HealthCheckApiConfig` implementation: +//! 1. Parses string fields into typed values (e.g., `String` → `SocketAddr`) +//! 2. Delegates domain validation to `HealthCheckApiConfig::new()` +//! 3. Maps domain errors to application errors via `From` implementations +//! +//! See `docs/decisions/tryfrom-for-dto-to-domain-conversion.md` for rationale. + +use std::convert::TryFrom; use std::net::SocketAddr; use schemars::JsonSchema; @@ -28,61 +44,49 @@ pub struct HealthCheckApiSection { pub use_tls_proxy: Option, } -impl HealthCheckApiSection { - /// Converts this DTO to a domain `HealthCheckApiConfig` - /// - /// # Errors - /// - /// Returns `CreateConfigError::InvalidBindAddress` if the bind address cannot be parsed as a valid IP:PORT combination. - /// Returns `CreateConfigError::DynamicPortNotSupported` if port 0 (dynamic port assignment) is specified. - /// Returns `CreateConfigError::InvalidDomain` if the domain is invalid. - /// Returns `CreateConfigError::TlsProxyWithoutDomain` if `use_tls_proxy` is true but domain is missing. - /// - /// Note: Localhost + TLS validation is performed at the domain layer - /// (see `TrackerConfig::validate()`) to avoid duplicating business rules. - pub fn to_health_check_api_config(&self) -> Result { - // Validate that the bind address can be parsed as SocketAddr - let bind_address = self.bind_address.parse::().map_err(|e| { +/// Converts from application DTO to domain type using `TryFrom` trait +/// +/// This implementation follows the standard library convention for fallible +/// conversions, enabling use of `.try_into()` and `TryFrom::try_from()`. +/// +/// # Example +/// +/// ```rust,ignore +/// let section = HealthCheckApiSection { +/// bind_address: "127.0.0.1:1313".to_string(), +/// domain: None, +/// use_tls_proxy: None, +/// }; +/// let config: HealthCheckApiConfig = section.try_into()?; +/// ``` +impl TryFrom for HealthCheckApiConfig { + type Error = CreateConfigError; + + fn try_from(section: HealthCheckApiSection) -> Result { + // Parse bind address from string to SocketAddr + let bind_address = section.bind_address.parse::().map_err(|e| { CreateConfigError::InvalidBindAddress { - address: self.bind_address.clone(), + address: section.bind_address.clone(), source: e, } })?; - // Reject port 0 (dynamic port assignment) - if bind_address.port() == 0 { - return Err(CreateConfigError::DynamicPortNotSupported { - bind_address: self.bind_address.clone(), - }); - } - - let use_tls_proxy = self.use_tls_proxy.unwrap_or(false); - - // Validate: use_tls_proxy requires domain - if use_tls_proxy && self.domain.is_none() { - return Err(CreateConfigError::TlsProxyWithoutDomain { - service_type: "Health Check API".to_string(), - bind_address: self.bind_address.clone(), - }); - } - // Parse domain if present - let domain = - match &self.domain { - Some(domain_str) => Some(DomainName::new(domain_str).map_err(|e| { - CreateConfigError::InvalidDomain { - domain: domain_str.clone(), - reason: e.to_string(), - } - })?), - None => None, - }; - - Ok(HealthCheckApiConfig { - bind_address, - domain, - use_tls_proxy, - }) + let domain = section + .domain + .map(|d| { + DomainName::new(&d).map_err(|e| CreateConfigError::InvalidDomain { + domain: d, + reason: e.to_string(), + }) + }) + .transpose()?; + + let use_tls_proxy = section.use_tls_proxy.unwrap_or(false); + + // Delegate all business validation to domain layer + HealthCheckApiConfig::new(bind_address, domain, use_tls_proxy) + .map_err(CreateConfigError::from) } } @@ -100,6 +104,10 @@ impl Default for HealthCheckApiSection { mod tests { use super::*; + // ========================================================================= + // TryFrom conversion tests + // ========================================================================= + #[test] fn it_should_convert_to_domain_config_when_bind_address_is_valid() { let section = HealthCheckApiSection { @@ -108,14 +116,14 @@ mod tests { use_tls_proxy: None, }; - let config = section.to_health_check_api_config().unwrap(); + let config: HealthCheckApiConfig = section.try_into().unwrap(); assert_eq!( - config.bind_address, + config.bind_address(), "127.0.0.1:1313".parse::().unwrap() ); - assert!(!config.use_tls_proxy); - assert!(config.domain.is_none()); + assert!(!config.use_tls_proxy()); + assert!(config.domain().is_none()); } #[test] @@ -126,13 +134,13 @@ mod tests { use_tls_proxy: Some(true), }; - let config = section.to_health_check_api_config().unwrap(); + let config: HealthCheckApiConfig = section.try_into().unwrap(); assert_eq!( - config.bind_address, + config.bind_address(), "0.0.0.0:1313".parse::().unwrap() ); - assert!(config.use_tls_proxy); + assert!(config.use_tls_proxy()); assert_eq!(config.tls_domain(), Some("health.tracker.local")); } @@ -144,7 +152,7 @@ mod tests { use_tls_proxy: None, }; - let result = section.to_health_check_api_config(); + let result: Result = section.try_into(); assert!(result.is_err()); assert!(matches!( @@ -154,19 +162,20 @@ mod tests { } #[test] - fn it_should_reject_dynamic_port_assignment() { + fn it_should_reject_dynamic_port_assignment_via_domain_validation() { let section = HealthCheckApiSection { bind_address: "0.0.0.0:0".to_string(), domain: None, use_tls_proxy: None, }; - let result = section.to_health_check_api_config(); + let result: Result = section.try_into(); assert!(result.is_err()); + // Port 0 is now rejected by domain layer assert!(matches!( result.unwrap_err(), - CreateConfigError::DynamicPortNotSupported { .. } + CreateConfigError::HealthCheckApiConfigInvalid(_) )); } @@ -178,7 +187,7 @@ mod tests { use_tls_proxy: None, }; - let result = section.to_health_check_api_config(); + let result: Result = section.try_into(); assert!(result.is_ok()); } @@ -191,7 +200,7 @@ mod tests { use_tls_proxy: None, }; - let result = section.to_health_check_api_config(); + let result: Result = section.try_into(); assert!(result.is_ok()); } @@ -213,7 +222,7 @@ mod tests { use_tls_proxy: Some(true), }; - let result = section.to_health_check_api_config(); + let result: Result = section.try_into(); assert!(result.is_err()); assert!(matches!( @@ -223,19 +232,38 @@ mod tests { } #[test] - fn it_should_fail_when_use_tls_proxy_without_domain() { + fn it_should_fail_when_use_tls_proxy_without_domain_via_domain_validation() { let section = HealthCheckApiSection { bind_address: "0.0.0.0:1313".to_string(), domain: None, use_tls_proxy: Some(true), }; - let result = section.to_health_check_api_config(); + let result: Result = section.try_into(); + + assert!(result.is_err()); + // TLS without domain is now rejected by domain layer + assert!(matches!( + result.unwrap_err(), + CreateConfigError::HealthCheckApiConfigInvalid(_) + )); + } + + #[test] + fn it_should_reject_localhost_with_tls_via_domain_validation() { + let section = HealthCheckApiSection { + bind_address: "127.0.0.1:1313".to_string(), + domain: Some("health.tracker.local".to_string()), + use_tls_proxy: Some(true), + }; + + let result: Result = section.try_into(); assert!(result.is_err()); + // Localhost + TLS is now rejected by domain layer assert!(matches!( result.unwrap_err(), - CreateConfigError::TlsProxyWithoutDomain { .. } + CreateConfigError::HealthCheckApiConfigInvalid(_) )); } @@ -247,9 +275,9 @@ mod tests { use_tls_proxy: None, }; - let config = section.to_health_check_api_config().unwrap(); + let config: HealthCheckApiConfig = section.try_into().unwrap(); - assert!(!config.use_tls_proxy); - assert!(config.domain.is_some()); + assert!(!config.use_tls_proxy()); + assert!(config.domain().is_some()); } } diff --git a/src/application/command_handlers/create/config/tracker/http_tracker_section.rs b/src/application/command_handlers/create/config/tracker/http_tracker_section.rs index b9716a8d..b4f9c2ed 100644 --- a/src/application/command_handlers/create/config/tracker/http_tracker_section.rs +++ b/src/application/command_handlers/create/config/tracker/http_tracker_section.rs @@ -1,3 +1,19 @@ +//! HTTP tracker section DTO +//! +//! This module contains the application layer DTO for HTTP tracker configuration. +//! It follows the **`TryFrom` pattern** for DTO to domain conversion, delegating +//! all business validation to the domain layer. +//! +//! ## Conversion Pattern +//! +//! The `TryFrom for HttpTrackerConfig` implementation: +//! 1. Parses string fields into typed values (e.g., `String` → `SocketAddr`) +//! 2. Delegates domain validation to `HttpTrackerConfig::new()` +//! 3. Maps domain errors to application errors via `From` implementations +//! +//! See `docs/decisions/tryfrom-for-dto-to-domain-conversion.md` for rationale. + +use std::convert::TryFrom; use std::net::SocketAddr; use schemars::JsonSchema; @@ -34,62 +50,48 @@ pub struct HttpTrackerSection { pub use_tls_proxy: Option, } -impl HttpTrackerSection { - /// Converts this DTO to a domain `HttpTrackerConfig` - /// - /// # Errors - /// - /// Returns `CreateConfigError::InvalidBindAddress` if the bind address cannot be parsed as a valid IP:PORT combination. - /// Returns `CreateConfigError::DynamicPortNotSupported` if port 0 (dynamic port assignment) is specified. - /// Returns `CreateConfigError::InvalidDomain` if the domain is invalid. - /// Returns `CreateConfigError::TlsProxyWithoutDomain` if `use_tls_proxy` is true but domain is missing. - /// - /// Note: Localhost + TLS validation is performed at the domain layer - /// (see `TrackerConfig::validate()`) to avoid duplicating business rules. - pub fn to_http_tracker_config(&self) -> Result { - // Validate that the bind address can be parsed as SocketAddr - let bind_address = self.bind_address.parse::().map_err(|e| { +/// Converts from application DTO to domain type using `TryFrom` trait +/// +/// This implementation follows the standard library convention for fallible +/// conversions, enabling use of `.try_into()` and `TryFrom::try_from()`. +/// +/// # Example +/// +/// ```rust,ignore +/// let section = HttpTrackerSection { +/// bind_address: "0.0.0.0:7070".to_string(), +/// domain: None, +/// use_tls_proxy: None, +/// }; +/// let config: HttpTrackerConfig = section.try_into()?; +/// ``` +impl TryFrom for HttpTrackerConfig { + type Error = CreateConfigError; + + fn try_from(section: HttpTrackerSection) -> Result { + // Parse bind address from string to SocketAddr + let bind_address = section.bind_address.parse::().map_err(|e| { CreateConfigError::InvalidBindAddress { - address: self.bind_address.clone(), + address: section.bind_address.clone(), source: e, } })?; - // Reject port 0 (dynamic port assignment) - if bind_address.port() == 0 { - return Err(CreateConfigError::DynamicPortNotSupported { - bind_address: self.bind_address.clone(), - }); - } - - let use_tls_proxy = self.use_tls_proxy.unwrap_or(false); - - // Validate: use_tls_proxy: true requires domain - if use_tls_proxy && self.domain.is_none() { - return Err(CreateConfigError::TlsProxyWithoutDomain { - service_type: "HTTP tracker".to_string(), - bind_address: self.bind_address.clone(), - }); - } - - // Convert domain to domain type with validation (if present) - let domain = match &self.domain { - Some(domain_str) => { - let domain = - DomainName::new(domain_str).map_err(|e| CreateConfigError::InvalidDomain { - domain: domain_str.clone(), - reason: e.to_string(), - })?; - Some(domain) - } - None => None, - }; - - Ok(HttpTrackerConfig { - bind_address, - domain, - use_tls_proxy, - }) + // Parse domain if present + let domain = section + .domain + .map(|d| { + DomainName::new(&d).map_err(|e| CreateConfigError::InvalidDomain { + domain: d, + reason: e.to_string(), + }) + }) + .transpose()?; + + let use_tls_proxy = section.use_tls_proxy.unwrap_or(false); + + // Delegate all business validation to domain layer + HttpTrackerConfig::new(bind_address, domain, use_tls_proxy).map_err(CreateConfigError::from) } } @@ -97,6 +99,10 @@ impl HttpTrackerSection { mod tests { use super::*; + // ========================================================================= + // TryFrom conversion tests + // ========================================================================= + #[test] fn it_should_convert_valid_bind_address_to_http_tracker_config() { let section = HttpTrackerSection { @@ -105,15 +111,15 @@ mod tests { use_tls_proxy: None, }; - let result = section.to_http_tracker_config(); + let result: Result = section.try_into(); assert!(result.is_ok()); let config = result.unwrap(); assert_eq!( - config.bind_address, + config.bind_address(), "0.0.0.0:7070".parse::().unwrap() ); - assert!(!config.use_tls_proxy); + assert!(!config.use_tls_proxy()); } #[test] @@ -124,7 +130,7 @@ mod tests { use_tls_proxy: None, }; - let result = section.to_http_tracker_config(); + let result: Result = section.try_into(); assert!(result.is_err()); if let Err(CreateConfigError::InvalidBindAddress { address, .. }) = result { @@ -135,21 +141,21 @@ mod tests { } #[test] - fn it_should_reject_port_zero() { + fn it_should_reject_port_zero_via_domain_validation() { let section = HttpTrackerSection { bind_address: "0.0.0.0:0".to_string(), domain: None, use_tls_proxy: None, }; - let result = section.to_http_tracker_config(); + let result: Result = section.try_into(); assert!(result.is_err()); - if let Err(CreateConfigError::DynamicPortNotSupported { bind_address }) = result { - assert_eq!(bind_address, "0.0.0.0:0"); - } else { - panic!("Expected DynamicPortNotSupported error"); - } + // Port 0 is now rejected by domain layer + assert!(matches!( + result.unwrap_err(), + CreateConfigError::HttpTrackerConfigInvalid(_) + )); } #[test] @@ -182,53 +188,66 @@ mod tests { use_tls_proxy: Some(true), }; - let result = section.to_http_tracker_config(); + let result: Result = section.try_into(); assert!(result.is_ok()); let config = result.unwrap(); - assert!(config.use_tls_proxy); - assert!(config.domain.is_some()); + assert!(config.use_tls_proxy()); + assert!(config.domain().is_some()); } #[test] - fn it_should_reject_tls_proxy_without_domain() { + fn it_should_reject_tls_proxy_without_domain_via_domain_validation() { let section = HttpTrackerSection { bind_address: "0.0.0.0:7070".to_string(), domain: None, use_tls_proxy: Some(true), }; - let result = section.to_http_tracker_config(); + let result: Result = section.try_into(); assert!(result.is_err()); - if let Err(CreateConfigError::TlsProxyWithoutDomain { - service_type, - bind_address, - }) = result - { - assert_eq!(service_type, "HTTP tracker"); - assert_eq!(bind_address, "0.0.0.0:7070"); - } else { - panic!("Expected TlsProxyWithoutDomain error"); - } + // TLS without domain is now rejected by domain layer + assert!(matches!( + result.unwrap_err(), + CreateConfigError::HttpTrackerConfigInvalid(_) + )); + } + + #[test] + fn it_should_reject_localhost_with_tls_via_domain_validation() { + let section = HttpTrackerSection { + bind_address: "127.0.0.1:7070".to_string(), + domain: Some("tracker.local".to_string()), + use_tls_proxy: Some(true), + }; + + let result: Result = section.try_into(); + assert!(result.is_err()); + + // Localhost + TLS is now rejected by domain layer + assert!(matches!( + result.unwrap_err(), + CreateConfigError::HttpTrackerConfigInvalid(_) + )); } #[test] fn it_should_accept_domain_without_tls_proxy() { - // Domain provided but use_tls_proxy is false - domain is ignored + // Domain provided but use_tls_proxy is false - domain is stored but not used for TLS let section = HttpTrackerSection { bind_address: "0.0.0.0:7070".to_string(), domain: Some("tracker.local".to_string()), use_tls_proxy: Some(false), }; - let result = section.to_http_tracker_config(); + let result: Result = section.try_into(); assert!(result.is_ok()); let config = result.unwrap(); - assert!(!config.use_tls_proxy); + assert!(!config.use_tls_proxy()); // Domain is still stored but won't be used for TLS - assert!(config.domain.is_some()); + assert!(config.domain().is_some()); } #[test] diff --git a/src/application/command_handlers/create/config/tracker/tracker_core_section.rs b/src/application/command_handlers/create/config/tracker/tracker_core_section.rs index 3dc373be..616ff530 100644 --- a/src/application/command_handlers/create/config/tracker/tracker_core_section.rs +++ b/src/application/command_handlers/create/config/tracker/tracker_core_section.rs @@ -3,6 +3,13 @@ //! This module provides the DTO for tracker core configuration, //! used for JSON deserialization and validation before converting //! to domain types. +//! +//! # Conversion Pattern +//! +//! Uses `TryFrom` for idiomatic Rust conversion from DTO to domain type. +//! See ADR: `docs/decisions/tryfrom-for-dto-to-domain-conversion.md` + +use std::convert::TryFrom; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; @@ -63,32 +70,31 @@ pub enum DatabaseSection { }, } -impl DatabaseSection { - /// Converts this DTO to the domain `DatabaseConfig` type. - /// - /// # Errors - /// - /// This conversion currently cannot fail, but returns `Result` - /// for consistency with other DTO conversions and to allow - /// future validation. - pub fn to_database_config(&self) -> Result { - match self { - Self::Sqlite { database_name } => Ok(DatabaseConfig::Sqlite(SqliteConfig { - database_name: database_name.clone(), - })), - Self::Mysql { +impl TryFrom for DatabaseConfig { + type Error = CreateConfigError; + + fn try_from(section: DatabaseSection) -> Result { + match section { + DatabaseSection::Sqlite { database_name } => { + let config = SqliteConfig::new(database_name)?; + Ok(Self::Sqlite(config)) + } + DatabaseSection::Mysql { host, port, database_name, username, password, - } => Ok(DatabaseConfig::Mysql(MysqlConfig { - host: host.clone(), - port: *port, - database_name: database_name.clone(), - username: username.clone(), - password: Password::from(password.as_str()), - })), + } => { + let config = MysqlConfig::new( + host, + port, + database_name, + username, + Password::from(password.as_str()), + )?; + Ok(Self::Mysql(config)) + } } } } @@ -116,17 +122,12 @@ pub struct TrackerCoreSection { pub private: bool, } -impl TrackerCoreSection { - /// Converts this DTO to the domain `TrackerCoreConfig` type. - /// - /// # Errors - /// - /// Returns error if database validation fails. - pub fn to_tracker_core_config(&self) -> Result { - Ok(TrackerCoreConfig { - database: self.database.to_database_config()?, - private: self.private, - }) +impl TryFrom for TrackerCoreConfig { + type Error = CreateConfigError; + + fn try_from(section: TrackerCoreSection) -> Result { + let database_config: DatabaseConfig = section.database.try_into()?; + Ok(Self::new(database_config, section.private)) } } @@ -143,15 +144,13 @@ mod tests { private: false, }; - let config = section.to_tracker_core_config().unwrap(); + let config: TrackerCoreConfig = section.try_into().unwrap(); assert_eq!( - config.database, - DatabaseConfig::Sqlite(SqliteConfig { - database_name: "tracker.db".to_string() - }) + *config.database(), + DatabaseConfig::Sqlite(SqliteConfig::new("tracker.db").unwrap()) ); - assert!(!config.private); + assert!(!config.private()); } #[test] @@ -163,9 +162,9 @@ mod tests { private: true, }; - let config = section.to_tracker_core_config().unwrap(); + let config: TrackerCoreConfig = section.try_into().unwrap(); - assert!(config.private); + assert!(config.private()); } #[test] @@ -217,19 +216,22 @@ mod tests { private: false, }; - let config = section.to_tracker_core_config().unwrap(); + let config: TrackerCoreConfig = section.try_into().unwrap(); assert_eq!( - config.database, - DatabaseConfig::Mysql(MysqlConfig { - host: "localhost".to_string(), - port: 3306, - database_name: "tracker".to_string(), - username: "tracker_user".to_string(), - password: Password::from("secure_password"), - }) + *config.database(), + DatabaseConfig::Mysql( + MysqlConfig::new( + "localhost", + 3306, + "tracker", + "tracker_user", + Password::from("secure_password"), + ) + .unwrap() + ) ); - assert!(!config.private); + assert!(!config.private()); } #[test] diff --git a/src/application/command_handlers/create/config/tracker/tracker_section.rs b/src/application/command_handlers/create/config/tracker/tracker_section.rs index fbb115fe..862c8b17 100644 --- a/src/application/command_handlers/create/config/tracker/tracker_section.rs +++ b/src/application/command_handlers/create/config/tracker/tracker_section.rs @@ -2,6 +2,14 @@ //! //! This module provides the aggregated DTO for complete tracker configuration, //! used for JSON deserialization and validation before converting to domain types. +//! +//! # Conversion Pattern +//! +//! Uses `TryFrom` for idiomatic Rust conversion from DTO to domain type. +//! See ADR: `docs/decisions/tryfrom-for-dto-to-domain-conversion.md` + +use std::convert::TryFrom; +use std::convert::TryInto; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; @@ -60,47 +68,39 @@ pub struct TrackerSection { pub health_check_api: HealthCheckApiSection, } -impl TrackerSection { - /// Converts this DTO to the domain `TrackerConfig` type. - /// - /// # Errors - /// - /// Returns error if any of the nested sections fail validation: - /// - Invalid bind address formats - /// - Invalid database configuration - /// - Socket address conflicts (multiple services on same IP:Port:Protocol) - pub fn to_tracker_config(&self) -> Result { - let core = self.core.to_tracker_core_config()?; - - let udp_trackers: Result, CreateConfigError> = self +impl TryFrom for TrackerConfig { + type Error = CreateConfigError; + + fn try_from(section: TrackerSection) -> Result { + let core = section.core.try_into()?; + + // Use TryFrom for DTO to domain conversions + let udp_trackers: Result, CreateConfigError> = section .udp_trackers - .iter() - .map(UdpTrackerSection::to_udp_tracker_config) + .into_iter() + .map(TryInto::try_into) .collect(); - let http_trackers: Result, CreateConfigError> = self + let http_trackers: Result, CreateConfigError> = section .http_trackers - .iter() - .map(HttpTrackerSection::to_http_tracker_config) + .into_iter() + .map(TryInto::try_into) .collect(); - let http_api: HttpApiConfig = self.http_api.clone().try_into()?; + let http_api: HttpApiConfig = section.http_api.try_into()?; - let health_check_api: HealthCheckApiConfig = - self.health_check_api.to_health_check_api_config()?; + let health_check_api: HealthCheckApiConfig = section.health_check_api.try_into()?; - let config = TrackerConfig { + // Create TrackerConfig with validated constructor + // This validates socket address uniqueness at construction time + TrackerConfig::new( core, - udp_trackers: udp_trackers?, - http_trackers: http_trackers?, + udp_trackers?, + http_trackers?, http_api, health_check_api, - }; - - // Validate socket address uniqueness - config.validate().map_err(CreateConfigError::from)?; - - Ok(config) + ) + .map_err(CreateConfigError::from) } } @@ -178,19 +178,17 @@ mod tests { health_check_api: HealthCheckApiSection::default(), }; - let config = section.to_tracker_config().unwrap(); + let config: TrackerConfig = section.try_into().unwrap(); assert_eq!( - config.core.database, - DatabaseConfig::Sqlite(SqliteConfig { - database_name: "tracker.db".to_string() - }) + *config.core().database(), + DatabaseConfig::Sqlite(SqliteConfig::new("tracker.db").unwrap()) ); - assert!(!config.core.private); - assert_eq!(config.udp_trackers.len(), 1); - assert_eq!(config.http_trackers.len(), 1); + assert!(!config.core().private()); + assert_eq!(config.udp_trackers().len(), 1); + assert_eq!(config.http_trackers().len(), 1); assert_eq!( - config.http_api.bind_address(), + config.http_api().bind_address(), "0.0.0.0:1212".parse::().unwrap() ); } @@ -235,10 +233,10 @@ mod tests { health_check_api: HealthCheckApiSection::default(), }; - let config = section.to_tracker_config().unwrap(); + let config: TrackerConfig = section.try_into().unwrap(); - assert_eq!(config.udp_trackers.len(), 2); - assert_eq!(config.http_trackers.len(), 2); + assert_eq!(config.udp_trackers().len(), 2); + assert_eq!(config.http_trackers().len(), 2); } #[test] @@ -264,7 +262,7 @@ mod tests { health_check_api: HealthCheckApiSection::default(), }; - let result = section.to_tracker_config(); + let result: Result = section.try_into(); assert!(result.is_err()); assert!(matches!( @@ -364,7 +362,7 @@ mod tests { health_check_api: HealthCheckApiSection::default(), }; - let result = section.to_tracker_config(); + let result: Result = section.try_into(); assert!(result.is_err()); assert!(matches!( result.unwrap_err(), @@ -400,7 +398,7 @@ mod tests { health_check_api: HealthCheckApiSection::default(), }; - let result = section.to_tracker_config(); + let result: Result = section.try_into(); assert!(result.is_ok()); } } diff --git a/src/application/command_handlers/create/config/tracker/udp_tracker_section.rs b/src/application/command_handlers/create/config/tracker/udp_tracker_section.rs index 41ee0fa4..d30464df 100644 --- a/src/application/command_handlers/create/config/tracker/udp_tracker_section.rs +++ b/src/application/command_handlers/create/config/tracker/udp_tracker_section.rs @@ -1,3 +1,19 @@ +//! UDP tracker section DTO +//! +//! This module contains the application layer DTO for UDP tracker configuration. +//! It follows the **`TryFrom` pattern** for DTO to domain conversion, delegating +//! all business validation to the domain layer. +//! +//! ## Conversion Pattern +//! +//! The `TryFrom for UdpTrackerConfig` implementation: +//! 1. Parses string fields into typed values (e.g., `String` → `SocketAddr`) +//! 2. Delegates domain validation to `UdpTrackerConfig::new()` +//! 3. Maps domain errors to application errors via `From` implementations +//! +//! See `docs/decisions/tryfrom-for-dto-to-domain-conversion.md` for rationale. + +use std::convert::TryFrom; use std::net::SocketAddr; use schemars::JsonSchema; @@ -22,47 +38,42 @@ pub struct UdpTrackerSection { pub domain: Option, } -impl UdpTrackerSection { - /// Converts this DTO to a domain `UdpTrackerConfig` - /// - /// # Errors - /// - /// Returns `CreateConfigError::InvalidBindAddress` if the bind address cannot be parsed as a valid IP:PORT combination. - /// Returns `CreateConfigError::DynamicPortNotSupported` if port 0 (dynamic port assignment) is specified. - /// Returns `CreateConfigError::InvalidDomain` if the domain is invalid. - pub fn to_udp_tracker_config(&self) -> Result { - // Validate that the bind address can be parsed as SocketAddr - let bind_address = self.bind_address.parse::().map_err(|e| { +/// Converts from application DTO to domain type using `TryFrom` trait +/// +/// This implementation follows the standard library convention for fallible +/// conversions, enabling use of `.try_into()` and `TryFrom::try_from()`. +/// +/// # Example +/// +/// ```rust,ignore +/// let section = UdpTrackerSection { bind_address: "0.0.0.0:6969".to_string(), domain: None }; +/// let config: UdpTrackerConfig = section.try_into()?; +/// ``` +impl TryFrom for UdpTrackerConfig { + type Error = CreateConfigError; + + fn try_from(section: UdpTrackerSection) -> Result { + // Parse bind address from string to SocketAddr + let bind_address = section.bind_address.parse::().map_err(|e| { CreateConfigError::InvalidBindAddress { - address: self.bind_address.clone(), + address: section.bind_address.clone(), source: e, } })?; - // Reject port 0 (dynamic port assignment) - if bind_address.port() == 0 { - return Err(CreateConfigError::DynamicPortNotSupported { - bind_address: self.bind_address.clone(), - }); - } - - // Convert domain to domain type with validation (if present) - let domain = match &self.domain { - Some(domain_str) => { - let domain = - DomainName::new(domain_str).map_err(|e| CreateConfigError::InvalidDomain { - domain: domain_str.clone(), - reason: e.to_string(), - })?; - Some(domain) - } - None => None, - }; - - Ok(UdpTrackerConfig { - bind_address, - domain, - }) + // Parse domain if present + let domain = section + .domain + .map(|d| { + DomainName::new(&d).map_err(|e| CreateConfigError::InvalidDomain { + domain: d, + reason: e.to_string(), + }) + }) + .transpose()?; + + // Delegate all business validation to domain layer + UdpTrackerConfig::new(bind_address, domain).map_err(CreateConfigError::from) } } @@ -70,6 +81,10 @@ impl UdpTrackerSection { mod tests { use super::*; + // ========================================================================= + // TryFrom conversion tests + // ========================================================================= + #[test] fn it_should_convert_valid_bind_address_to_udp_tracker_config() { let section = UdpTrackerSection { @@ -77,15 +92,15 @@ mod tests { domain: None, }; - let result = section.to_udp_tracker_config(); + let result: Result = section.try_into(); assert!(result.is_ok()); let config = result.unwrap(); assert_eq!( - config.bind_address, + config.bind_address(), "0.0.0.0:6969".parse::().unwrap() ); - assert!(config.domain.is_none()); + assert!(config.domain().is_none()); } #[test] @@ -95,16 +110,16 @@ mod tests { domain: Some("udp.tracker.local".to_string()), }; - let result = section.to_udp_tracker_config(); + let result: Result = section.try_into(); assert!(result.is_ok()); let config = result.unwrap(); assert_eq!( - config.bind_address, + config.bind_address(), "0.0.0.0:6969".parse::().unwrap() ); assert_eq!( - config.domain.as_ref().map(DomainName::as_str), + config.domain().map(DomainName::as_str), Some("udp.tracker.local") ); } @@ -116,7 +131,7 @@ mod tests { domain: Some(String::new()), // Empty domain is invalid }; - let result = section.to_udp_tracker_config(); + let result: Result = section.try_into(); assert!(result.is_err()); if let Err(CreateConfigError::InvalidDomain { domain, .. }) = result { @@ -133,7 +148,7 @@ mod tests { domain: None, }; - let result = section.to_udp_tracker_config(); + let result: Result = section.try_into(); assert!(result.is_err()); if let Err(CreateConfigError::InvalidBindAddress { address, .. }) = result { @@ -144,22 +159,26 @@ mod tests { } #[test] - fn it_should_reject_port_zero() { + fn it_should_reject_port_zero_via_domain_validation() { let section = UdpTrackerSection { bind_address: "0.0.0.0:0".to_string(), domain: None, }; - let result = section.to_udp_tracker_config(); + let result: Result = section.try_into(); assert!(result.is_err()); - if let Err(CreateConfigError::DynamicPortNotSupported { bind_address }) = result { - assert_eq!(bind_address, "0.0.0.0:0"); - } else { - panic!("Expected DynamicPortNotSupported error"); - } + // Port 0 is now rejected by domain layer + assert!(matches!( + result.unwrap_err(), + CreateConfigError::UdpTrackerConfigInvalid(_) + )); } + // ========================================================================= + // Serialization tests + // ========================================================================= + #[test] fn it_should_be_serializable_without_domain() { let section = UdpTrackerSection { diff --git a/src/application/command_handlers/create/config/validated_params.rs b/src/application/command_handlers/create/config/validated_params.rs new file mode 100644 index 00000000..5578e8d1 --- /dev/null +++ b/src/application/command_handlers/create/config/validated_params.rs @@ -0,0 +1,258 @@ +//! DTO to Domain Conversion for Environment Parameters +//! +//! This module provides the `TryFrom` implementation that converts +//! `EnvironmentCreationConfig` (a DTO) to `EnvironmentParams` (a domain type). +//! +//! # Architecture +//! +//! The conversion lives in the Application layer because it references the DTO, +//! but the target type (`EnvironmentParams`) is a pure domain value object. +//! +//! ```text +//! EnvironmentCreationConfig (DTO - Application Layer) +//! │ +//! │ TryFrom (this module - Application Layer) +//! ▼ +//! EnvironmentParams (Domain Value Object) +//! │ +//! │ Environment::create(params, working_dir, timestamp) +//! ▼ +//! Environment (Domain Aggregate) +//! ``` +//! +//! # Usage +//! +//! ```rust,no_run +//! use std::convert::TryInto; +//! use torrust_tracker_deployer_lib::application::command_handlers::create::config::EnvironmentCreationConfig; +//! use torrust_tracker_deployer_lib::domain::environment::EnvironmentParams; +//! +//! let config: EnvironmentCreationConfig = // ... load from JSON +//! # todo!(); +//! let params: EnvironmentParams = config.try_into()?; +//! +//! // Access validated domain objects by name +//! println!("Environment: {}", params.environment_name.as_str()); +//! # Ok::<(), Box>(()) +//! ``` +//! +//! See `docs/decisions/tryfrom-for-dto-to-domain-conversion.md` for rationale. + +use std::convert::TryFrom; +use std::convert::TryInto; + +use crate::domain::environment::EnvironmentParams; +use crate::domain::https::HttpsConfig; +use crate::domain::{EnvironmentName, InstanceName}; + +use super::errors::CreateConfigError; +use super::EnvironmentCreationConfig; + +impl TryFrom for EnvironmentParams { + type Error = CreateConfigError; + + /// Converts DTO configuration to validated domain parameters + /// + /// This performs all validation and type conversion from string-based + /// DTO fields to strongly-typed domain objects. + /// + /// # Validation + /// + /// - Environment name must follow naming rules + /// - Instance name (if provided) must follow instance naming rules + /// - Provider config must be valid (e.g., valid profile name for LXD) + /// - SSH username must follow Linux username requirements + /// - SSH key files must exist and be accessible + /// - SSH key paths must be absolute + /// + /// # Instance Name Auto-Generation + /// + /// If `instance_name` is not provided in the configuration, it will be + /// auto-generated using the format: `torrust-tracker-vm-{env_name}` + /// + /// # Errors + /// + /// Returns `CreateConfigError` if any validation fails. All error variants + /// implement `.help()` with detailed troubleshooting guidance. + fn try_from(config: EnvironmentCreationConfig) -> Result { + // Convert environment name string to domain type + let environment_name = EnvironmentName::new(&config.environment.name)?; + + // Instance name: use provided or auto-generate from environment name + let instance_name = match &config.environment.instance_name { + Some(name_str) => InstanceName::new(name_str.clone()).map_err(|e| { + CreateConfigError::InvalidInstanceName { + name: name_str.clone(), + reason: e.to_string(), + } + })?, + None => generate_instance_name(&environment_name), + }; + + // Convert ProviderSection (DTO) to domain ProviderConfig + let provider_config = config.provider.try_into()?; + + // Get SSH port before consuming ssh_credentials + let ssh_port = config.ssh_credentials.port; + + // Convert SSH credentials config to domain type + let ssh_credentials = config.ssh_credentials.try_into()?; + + // Convert TrackerSection (DTO) to domain TrackerConfig + let tracker_config = config.tracker.try_into()?; + + // Convert Prometheus and Grafana sections to domain types + let prometheus_config = config.prometheus.map(TryInto::try_into).transpose()?; + let grafana_config = config.grafana.map(TryInto::try_into).transpose()?; + + // Convert HTTPS section to domain type with email validation + let https_config = config + .https + .map(|section| HttpsConfig::new(section.admin_email, section.use_staging)) + .transpose()?; + + Ok(EnvironmentParams::new( + environment_name, + instance_name, + provider_config, + ssh_credentials, + ssh_port, + tracker_config, + prometheus_config, + grafana_config, + https_config, + )) + } +} + +/// Generates an instance name from the environment name +/// +/// Format: `torrust-tracker-vm-{env_name}` +/// +/// # Panics +/// +/// This function does not panic. The generated instance name is guaranteed +/// to be valid for any valid environment name. +fn generate_instance_name(env_name: &EnvironmentName) -> InstanceName { + let instance_name_str = format!("torrust-tracker-vm-{}", env_name.as_str()); + InstanceName::new(instance_name_str) + .expect("Generated instance name should always be valid for valid environment names") +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::application::command_handlers::create::config::provider::LxdProviderSection; + use crate::application::command_handlers::create::config::tracker::TrackerSection; + use crate::application::command_handlers::create::config::{ + EnvironmentSection, ProviderSection, SshCredentialsConfig, + }; + + /// Helper to create a valid configuration for testing + fn valid_config() -> EnvironmentCreationConfig { + let project_root = env!("CARGO_MANIFEST_DIR"); + let private_key_path = format!("{project_root}/fixtures/testing_rsa"); + let public_key_path = format!("{project_root}/fixtures/testing_rsa.pub"); + + EnvironmentCreationConfig::new( + EnvironmentSection { + name: "test-env".to_string(), + instance_name: None, + }, + SshCredentialsConfig::new(private_key_path, public_key_path, "torrust".to_string(), 22), + ProviderSection::Lxd(LxdProviderSection { + profile_name: "lxd-test-env".to_string(), + }), + TrackerSection::default(), + None, + None, + None, + ) + } + + #[test] + fn it_should_convert_valid_config_to_environment_params() { + let config = valid_config(); + let result: Result = config.try_into(); + + assert!(result.is_ok()); + let params = result.unwrap(); + assert_eq!(params.environment_name.as_str(), "test-env"); + assert_eq!(params.instance_name.as_str(), "torrust-tracker-vm-test-env"); + assert_eq!(params.ssh_port, 22); + } + + #[test] + fn it_should_use_custom_instance_name_when_provided() { + let project_root = env!("CARGO_MANIFEST_DIR"); + let private_key_path = format!("{project_root}/fixtures/testing_rsa"); + let public_key_path = format!("{project_root}/fixtures/testing_rsa.pub"); + + let config = EnvironmentCreationConfig::new( + EnvironmentSection { + name: "my-env".to_string(), + instance_name: Some("custom-vm-name".to_string()), + }, + SshCredentialsConfig::new(private_key_path, public_key_path, "torrust".to_string(), 22), + ProviderSection::Lxd(LxdProviderSection { + profile_name: "lxd-my-env".to_string(), + }), + TrackerSection::default(), + None, + None, + None, + ); + + let params: EnvironmentParams = config.try_into().unwrap(); + assert_eq!(params.instance_name.as_str(), "custom-vm-name"); + } + + #[test] + fn it_should_reject_invalid_environment_name() { + let project_root = env!("CARGO_MANIFEST_DIR"); + let private_key_path = format!("{project_root}/fixtures/testing_rsa"); + let public_key_path = format!("{project_root}/fixtures/testing_rsa.pub"); + + let config = EnvironmentCreationConfig::new( + EnvironmentSection { + name: "INVALID_NAME".to_string(), // uppercase not allowed + instance_name: None, + }, + SshCredentialsConfig::new(private_key_path, public_key_path, "torrust".to_string(), 22), + ProviderSection::Lxd(LxdProviderSection { + profile_name: "lxd-test".to_string(), + }), + TrackerSection::default(), + None, + None, + None, + ); + + let result: Result = config.try_into(); + assert!(result.is_err()); + } + + #[test] + fn it_should_provide_named_field_access() { + use crate::adapters::ssh::SshCredentials; + use crate::domain::grafana::GrafanaConfig; + use crate::domain::https::HttpsConfig; + use crate::domain::prometheus::PrometheusConfig; + use crate::domain::provider::ProviderConfig; + use crate::domain::tracker::TrackerConfig; + + let config = valid_config(); + let params: EnvironmentParams = config.try_into().unwrap(); + + // All fields are accessible by name (not by position) + let _name: &EnvironmentName = ¶ms.environment_name; + let _instance: &InstanceName = ¶ms.instance_name; + let _provider: &ProviderConfig = ¶ms.provider_config; + let _ssh: &SshCredentials = ¶ms.ssh_credentials; + let _port: u16 = params.ssh_port; + let _tracker: &TrackerConfig = ¶ms.tracker_config; + let _prometheus: &Option = ¶ms.prometheus_config; + let _grafana: &Option = ¶ms.grafana_config; + let _https: &Option = ¶ms.https_config; + } +} diff --git a/src/application/command_handlers/create/handler.rs b/src/application/command_handlers/create/handler.rs index 3a18d680..a0bfcdee 100644 --- a/src/application/command_handlers/create/handler.rs +++ b/src/application/command_handlers/create/handler.rs @@ -4,12 +4,13 @@ //! creation business logic. It follows the Command Pattern with dependency //! injection and is delivery-agnostic. +use std::convert::TryInto; use std::sync::Arc; use tracing::{info, instrument}; use crate::application::command_handlers::create::config::EnvironmentCreationConfig; use crate::domain::environment::repository::EnvironmentRepository; -use crate::domain::environment::{Created, Environment}; +use crate::domain::environment::{Created, Environment, EnvironmentParams}; use crate::shared::Clock; use super::errors::CreateCommandHandlerError; @@ -215,42 +216,25 @@ impl CreateCommandHandler { config: EnvironmentCreationConfig, working_dir: &std::path::Path, ) -> Result, CreateCommandHandlerError> { - let ( - environment_name, - _instance_name, - provider_config, - ssh_credentials, - ssh_port, - tracker_config, - prometheus_config, - grafana_config, - https_config, - ) = config - .to_environment_params() + // Convert DTO to validated domain parameters + let params: EnvironmentParams = config + .try_into() .map_err(CreateCommandHandlerError::InvalidConfiguration)?; + // Check for duplicate environment if self .environment_repository - .exists(&environment_name) + .exists(¶ms.environment_name) .map_err(CreateCommandHandlerError::RepositoryError)? { return Err(CreateCommandHandlerError::EnvironmentAlreadyExists { - name: environment_name.as_str().to_string(), + name: params.environment_name.as_str().to_string(), }); } - let environment = Environment::with_working_dir_and_tracker( - environment_name, - provider_config, - ssh_credentials, - ssh_port, - tracker_config, - prometheus_config, - grafana_config, - https_config, - working_dir, - self.clock.now(), - ); + // Create environment aggregate from validated params + let environment = Environment::create(params, working_dir, self.clock.now()) + .map_err(|e| CreateCommandHandlerError::InvalidConfiguration(e.into()))?; self.environment_repository .save(&environment.clone().into_any()) diff --git a/src/application/command_handlers/release/handler.rs b/src/application/command_handlers/release/handler.rs index fb29ba4b..30c5057d 100644 --- a/src/application/command_handlers/release/handler.rs +++ b/src/application/command_handlers/release/handler.rs @@ -351,7 +351,7 @@ impl ReleaseCommandHandler { let current_step = ReleaseStep::RenderPrometheusTemplates; // Check if Prometheus is configured - if environment.context().user_inputs.prometheus.is_none() { + if environment.context().user_inputs.prometheus().is_none() { info!( command = "release", step = %current_step, @@ -400,7 +400,7 @@ impl ReleaseCommandHandler { let current_step = ReleaseStep::CreatePrometheusStorage; // Check if Prometheus is configured - if environment.context().user_inputs.prometheus.is_none() { + if environment.context().user_inputs.prometheus().is_none() { info!( command = "release", step = %current_step, @@ -452,7 +452,7 @@ impl ReleaseCommandHandler { let current_step = ReleaseStep::DeployPrometheusConfigToRemote; // Check if Prometheus is configured - if environment.context().user_inputs.prometheus.is_none() { + if environment.context().user_inputs.prometheus().is_none() { info!( command = "release", step = %current_step, @@ -497,7 +497,7 @@ impl ReleaseCommandHandler { let current_step = ReleaseStep::RenderGrafanaTemplates; // Check if Grafana is configured - if environment.context().user_inputs.grafana.is_none() { + if environment.context().user_inputs.grafana().is_none() { info!( command = "release", step = %current_step, @@ -508,7 +508,7 @@ impl ReleaseCommandHandler { } // Check if Prometheus is configured (required for datasource) - if environment.context().user_inputs.prometheus.is_none() { + if environment.context().user_inputs.prometheus().is_none() { info!( command = "release", step = %current_step, @@ -556,7 +556,7 @@ impl ReleaseCommandHandler { let current_step = ReleaseStep::RenderCaddyTemplates; // Check if HTTPS is configured - if environment.context().user_inputs.https.is_none() { + if environment.context().user_inputs.https().is_none() { info!( command = "release", step = %current_step, @@ -606,7 +606,7 @@ impl ReleaseCommandHandler { let current_step = ReleaseStep::DeployCaddyConfigToRemote; // Check if HTTPS is configured - if environment.context().user_inputs.https.is_none() { + if environment.context().user_inputs.https().is_none() { info!( command = "release", step = %current_step, @@ -653,7 +653,7 @@ impl ReleaseCommandHandler { let current_step = ReleaseStep::DeployGrafanaProvisioning; // Check if Grafana is configured - if environment.context().user_inputs.grafana.is_none() { + if environment.context().user_inputs.grafana().is_none() { info!( command = "release", step = %current_step, @@ -664,7 +664,7 @@ impl ReleaseCommandHandler { } // Check if Prometheus is configured (required for datasource) - if environment.context().user_inputs.prometheus.is_none() { + if environment.context().user_inputs.prometheus().is_none() { info!( command = "release", step = %current_step, diff --git a/src/application/command_handlers/show/handler.rs b/src/application/command_handlers/show/handler.rs index b3e25ae5..60c71d8c 100644 --- a/src/application/command_handlers/show/handler.rs +++ b/src/application/command_handlers/show/handler.rs @@ -142,22 +142,12 @@ impl ShowCommandHandler { // Add service info for Released/Running states if Self::should_show_services(any_env.state_name()) { - // If HTTPS is configured, always compute from tracker config to show TLS domains - // Otherwise, try stored endpoints first for backward compatibility - let services = if any_env.https_config().is_some() { - // HTTPS enabled: compute from config to show proper TLS domains - let tracker_config = any_env.tracker_config(); - let grafana_config = any_env.grafana_config(); - ServiceInfo::from_tracker_config(tracker_config, instance_ip, grafana_config) - } else if let Some(endpoints) = any_env.service_endpoints() { - // No HTTPS: use stored endpoints (backward compatibility) - ServiceInfo::from_service_endpoints(endpoints) - } else { - // Fallback: compute from tracker config - let tracker_config = any_env.tracker_config(); - let grafana_config = any_env.grafana_config(); - ServiceInfo::from_tracker_config(tracker_config, instance_ip, grafana_config) - }; + // Always compute from tracker config to show proper service information + // including TLS domains, localhost hints, and HTTPS status + let tracker_config = any_env.tracker_config(); + let grafana_config = any_env.grafana_config(); + let services = + ServiceInfo::from_tracker_config(tracker_config, instance_ip, grafana_config); info = info.with_services(services); // Add Prometheus info if configured diff --git a/src/application/command_handlers/show/info/tracker.rs b/src/application/command_handlers/show/info/tracker.rs index abcbe678..be4b98fd 100644 --- a/src/application/command_handlers/show/info/tracker.rs +++ b/src/application/command_handlers/show/info/tracker.rs @@ -4,7 +4,6 @@ use std::net::IpAddr; -use crate::domain::environment::runtime_outputs::ServiceEndpoints; use crate::domain::grafana::GrafanaConfig; use crate::domain::tracker::config::is_localhost; use crate::domain::tracker::TrackerConfig; @@ -124,65 +123,117 @@ impl ServiceInfo { /// * `instance_ip` - The IP address of the deployed instance /// * `grafana_config` - Optional Grafana configuration (for TLS domain info) #[must_use] - #[allow(clippy::too_many_lines)] pub fn from_tracker_config( tracker_config: &TrackerConfig, instance_ip: IpAddr, grafana_config: Option<&GrafanaConfig>, ) -> Self { - let udp_trackers = tracker_config - .udp_trackers + let mut tls_domains = Vec::new(); + + let udp_trackers = Self::build_udp_tracker_urls(tracker_config, instance_ip); + + let (https_http_trackers, direct_http_trackers, localhost_http_trackers) = + Self::build_http_tracker_info(tracker_config, instance_ip, &mut tls_domains); + + let (api_endpoint, api_uses_https, api_is_localhost_only) = + Self::build_api_endpoint_info(tracker_config, instance_ip, &mut tls_domains); + + Self::collect_grafana_tls_domain(grafana_config, &mut tls_domains); + + let (health_check_url, health_check_uses_https, health_check_is_localhost_only) = + Self::build_health_check_info(tracker_config, instance_ip, &mut tls_domains); + + Self::new( + udp_trackers, + https_http_trackers, + direct_http_trackers, + localhost_http_trackers, + api_endpoint, + api_uses_https, + api_is_localhost_only, + health_check_url, + health_check_uses_https, + health_check_is_localhost_only, + tls_domains, + ) + } + + /// Build UDP tracker URLs from configuration + fn build_udp_tracker_urls(tracker_config: &TrackerConfig, instance_ip: IpAddr) -> Vec { + tracker_config + .udp_trackers() .iter() .map(|udp| { let host = udp - .domain - .as_ref() + .domain() .map_or_else(|| instance_ip.to_string(), |d| d.as_str().to_string()); - format!("udp://{}:{}/announce", host, udp.bind_address.port()) + format!("udp://{}:{}/announce", host, udp.bind_address().port()) }) - .collect(); + .collect() + } - // Separate HTTP trackers by TLS configuration and localhost status + /// Build HTTP tracker information, separating by TLS and localhost status + /// + /// Returns (`https_trackers`, `direct_trackers`, `localhost_trackers`) + fn build_http_tracker_info( + tracker_config: &TrackerConfig, + instance_ip: IpAddr, + tls_domains: &mut Vec, + ) -> (Vec, Vec, Vec) { let mut https_http_trackers = Vec::new(); let mut direct_http_trackers = Vec::new(); let mut localhost_http_trackers = Vec::new(); - let mut tls_domains = Vec::new(); - for (index, http) in tracker_config.http_trackers.iter().enumerate() { - if http.use_tls_proxy { - if let Some(domain) = &http.domain { + for (index, http) in tracker_config.http_trackers().iter().enumerate() { + if http.use_tls_proxy() { + if let Some(domain) = http.domain() { // TLS-enabled tracker - use HTTPS domain URL - // Note: localhost + TLS is rejected at config validation time, - // so we don't need to check for it here + // Note: localhost + TLS is rejected at config validation time https_http_trackers.push(format!("https://{}/announce", domain.as_str())); tls_domains.push(TlsDomainInfo { domain: domain.as_str().to_string(), - internal_port: http.bind_address.port(), + internal_port: http.bind_address().port(), }); } - } else if is_localhost(&http.bind_address) { + } else if is_localhost(&http.bind_address()) { // Localhost-only tracker - internal access only localhost_http_trackers.push(LocalhostServiceInfo { service_name: format!("http_tracker_{}", index + 1), - port: http.bind_address.port(), + port: http.bind_address().port(), }); } else { // Non-TLS, non-localhost tracker - use direct IP URL direct_http_trackers.push(format!( "http://{}:{}/announce", // DevSkim: ignore DS137138 instance_ip, - http.bind_address.port() + http.bind_address().port() )); } } - // Build API endpoint based on TLS configuration and localhost status - let api_is_localhost_only = is_localhost(&tracker_config.http_api.bind_address()); - let (api_endpoint, api_uses_https) = if tracker_config.http_api.use_tls_proxy() { - if let Some(domain) = tracker_config.http_api.domain() { + ( + https_http_trackers, + direct_http_trackers, + localhost_http_trackers, + ) + } + + /// Build API endpoint information + /// + /// Returns (`endpoint_url`, `uses_https`, `is_localhost_only`) + fn build_api_endpoint_info( + tracker_config: &TrackerConfig, + instance_ip: IpAddr, + tls_domains: &mut Vec, + ) -> (String, bool, bool) { + let api = tracker_config.http_api(); + let is_localhost_only = is_localhost(&api.bind_address()); + + let (endpoint, uses_https) = if api.use_tls_proxy() { + if let Some(domain) = api.domain() { tls_domains.push(TlsDomainInfo { domain: domain.as_str().to_string(), - internal_port: tracker_config.http_api.bind_address().port(), + internal_port: api.bind_address().port(), }); (format!("https://{}/api", domain.as_str()), true) } else { @@ -191,7 +242,7 @@ impl ServiceInfo { format!( "http://{}:{}/api", // DevSkim: ignore DS137138 instance_ip, - tracker_config.http_api.bind_address().port() + api.bind_address().port() ), false, ) @@ -201,13 +252,20 @@ impl ServiceInfo { format!( "http://{}:{}/api", // DevSkim: ignore DS137138 instance_ip, - tracker_config.http_api.bind_address().port() + api.bind_address().port() ), false, ) }; - // Add Grafana TLS domain if configured + (endpoint, uses_https, is_localhost_only) + } + + /// Collect Grafana TLS domain if configured + fn collect_grafana_tls_domain( + grafana_config: Option<&GrafanaConfig>, + tls_domains: &mut Vec, + ) { if let Some(grafana) = grafana_config { if let Some(domain) = grafana.tls_domain() { tls_domains.push(TlsDomainInfo { @@ -216,89 +274,37 @@ impl ServiceInfo { }); } } - - // Build health check URL based on TLS configuration and localhost status - let health_check_is_localhost_only = - is_localhost(&tracker_config.health_check_api.bind_address); - let (health_check_url, health_check_uses_https) = - if let Some(domain) = tracker_config.health_check_api.tls_domain() { - tls_domains.push(TlsDomainInfo { - domain: domain.to_string(), - internal_port: tracker_config.health_check_api.bind_address.port(), - }); - (format!("https://{domain}/health_check"), true) - } else { - ( - format!( - "http://{}:{}/health_check", // DevSkim: ignore DS137138 - instance_ip, - tracker_config.health_check_api.bind_address.port() - ), - false, - ) - }; - - Self::new( - udp_trackers, - https_http_trackers, - direct_http_trackers, - localhost_http_trackers, - api_endpoint, - api_uses_https, - api_is_localhost_only, - health_check_url, - health_check_uses_https, - health_check_is_localhost_only, - tls_domains, - ) } - /// Build `ServiceInfo` from stored `ServiceEndpoints` - /// - /// This method extracts service URLs from the runtime outputs - /// that were stored when services were started. + /// Build health check endpoint information /// - /// Note: This method is for backward compatibility with stored endpoints. - /// New deployments should use `from_tracker_config` which has full TLS awareness. - #[must_use] - pub fn from_service_endpoints(endpoints: &ServiceEndpoints) -> Self { - let udp_trackers = endpoints - .udp_trackers - .iter() - .map(ToString::to_string) - .collect(); - - // For backward compatibility, all HTTP trackers go to direct access - // (stored endpoints don't have TLS or localhost information) - let direct_http_trackers = endpoints - .http_trackers - .iter() - .map(ToString::to_string) - .collect(); - - let api_endpoint = endpoints - .api_endpoint - .as_ref() - .map_or_else(String::new, ToString::to_string); - - let health_check_url = endpoints - .health_check_url - .as_ref() - .map_or_else(String::new, ToString::to_string); + /// Returns (`url`, `uses_https`, `is_localhost_only`) + fn build_health_check_info( + tracker_config: &TrackerConfig, + instance_ip: IpAddr, + tls_domains: &mut Vec, + ) -> (String, bool, bool) { + let health_check = tracker_config.health_check_api(); + let is_localhost_only = is_localhost(&health_check.bind_address()); + + let (url, uses_https) = if let Some(domain) = health_check.tls_domain() { + tls_domains.push(TlsDomainInfo { + domain: domain.to_string(), + internal_port: health_check.bind_address().port(), + }); + (format!("https://{domain}/health_check"), true) + } else { + ( + format!( + "http://{}:{}/health_check", // DevSkim: ignore DS137138 + instance_ip, + health_check.bind_address().port() + ), + false, + ) + }; - Self::new( - udp_trackers, - Vec::new(), // No HTTPS trackers from legacy endpoints - direct_http_trackers, - Vec::new(), // No localhost tracker info from legacy endpoints - api_endpoint, - false, // Legacy endpoints don't have TLS info - false, // Legacy endpoints don't have localhost info - health_check_url, - false, // Legacy endpoints don't have health check TLS info - false, // Legacy endpoints don't have localhost info - Vec::new(), // No TLS domains from legacy endpoints - ) + (url, uses_https, is_localhost_only) } /// Returns true if any service has TLS enabled diff --git a/src/application/command_handlers/test/handler.rs b/src/application/command_handlers/test/handler.rs index 4e55b6a4..d6898b58 100644 --- a/src/application/command_handlers/test/handler.rs +++ b/src/application/command_handlers/test/handler.rs @@ -136,9 +136,9 @@ impl TestCommandHandler { let tracker_config = any_env.tracker_config(); // Build service endpoints from configuration (with server IP) - let tracker_api_endpoint = Self::build_api_endpoint(instance_ip, &tracker_config.http_api); + let tracker_api_endpoint = Self::build_api_endpoint(instance_ip, tracker_config.http_api()); let http_tracker_endpoints: Vec = tracker_config - .http_trackers + .http_trackers() .iter() .map(|config| Self::build_http_tracker_endpoint(instance_ip, config)) .collect(); @@ -190,7 +190,7 @@ impl TestCommandHandler { server_ip: std::net::IpAddr, config: &HttpTrackerConfig, ) -> ServiceEndpoint { - let port = config.bind_address.port(); + let port = config.bind_address().port(); let path = "/health_check"; let socket_addr = std::net::SocketAddr::new(server_ip, port); diff --git a/src/application/services/ansible_template_service.rs b/src/application/services/ansible_template_service.rs index 386b3132..99f207b7 100644 --- a/src/application/services/ansible_template_service.rs +++ b/src/application/services/ansible_template_service.rs @@ -137,7 +137,7 @@ impl AnsibleTemplateService { instance_ip: IpAddr, ssh_port_override: Option, ) -> Result<(), AnsibleTemplateServiceError> { - let effective_ssh_port = ssh_port_override.unwrap_or(user_inputs.ssh_port); + let effective_ssh_port = ssh_port_override.unwrap_or(user_inputs.ssh_port()); info!( instance_ip = %instance_ip, @@ -150,10 +150,10 @@ impl AnsibleTemplateService { RenderAnsibleTemplatesStep::new( self.ansible_template_renderer.clone(), - user_inputs.ssh_credentials.clone(), + user_inputs.ssh_credentials().clone(), ssh_socket_addr, - user_inputs.tracker.clone(), - user_inputs.grafana.clone(), + user_inputs.tracker().clone(), + user_inputs.grafana().cloned(), ) .execute() .await diff --git a/src/application/steps/rendering/caddy_templates.rs b/src/application/steps/rendering/caddy_templates.rs index b137fdbb..17ddd272 100644 --- a/src/application/steps/rendering/caddy_templates.rs +++ b/src/application/steps/rendering/caddy_templates.rs @@ -99,7 +99,7 @@ impl RenderCaddyTemplatesStep { )] pub fn execute(&self) -> Result, CaddyProjectGeneratorError> { // Check if HTTPS is configured - let Some(https_config) = &self.environment.context().user_inputs.https else { + let Some(https_config) = self.environment.context().user_inputs.https() else { info!( step = "render_caddy_templates", status = "skipped", @@ -157,7 +157,7 @@ impl RenderCaddyTemplatesStep { https_config: &crate::domain::https::HttpsConfig, ) -> CaddyContext { let user_inputs = &self.environment.context().user_inputs; - let tracker = &user_inputs.tracker; + let tracker = user_inputs.tracker(); let mut context = CaddyContext::new(https_config.admin_email(), https_config.use_staging()); @@ -179,7 +179,7 @@ impl RenderCaddyTemplatesStep { } // Add Grafana if TLS configured - if let Some(ref grafana) = user_inputs.grafana { + if let Some(grafana) = user_inputs.grafana() { if let Some(tls_domain) = grafana.tls_domain() { // Grafana default port is 3000 context = context.with_grafana(CaddyService::new(tls_domain, 3000)); diff --git a/src/application/steps/rendering/docker_compose_templates.rs b/src/application/steps/rendering/docker_compose_templates.rs index 161357f6..a986cf61 100644 --- a/src/application/steps/rendering/docker_compose_templates.rs +++ b/src/application/steps/rendering/docker_compose_templates.rs @@ -117,10 +117,10 @@ impl RenderDockerComposeTemplatesStep { DatabaseConfig::Mysql(mysql_config) => Self::create_mysql_contexts( admin_token, tracker, - mysql_config.port, - mysql_config.database_name.clone(), - mysql_config.username.clone(), - mysql_config.password.expose_secret().to_string(), + mysql_config.port(), + mysql_config.database_name().to_string(), + mysql_config.username().to_string(), + mysql_config.password().expose_secret().to_string(), ), }; @@ -186,18 +186,17 @@ impl RenderDockerComposeTemplatesStep { let user_inputs = &self.environment.context().user_inputs; // Check if HTTPS is configured - if user_inputs.https.is_none() { + if user_inputs.https().is_none() { return false; } - let tracker = &user_inputs.tracker; + let tracker = user_inputs.tracker(); // Check if any service has TLS configured let tracker_api_has_tls = tracker.http_api_tls_domain().is_some(); let http_trackers_have_tls = !tracker.http_trackers_with_tls().is_empty(); let grafana_has_tls = user_inputs - .grafana - .as_ref() + .grafana() .is_some_and(|g| g.tls_domain().is_some()); // Caddy is enabled if HTTPS is configured AND at least one service has TLS @@ -275,18 +274,17 @@ impl RenderDockerComposeTemplatesStep { let user_inputs = &self.environment.context().user_inputs; // Check if HTTPS is configured - let Some(https_config) = &user_inputs.https else { + let Some(https_config) = user_inputs.https() else { return builder; }; - let tracker = &user_inputs.tracker; + let tracker = user_inputs.tracker(); // Check if any service has TLS configured let has_tracker_api_tls = tracker.http_api_tls_domain().is_some(); let has_http_tracker_tls = !tracker.http_trackers_with_tls().is_empty(); let has_grafana_tls = user_inputs - .grafana - .as_ref() + .grafana() .is_some_and(|g| g.tls_domain().is_some()); let has_any_tls = has_tracker_api_tls || has_http_tracker_tls || has_grafana_tls; @@ -321,14 +319,14 @@ impl RenderDockerComposeTemplatesStep { ) -> (Vec, Vec, u16, bool) { // Extract UDP tracker ports (always exposed - no TLS termination via Caddy) let udp_ports: Vec = tracker_config - .udp_trackers + .udp_trackers() .iter() - .map(|tracker| tracker.bind_address.port()) + .map(|tracker| tracker.bind_address().port()) .collect(); // Get the set of HTTP tracker ports that have TLS enabled let tls_enabled_ports: std::collections::HashSet = user_inputs - .tracker + .tracker() .http_trackers_with_tls() .iter() .map(|(_, port)| *port) @@ -336,17 +334,17 @@ impl RenderDockerComposeTemplatesStep { // Extract HTTP tracker ports WITHOUT TLS (these need to be exposed) let http_ports_without_tls: Vec = tracker_config - .http_trackers + .http_trackers() .iter() - .map(|tracker| tracker.bind_address.port()) + .map(|tracker| tracker.bind_address().port()) .filter(|port| !tls_enabled_ports.contains(port)) .collect(); // Extract HTTP API port - let api_port = tracker_config.http_api.bind_address().port(); + let api_port = tracker_config.http_api().bind_address().port(); // Check if HTTP API has TLS enabled - let http_api_has_tls = user_inputs.tracker.http_api_tls_domain().is_some(); + let http_api_has_tls = user_inputs.tracker().http_api_tls_domain().is_some(); ( udp_ports, diff --git a/src/application/steps/rendering/grafana_templates.rs b/src/application/steps/rendering/grafana_templates.rs index f09cc9fe..214ebe47 100644 --- a/src/application/steps/rendering/grafana_templates.rs +++ b/src/application/steps/rendering/grafana_templates.rs @@ -94,7 +94,7 @@ impl RenderGrafanaTemplatesStep { )] pub fn execute(&self) -> Result, GrafanaProjectGeneratorError> { // Check if Grafana is configured - if self.environment.context().user_inputs.grafana.is_none() { + if self.environment.context().user_inputs.grafana().is_none() { info!( step = "render_grafana_templates", status = "skipped", @@ -105,7 +105,7 @@ impl RenderGrafanaTemplatesStep { } // Check if Prometheus is configured (required for datasource) - let Some(prometheus_config) = &self.environment.context().user_inputs.prometheus else { + let Some(prometheus_config) = self.environment.context().user_inputs.prometheus() else { info!( step = "render_grafana_templates", status = "skipped", diff --git a/src/application/steps/rendering/prometheus_templates.rs b/src/application/steps/rendering/prometheus_templates.rs index 7c6b35d1..b841c440 100644 --- a/src/application/steps/rendering/prometheus_templates.rs +++ b/src/application/steps/rendering/prometheus_templates.rs @@ -94,7 +94,7 @@ impl RenderPrometheusTemplatesStep { )] pub fn execute(&self) -> Result, PrometheusProjectGeneratorError> { // Check if Prometheus is configured - let Some(prometheus_config) = &self.environment.context().user_inputs.prometheus else { + let Some(prometheus_config) = self.environment.context().user_inputs.prometheus() else { info!( step = "render_prometheus_templates", status = "skipped", @@ -115,7 +115,7 @@ impl RenderPrometheusTemplatesStep { PrometheusProjectGenerator::new(&self.build_dir, self.template_manager.clone()); // Extract tracker config for API token and port - let tracker_config = &self.environment.context().user_inputs.tracker; + let tracker_config = self.environment.context().user_inputs.tracker(); generator.render(prometheus_config, tracker_config)?; let prometheus_build_dir = self.build_dir.join("storage/prometheus/etc"); diff --git a/src/application/steps/rendering/tracker_templates.rs b/src/application/steps/rendering/tracker_templates.rs index 69a38f72..668fc067 100644 --- a/src/application/steps/rendering/tracker_templates.rs +++ b/src/application/steps/rendering/tracker_templates.rs @@ -109,7 +109,7 @@ impl RenderTrackerTemplatesStep { TrackerProjectGenerator::new(&self.build_dir, self.template_manager.clone()); // Extract tracker config from environment (Phase 6) - let tracker_config = &self.environment.context().user_inputs.tracker; + let tracker_config = self.environment.context().user_inputs.tracker(); generator.render(Some(tracker_config))?; let tracker_build_dir = self.build_dir.join("tracker"); diff --git a/src/domain/environment/context.rs b/src/domain/environment/context.rs index d63eb77d..03ded295 100644 --- a/src/domain/environment/context.rs +++ b/src/domain/environment/context.rs @@ -36,7 +36,9 @@ //! where to add new fields as the application evolves. use crate::adapters::ssh::SshCredentials; -use crate::domain::environment::{EnvironmentName, InternalConfig, RuntimeOutputs, UserInputs}; +use crate::domain::environment::{ + EnvironmentName, EnvironmentParams, InternalConfig, RuntimeOutputs, UserInputs, +}; use crate::domain::grafana::GrafanaConfig; use crate::domain::prometheus::PrometheusConfig; use crate::domain::provider::ProviderConfig; @@ -176,7 +178,7 @@ impl EnvironmentContext { /// let created_at = Utc.with_ymd_and_hms(2025, 1, 1, 0, 0, 0).unwrap(); /// let context = EnvironmentContext::new(&env_name, provider_config, ssh_credentials, 22, created_at); /// - /// assert_eq!(context.user_inputs.instance_name.as_str(), "torrust-tracker-vm-production"); + /// assert_eq!(context.user_inputs.instance_name().as_str(), "torrust-tracker-vm-production"); /// let lxd_config = context.user_inputs.provider_config().as_lxd().unwrap(); /// assert_eq!(lxd_config.profile_name.as_str(), "torrust-profile-production"); /// assert_eq!(context.internal_config.data_dir, PathBuf::from("./data/production")); @@ -199,72 +201,71 @@ impl EnvironmentContext { ) -> Self { Self { created_at, - user_inputs: UserInputs::new(name, provider_config, ssh_credentials, ssh_port), + user_inputs: UserInputs::new(name, provider_config, ssh_credentials, ssh_port) + .expect("UserInputs::new with defaults should never fail - default config always passes validation"), internal_config: InternalConfig::new(name), - runtime_outputs: RuntimeOutputs { - instance_ip: None, - provision_method: None, - service_endpoints: None, - }, + runtime_outputs: RuntimeOutputs::new(), } } - /// Creates a new environment context with custom tracker configuration + /// Creates a new environment context from validated parameters /// /// This creates absolute paths for data and build directories by using the - /// provided working directory as the base, and allows specifying custom - /// tracker, prometheus, grafana, and https configurations. - #[must_use] - #[allow(clippy::too_many_arguments)] // Public API with necessary configuration parameters - pub fn with_working_dir_and_tracker( - name: &EnvironmentName, - provider_config: ProviderConfig, - ssh_credentials: SshCredentials, - ssh_port: u16, - tracker_config: crate::domain::tracker::TrackerConfig, - prometheus_config: Option, - grafana_config: Option, - https_config: Option, + /// provided working directory as the base. + /// + /// # Arguments + /// + /// * `params` - Validated environment parameters (domain value object) + /// * `working_dir` - Base directory for data and build directories + /// * `created_at` - Timestamp for context creation + /// + /// # Errors + /// + /// Returns `UserInputsError` if cross-service invariant validation fails: + /// - `GrafanaRequiresPrometheus` if Grafana is configured without Prometheus + /// - `HttpsSectionWithoutTlsServices` if HTTPS section exists but no service uses TLS + /// - `TlsServicesWithoutHttpsSection` if a service uses TLS but HTTPS section is missing + pub fn create( + params: EnvironmentParams, working_dir: &std::path::Path, created_at: DateTime, - ) -> Self { - Self { + ) -> Result { + Ok(Self { created_at, user_inputs: UserInputs::with_tracker( - name, - provider_config, - ssh_credentials, - ssh_port, - tracker_config, - prometheus_config, - grafana_config, - https_config, + ¶ms.environment_name, + params.provider_config, + params.ssh_credentials, + params.ssh_port, + params.tracker_config, + params.prometheus_config, + params.grafana_config, + params.https_config, + )?, + internal_config: InternalConfig::with_working_dir( + ¶ms.environment_name, + working_dir, ), - internal_config: InternalConfig::with_working_dir(name, working_dir), - runtime_outputs: RuntimeOutputs { - instance_ip: None, - provision_method: None, - service_endpoints: None, - }, - } + runtime_outputs: RuntimeOutputs::new(), + }) } /// Returns the SSH username for this environment #[must_use] pub fn ssh_username(&self) -> &crate::shared::Username { - &self.user_inputs.ssh_credentials.ssh_username + &self.user_inputs.ssh_credentials().ssh_username } /// Returns the SSH private key path for this environment #[must_use] pub fn ssh_private_key_path(&self) -> &PathBuf { - &self.user_inputs.ssh_credentials.ssh_priv_key_path + &self.user_inputs.ssh_credentials().ssh_priv_key_path } /// Returns the SSH public key path for this environment #[must_use] pub fn ssh_public_key_path(&self) -> &PathBuf { - &self.user_inputs.ssh_credentials.ssh_pub_key_path + &self.user_inputs.ssh_credentials().ssh_pub_key_path } /// Returns the templates directory for this environment @@ -299,7 +300,7 @@ impl EnvironmentContext { /// configuration (e.g., LXD, Hetzner). #[must_use] pub fn tofu_build_dir(&self) -> PathBuf { - let provider = self.user_inputs.provider_config.provider(); + let provider = self.user_inputs.provider_config().provider(); self.internal_config.tofu_build_dir_for_provider(provider) } @@ -322,51 +323,51 @@ impl EnvironmentContext { /// Returns the environment name #[must_use] pub fn name(&self) -> &EnvironmentName { - &self.user_inputs.name + self.user_inputs.name() } /// Returns the instance name #[must_use] pub fn instance_name(&self) -> &crate::domain::InstanceName { - &self.user_inputs.instance_name + self.user_inputs.instance_name() } /// Returns the provider configuration #[must_use] pub fn provider_config(&self) -> &ProviderConfig { - &self.user_inputs.provider_config + self.user_inputs.provider_config() } /// Returns the SSH credentials #[must_use] pub fn ssh_credentials(&self) -> &SshCredentials { - &self.user_inputs.ssh_credentials + self.user_inputs.ssh_credentials() } /// Returns the SSH port #[must_use] pub fn ssh_port(&self) -> u16 { - self.user_inputs.ssh_port + self.user_inputs.ssh_port() } /// Returns the database configuration #[must_use] pub fn database_config(&self) -> &crate::domain::tracker::DatabaseConfig { - &self.user_inputs.tracker.core.database + self.user_inputs.tracker().core().database() } /// Returns the tracker configuration #[must_use] pub fn tracker_config(&self) -> &crate::domain::tracker::TrackerConfig { - &self.user_inputs.tracker + self.user_inputs.tracker() } /// Returns the admin token #[must_use] pub fn admin_token(&self) -> &str { self.user_inputs - .tracker - .http_api + .tracker() + .http_api() .admin_token() .expose_secret() } @@ -374,13 +375,13 @@ impl EnvironmentContext { /// Returns the Prometheus configuration if enabled #[must_use] pub fn prometheus_config(&self) -> Option<&PrometheusConfig> { - self.user_inputs.prometheus.as_ref() + self.user_inputs.prometheus() } /// Returns the Grafana configuration if enabled #[must_use] pub fn grafana_config(&self) -> Option<&GrafanaConfig> { - self.user_inputs.grafana.as_ref() + self.user_inputs.grafana() } /// Returns the build directory @@ -398,13 +399,13 @@ impl EnvironmentContext { /// Returns the instance IP address if available #[must_use] pub fn instance_ip(&self) -> Option { - self.runtime_outputs.instance_ip + self.runtime_outputs.instance_ip() } /// Returns the provision method #[must_use] pub fn provision_method(&self) -> Option { - self.runtime_outputs.provision_method + self.runtime_outputs.provision_method() } /// Returns the creation timestamp diff --git a/src/domain/environment/mod.rs b/src/domain/environment/mod.rs index 459cb253..dd4a36d4 100644 --- a/src/domain/environment/mod.rs +++ b/src/domain/environment/mod.rs @@ -102,6 +102,7 @@ pub mod context; pub mod internal_config; pub mod name; +pub mod params; pub mod repository; pub mod runtime_outputs; pub mod state; @@ -119,13 +120,14 @@ pub use trace_id::TraceId; pub use context::EnvironmentContext; pub use internal_config::InternalConfig; pub use name::{EnvironmentName, EnvironmentNameError}; +pub use params::EnvironmentParams; pub use runtime_outputs::{ProvisionMethod, RuntimeOutputs}; pub use state::{ AnyEnvironmentState, ConfigureFailed, Configured, Configuring, Created, DestroyFailed, Destroyed, Destroying, ProvisionFailed, Provisioned, Provisioning, ReleaseFailed, Released, Releasing, RunFailed, Running, }; -pub use user_inputs::UserInputs; +pub use user_inputs::{UserInputs, UserInputsError}; // Re-export tracker types for convenience pub use crate::domain::tracker::{ @@ -296,43 +298,39 @@ impl Environment { } } - /// Creates a new environment in Created state with custom tracker configuration + /// Creates a new environment in Created state from validated parameters + /// + /// This is the primary factory method for creating a fully-configured + /// `Environment` aggregate. It accepts an `EnvironmentParams` value object + /// containing all validated domain inputs. /// /// This creates absolute paths for data and build directories by using the - /// provided working directory as the base, and allows specifying custom - /// tracker, prometheus, grafana, and https configurations. - #[must_use] + /// provided working directory as the base. + /// + /// # Arguments + /// + /// * `params` - Validated environment parameters (domain value object) + /// * `working_dir` - Base directory for data and build directories + /// * `created_at` - Timestamp for environment creation + /// + /// # Errors + /// + /// Returns `UserInputsError` if the cross-service configuration is invalid: + /// - `GrafanaRequiresPrometheus`: Grafana is configured but Prometheus is not + /// - `HttpsSectionWithoutTlsServices`: HTTPS section exists but no service uses TLS + /// - `TlsServicesWithoutHttpsSection`: Service has TLS but HTTPS section is missing #[allow(clippy::needless_pass_by_value)] // Public API takes ownership for ergonomics - #[allow(clippy::too_many_arguments)] // Public API with necessary configuration parameters - pub fn with_working_dir_and_tracker( - name: EnvironmentName, - provider_config: ProviderConfig, - ssh_credentials: SshCredentials, - ssh_port: u16, - tracker_config: TrackerConfig, - prometheus_config: Option, - grafana_config: Option, - https_config: Option, + pub fn create( + params: EnvironmentParams, working_dir: &std::path::Path, created_at: DateTime, - ) -> Environment { - let context = EnvironmentContext::with_working_dir_and_tracker( - &name, - provider_config, - ssh_credentials, - ssh_port, - tracker_config, - prometheus_config, - grafana_config, - https_config, - working_dir, - created_at, - ); + ) -> Result, UserInputsError> { + let context = EnvironmentContext::create(params, working_dir, created_at)?; - Environment { + Ok(Environment { context, state: Created, - } + }) } } @@ -361,8 +359,8 @@ impl Environment { fn with_state(self, new_state: T) -> Environment { // Log state transition for observability and audit trail tracing::info!( - environment_name = %self.context.user_inputs.name, - instance_name = %self.context.user_inputs.instance_name, + environment_name = %self.context.user_inputs.name(), + instance_name = %self.context.user_inputs.instance_name(), from_state = std::any::type_name::(), to_state = std::any::type_name::(), "Environment state transition" @@ -421,7 +419,7 @@ impl Environment { /// Returns the environment name #[must_use] pub fn name(&self) -> &EnvironmentName { - &self.context.user_inputs.name + self.context.user_inputs.name() } /// Returns the instance name for this environment @@ -696,7 +694,7 @@ impl Environment { /// ``` #[must_use] pub fn with_instance_ip(mut self, ip: IpAddr) -> Self { - self.context_mut().runtime_outputs.instance_ip = Some(ip); + self.context_mut().runtime_outputs.set_instance_ip(ip); self } @@ -715,7 +713,9 @@ impl Environment { /// Returns the environment with the provision method set. #[must_use] pub fn with_provision_method(mut self, method: runtime_outputs::ProvisionMethod) -> Self { - self.context_mut().runtime_outputs.provision_method = Some(method); + self.context_mut() + .runtime_outputs + .set_provision_method(method); self } @@ -1106,32 +1106,28 @@ mod tests { ssh_username, ); - let instance_name = - InstanceName::new(format!("torrust-tracker-vm-{}", env_name.as_str())).unwrap(); let profile_name = ProfileName::new(format!("lxd-{}", env_name.as_str())).unwrap(); let provider_config = ProviderConfig::Lxd(LxdConfig { profile_name }); + let user_inputs = UserInputs::with_tracker( + &env_name, + provider_config, + ssh_credentials, + 22, + TrackerConfig::default(), + Some(PrometheusConfig::default()), + Some(GrafanaConfig::default()), + None, + ) + .expect("Test UserInputs should always be valid with defaults"); + let context = EnvironmentContext { - user_inputs: UserInputs { - name: env_name, - instance_name, - provider_config, - ssh_credentials, - ssh_port: 22, - tracker: TrackerConfig::default(), - prometheus: Some(PrometheusConfig::default()), - grafana: Some(GrafanaConfig::default()), - https: None, - }, + user_inputs, internal_config: InternalConfig { data_dir: data_dir.clone(), build_dir: build_dir.clone(), }, - runtime_outputs: RuntimeOutputs { - instance_ip: None, - provision_method: None, - service_endpoints: None, - }, + runtime_outputs: RuntimeOutputs::new(), created_at: chrono::Utc::now(), }; @@ -1546,8 +1542,8 @@ mod tests { .build(); // Can access user inputs directly - assert_eq!(env.context.user_inputs.name.as_str(), "test-split"); - assert_eq!(env.context.user_inputs.ssh_port, 22); + assert_eq!(env.context.user_inputs.name().as_str(), "test-split"); + assert_eq!(env.context.user_inputs.ssh_port(), 22); } #[test] @@ -1571,7 +1567,7 @@ mod tests { .build(); // Runtime outputs start empty - assert_eq!(env.context.runtime_outputs.instance_ip, None); + assert_eq!(env.context.runtime_outputs.instance_ip(), None); } #[test] @@ -1584,7 +1580,7 @@ mod tests { let ip = IpAddr::V4(Ipv4Addr::new(10, 0, 0, 1)); let env = env.with_instance_ip(ip); - assert_eq!(env.context.runtime_outputs.instance_ip, Some(ip)); + assert_eq!(env.context.runtime_outputs.instance_ip(), Some(ip)); } #[test] diff --git a/src/domain/environment/params.rs b/src/domain/environment/params.rs new file mode 100644 index 00000000..c47567e8 --- /dev/null +++ b/src/domain/environment/params.rs @@ -0,0 +1,216 @@ +//! Environment Creation Parameters +//! +//! This module provides `EnvironmentParams`, a domain value object that holds +//! all validated parameters needed to create an `Environment` aggregate. +//! +//! # DDD Pattern: Factory Input +//! +//! This is a value object that groups all the inputs required by the +//! `Environment::create()` factory method. It provides: +//! +//! - **Named fields**: Self-documenting, no positional confusion +//! - **Type safety**: All fields are validated domain types +//! - **Clean API**: Single parameter instead of 10+ arguments +//! +//! # Architecture +//! +//! ```text +//! EnvironmentCreationConfig (DTO - Application Layer) +//! │ +//! │ TryFrom (in Application Layer) +//! ▼ +//! EnvironmentParams (Domain Value Object) +//! │ +//! │ Environment::create(params, working_dir, timestamp) +//! ▼ +//! Environment (Domain Aggregate) +//! ``` +//! +//! The `TryFrom` implementation lives in the Application layer since it needs +//! to reference the DTO, but `EnvironmentParams` itself is a pure domain type. +//! +//! # Usage +//! +//! ```rust,no_run +//! use torrust_tracker_deployer_lib::domain::environment::EnvironmentParams; +//! use torrust_tracker_deployer_lib::domain::{EnvironmentName, InstanceName}; +//! +//! // EnvironmentParams is typically constructed via TryFrom in application layer +//! // or directly in domain tests +//! ``` + +use crate::adapters::ssh::SshCredentials; +use crate::domain::grafana::GrafanaConfig; +use crate::domain::https::HttpsConfig; +use crate::domain::prometheus::PrometheusConfig; +use crate::domain::provider::ProviderConfig; +use crate::domain::tracker::TrackerConfig; +use crate::domain::{EnvironmentName, InstanceName}; + +/// Parameters for creating a new Environment aggregate +/// +/// This value object contains all validated domain objects needed to construct +/// an `Environment` aggregate. It serves as a "factory input" pattern, +/// grouping related parameters into a single, self-documenting type. +/// +/// # Field Categories +/// +/// - **Identity**: `environment_name`, `instance_name` +/// - **Infrastructure**: `provider_config`, `ssh_credentials`, `ssh_port` +/// - **Application**: `tracker_config` +/// - **Observability**: `prometheus_config`, `grafana_config` +/// - **Security**: `https_config` +/// +/// # Invariants +/// +/// All fields are pre-validated domain types. Cross-field validation +/// (e.g., Grafana requires Prometheus) happens in `Environment::create()`. +#[derive(Debug, Clone)] +pub struct EnvironmentParams { + /// Validated environment name (e.g., "production", "staging") + pub environment_name: EnvironmentName, + + /// Validated instance name for the VM/container + /// + /// Either user-provided or auto-generated as `torrust-tracker-vm-{env_name}` + pub instance_name: InstanceName, + + /// Provider-specific configuration (LXD, Hetzner, etc.) + pub provider_config: ProviderConfig, + + /// SSH credentials for remote access to the deployed instance + pub ssh_credentials: SshCredentials, + + /// SSH port for remote connections (typically 22) + pub ssh_port: u16, + + /// Tracker application configuration + pub tracker_config: TrackerConfig, + + /// Optional Prometheus monitoring configuration + pub prometheus_config: Option, + + /// Optional Grafana dashboard configuration + /// + /// Note: Requires `prometheus_config` to be set (validated in `Environment::create()`) + pub grafana_config: Option, + + /// Optional HTTPS/TLS configuration for secure endpoints + pub https_config: Option, +} + +impl EnvironmentParams { + /// Creates a new `EnvironmentParams` instance + /// + /// This constructor is primarily used in domain tests. In production, + /// `EnvironmentParams` is typically constructed via `TryFrom` conversion + /// from a configuration DTO in the application layer. + /// + /// # Arguments + /// + /// * `environment_name` - Validated environment name + /// * `instance_name` - Validated instance name + /// * `provider_config` - Provider configuration + /// * `ssh_credentials` - SSH access credentials + /// * `ssh_port` - SSH port number + /// * `tracker_config` - Tracker application configuration + /// * `prometheus_config` - Optional Prometheus configuration + /// * `grafana_config` - Optional Grafana configuration + /// * `https_config` - Optional HTTPS configuration + #[must_use] + #[allow(clippy::too_many_arguments)] + pub fn new( + environment_name: EnvironmentName, + instance_name: InstanceName, + provider_config: ProviderConfig, + ssh_credentials: SshCredentials, + ssh_port: u16, + tracker_config: TrackerConfig, + prometheus_config: Option, + grafana_config: Option, + https_config: Option, + ) -> Self { + Self { + environment_name, + instance_name, + provider_config, + ssh_credentials, + ssh_port, + tracker_config, + prometheus_config, + grafana_config, + https_config, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::domain::provider::LxdConfig; + use crate::domain::ProfileName; + use crate::shared::Username; + use std::path::PathBuf; + + fn sample_ssh_credentials() -> SshCredentials { + let project_root = env!("CARGO_MANIFEST_DIR"); + SshCredentials::new( + PathBuf::from(format!("{project_root}/fixtures/testing_rsa")), + PathBuf::from(format!("{project_root}/fixtures/testing_rsa.pub")), + Username::new("torrust").unwrap(), + ) + } + + fn sample_tracker_config() -> TrackerConfig { + TrackerConfig::default() + } + + #[test] + fn it_should_create_environment_params_with_all_fields() { + let params = EnvironmentParams::new( + EnvironmentName::new("test-env").unwrap(), + InstanceName::new("test-instance".to_string()).unwrap(), + ProviderConfig::Lxd(LxdConfig { + profile_name: ProfileName::new("lxd-test").unwrap(), + }), + sample_ssh_credentials(), + 22, + sample_tracker_config(), + None, + None, + None, + ); + + assert_eq!(params.environment_name.as_str(), "test-env"); + assert_eq!(params.instance_name.as_str(), "test-instance"); + assert_eq!(params.ssh_port, 22); + } + + #[test] + fn it_should_provide_named_field_access() { + let params = EnvironmentParams::new( + EnvironmentName::new("prod").unwrap(), + InstanceName::new("prod-vm".to_string()).unwrap(), + ProviderConfig::Lxd(LxdConfig { + profile_name: ProfileName::new("lxd-prod").unwrap(), + }), + sample_ssh_credentials(), + 2222, + sample_tracker_config(), + None, + None, + None, + ); + + // All fields accessible by name + let _name: &EnvironmentName = ¶ms.environment_name; + let _instance: &InstanceName = ¶ms.instance_name; + let _provider: &ProviderConfig = ¶ms.provider_config; + let _ssh: &SshCredentials = ¶ms.ssh_credentials; + let _port: u16 = params.ssh_port; + let _tracker: &TrackerConfig = ¶ms.tracker_config; + let _prometheus: &Option = ¶ms.prometheus_config; + let _grafana: &Option = ¶ms.grafana_config; + let _https: &Option = ¶ms.https_config; + } +} diff --git a/src/domain/environment/runtime_outputs.rs b/src/domain/environment/runtime_outputs.rs index c21d1e7e..929da6fd 100644 --- a/src/domain/environment/runtime_outputs.rs +++ b/src/domain/environment/runtime_outputs.rs @@ -143,13 +143,15 @@ impl ServiceEndpoints { tracker_config: &crate::domain::tracker::TrackerConfig, instance_ip: IpAddr, ) -> Self { - let udp_trackers = Self::build_udp_tracker_urls(&tracker_config.udp_trackers, instance_ip); + let udp_trackers = Self::build_udp_tracker_urls(tracker_config.udp_trackers(), instance_ip); let http_trackers = - Self::build_http_tracker_urls(&tracker_config.http_trackers, instance_ip); + Self::build_http_tracker_urls(tracker_config.http_trackers(), instance_ip); let api_endpoint = - Self::build_api_endpoint_url(tracker_config.http_api.bind_address(), instance_ip); - let health_check_url = - Self::build_health_check_url(tracker_config.health_check_api.bind_address, instance_ip); + Self::build_api_endpoint_url(tracker_config.http_api().bind_address(), instance_ip); + let health_check_url = Self::build_health_check_url( + tracker_config.health_check_api().bind_address(), + instance_ip, + ); Self::new(udp_trackers, http_trackers, api_endpoint, health_check_url) } @@ -164,7 +166,7 @@ impl ServiceEndpoints { Url::parse(&format!( "udp://{}:{}/announce", instance_ip, - udp.bind_address.port() + udp.bind_address().port() )) .ok() }) @@ -181,7 +183,7 @@ impl ServiceEndpoints { Url::parse(&format!( "http://{}:{}/announce", // DevSkim: ignore DS137138 instance_ip, - http.bind_address.port() + http.bind_address().port() )) .ok() }) @@ -216,8 +218,17 @@ impl ServiceEndpoints { /// Runtime outputs generated during deployment operations /// /// This struct contains fields that are generated during deployment operations -/// and represent the runtime state of deployed infrastructure. These fields -/// are mutable as operations progress. +/// and represent the runtime state of deployed infrastructure. Fields are +/// private to protect invariants and provide semantic clarity through setters. +/// +/// # Lifecycle +/// +/// Fields are populated at different stages of the deployment lifecycle: +/// - **Creation**: All fields are `None` (use `RuntimeOutputs::new()`) +/// - **After Provisioning**: `instance_ip` and `provision_method` are set +/// (use `record_provisioning()` or `record_registration()`) +/// - **After Run Command**: `service_endpoints` is set +/// (use `record_services_started()`) /// /// # Future Fields /// @@ -231,11 +242,15 @@ impl ServiceEndpoints { /// use torrust_tracker_deployer_lib::domain::environment::runtime_outputs::{RuntimeOutputs, ProvisionMethod}; /// use std::net::{IpAddr, Ipv4Addr}; /// -/// let runtime_outputs = RuntimeOutputs { -/// instance_ip: Some(IpAddr::V4(Ipv4Addr::new(192, 168, 1, 100))), -/// provision_method: Some(ProvisionMethod::Provisioned), -/// service_endpoints: None, -/// }; +/// // Create empty runtime outputs +/// let mut runtime_outputs = RuntimeOutputs::new(); +/// assert!(runtime_outputs.instance_ip().is_none()); +/// +/// // After provisioning, record the IP and method +/// let ip = IpAddr::V4(Ipv4Addr::new(192, 168, 1, 100)); +/// runtime_outputs.record_provisioning(ip); +/// assert_eq!(runtime_outputs.instance_ip(), Some(ip)); +/// assert_eq!(runtime_outputs.provision_method(), Some(ProvisionMethod::Provisioned)); /// ``` #[derive(Debug, Clone, Serialize, Deserialize)] pub struct RuntimeOutputs { @@ -243,7 +258,7 @@ pub struct RuntimeOutputs { /// /// This field stores the IP address of the provisioned instance and is /// `None` until the environment has been successfully provisioned. - pub instance_ip: Option, + instance_ip: Option, /// How the instance was provisioned /// @@ -255,7 +270,7 @@ pub struct RuntimeOutputs { /// - `Some(Provisioned)`: Instance was created via `provision` command /// - `Some(Registered)`: Instance was connected via `register` command #[serde(default)] - pub provision_method: Option, + provision_method: Option, /// Service endpoints populated after services are started /// @@ -265,5 +280,130 @@ pub struct RuntimeOutputs { /// - `None`: Services not yet started or legacy state /// - `Some(endpoints)`: URLs for all running services #[serde(default)] - pub service_endpoints: Option, + service_endpoints: Option, +} + +impl RuntimeOutputs { + /// Creates new empty runtime outputs + /// + /// All fields are initialized to `None`, representing an environment + /// that has not yet been provisioned or run. + /// + /// # Examples + /// + /// ```rust + /// use torrust_tracker_deployer_lib::domain::environment::runtime_outputs::RuntimeOutputs; + /// + /// let outputs = RuntimeOutputs::new(); + /// assert!(outputs.instance_ip().is_none()); + /// assert!(outputs.provision_method().is_none()); + /// assert!(outputs.service_endpoints().is_none()); + /// ``` + #[must_use] + pub fn new() -> Self { + Self { + instance_ip: None, + provision_method: None, + service_endpoints: None, + } + } + + // ========================================================================= + // Getters - Access runtime output values + // ========================================================================= + + /// Returns the instance IP address if available + /// + /// This is `None` until the environment has been provisioned or registered. + #[must_use] + pub fn instance_ip(&self) -> Option { + self.instance_ip + } + + /// Returns how the instance was provisioned + /// + /// - `None`: Unknown or legacy state + /// - `Some(Provisioned)`: Created via `provision` command + /// - `Some(Registered)`: Connected via `register` command + #[must_use] + pub fn provision_method(&self) -> Option { + self.provision_method + } + + /// Returns the service endpoints if available + /// + /// This is `None` until the `run` command has started services successfully. + #[must_use] + pub fn service_endpoints(&self) -> Option<&ServiceEndpoints> { + self.service_endpoints.as_ref() + } + + // ========================================================================= + // Semantic Setters - Record deployment lifecycle events + // ========================================================================= + + /// Records that provisioning has completed with the given instance IP + /// + /// Call this after the `provision` command successfully creates infrastructure. + /// Sets both `instance_ip` and `provision_method` to `Provisioned`. + /// + /// # Arguments + /// + /// * `ip` - The IP address of the newly provisioned instance + pub fn record_provisioning(&mut self, ip: IpAddr) { + self.instance_ip = Some(ip); + self.provision_method = Some(ProvisionMethod::Provisioned); + } + + /// Records that an existing instance has been registered + /// + /// Call this after the `register` command connects to existing infrastructure. + /// Sets both `instance_ip` and `provision_method` to `Registered`. + /// + /// # Arguments + /// + /// * `ip` - The IP address of the registered instance + pub fn record_registration(&mut self, ip: IpAddr) { + self.instance_ip = Some(ip); + self.provision_method = Some(ProvisionMethod::Registered); + } + + /// Records that services have been started with the given endpoints + /// + /// Call this after the `run` command successfully starts all services. + /// The endpoints can then be displayed to users or used for health checks. + /// + /// # Arguments + /// + /// * `endpoints` - The URLs for all running services + pub fn record_services_started(&mut self, endpoints: ServiceEndpoints) { + self.service_endpoints = Some(endpoints); + } + + // ========================================================================= + // Low-level setters - For backward compatibility and state restoration + // ========================================================================= + + /// Sets the instance IP directly + /// + /// Prefer `record_provisioning()` or `record_registration()` which also + /// set the provision method. This method is provided for cases where + /// only the IP needs to be updated (e.g., deserialization workarounds). + pub fn set_instance_ip(&mut self, ip: IpAddr) { + self.instance_ip = Some(ip); + } + + /// Sets the provision method directly + /// + /// Prefer `record_provisioning()` or `record_registration()` which also + /// set the instance IP. This method is provided for backward compatibility. + pub fn set_provision_method(&mut self, method: ProvisionMethod) { + self.provision_method = Some(method); + } +} + +impl Default for RuntimeOutputs { + fn default() -> Self { + Self::new() + } } diff --git a/src/domain/environment/state/mod.rs b/src/domain/environment/state/mod.rs index bb25a150..9610708c 100644 --- a/src/domain/environment/state/mod.rs +++ b/src/domain/environment/state/mod.rs @@ -243,7 +243,7 @@ impl AnyEnvironmentState { /// A reference to the `EnvironmentName` contained within the environment. #[must_use] pub fn name(&self) -> &EnvironmentName { - &self.context().user_inputs.name + self.context().user_inputs.name() } /// Get the state name as a string @@ -405,7 +405,7 @@ impl AnyEnvironmentState { /// A reference to the `InstanceName` contained within the environment. #[must_use] pub fn instance_name(&self) -> &crate::domain::environment::InstanceName { - &self.context().user_inputs.instance_name + self.context().user_inputs.instance_name() } /// Get the LXD profile name regardless of current state @@ -441,7 +441,7 @@ impl AnyEnvironmentState { /// A reference to the `SshCredentials` contained within the environment. #[must_use] pub fn ssh_credentials(&self) -> &crate::adapters::ssh::SshCredentials { - &self.context().user_inputs.ssh_credentials + self.context().user_inputs.ssh_credentials() } /// Get the SSH port regardless of current state @@ -454,7 +454,7 @@ impl AnyEnvironmentState { /// The SSH port number. #[must_use] pub fn ssh_port(&self) -> u16 { - self.context().user_inputs.ssh_port + self.context().user_inputs.ssh_port() } /// Get the provider name regardless of current state @@ -496,7 +496,7 @@ impl AnyEnvironmentState { /// A reference to the `TrackerConfig` contained within the environment. #[must_use] pub fn tracker_config(&self) -> &crate::domain::tracker::TrackerConfig { - &self.context().user_inputs.tracker + self.context().user_inputs.tracker() } /// Get the instance IP address if available, regardless of current state @@ -510,7 +510,7 @@ impl AnyEnvironmentState { /// - `None` if the environment hasn't been provisioned yet #[must_use] pub fn instance_ip(&self) -> Option { - self.context().runtime_outputs.instance_ip + self.context().runtime_outputs.instance_ip() } /// Get when the environment was created @@ -538,7 +538,7 @@ impl AnyEnvironmentState { /// - `None` if the provision method hasn't been set yet (legacy or pre-provisioned state) #[must_use] pub fn provision_method(&self) -> Option { - self.context().runtime_outputs.provision_method + self.context().runtime_outputs.provision_method() } /// Get the service endpoints if available, regardless of current state @@ -552,7 +552,7 @@ impl AnyEnvironmentState { /// - `None` if services haven't been started yet or URLs weren't recorded #[must_use] pub fn service_endpoints(&self) -> Option<&ServiceEndpoints> { - self.context().runtime_outputs.service_endpoints.as_ref() + self.context().runtime_outputs.service_endpoints() } /// Get the Prometheus configuration if enabled, regardless of current state @@ -566,7 +566,7 @@ impl AnyEnvironmentState { /// - `None` if Prometheus is not enabled #[must_use] pub fn prometheus_config(&self) -> Option<&crate::domain::prometheus::PrometheusConfig> { - self.context().user_inputs.prometheus.as_ref() + self.context().user_inputs.prometheus() } /// Get the Grafana configuration if enabled, regardless of current state @@ -580,7 +580,7 @@ impl AnyEnvironmentState { /// - `None` if Grafana is not enabled #[must_use] pub fn grafana_config(&self) -> Option<&crate::domain::grafana::GrafanaConfig> { - self.context().user_inputs.grafana.as_ref() + self.context().user_inputs.grafana() } /// Get the HTTPS configuration if enabled, regardless of current state @@ -594,7 +594,7 @@ impl AnyEnvironmentState { /// - `None` if HTTPS is not enabled #[must_use] pub fn https_config(&self) -> Option<&crate::domain::https::HttpsConfig> { - self.context().user_inputs.https.as_ref() + self.context().user_inputs.https() } /// Check if this environment was registered from existing infrastructure diff --git a/src/domain/environment/state/released.rs b/src/domain/environment/state/released.rs index 59bffd96..a7b159eb 100644 --- a/src/domain/environment/state/released.rs +++ b/src/domain/environment/state/released.rs @@ -37,7 +37,9 @@ impl Environment { mut self, service_endpoints: ServiceEndpoints, ) -> Environment { - self.context.runtime_outputs.service_endpoints = Some(service_endpoints); + self.context + .runtime_outputs + .record_services_started(service_endpoints); self.with_state(Running) } diff --git a/src/domain/environment/testing.rs b/src/domain/environment/testing.rs index 6419fe62..6dd7560c 100644 --- a/src/domain/environment/testing.rs +++ b/src/domain/environment/testing.rs @@ -145,33 +145,32 @@ impl EnvironmentTestBuilder { ssh_username, ); - let instance_name = - InstanceName::new(format!("torrust-tracker-vm-{}", env_name.as_str())).unwrap(); let profile_name = ProfileName::new(format!("lxd-{}", env_name.as_str())).unwrap(); let provider_config = ProviderConfig::Lxd(LxdConfig { profile_name }); + let user_inputs = UserInputs::with_tracker( + &env_name, + provider_config, + ssh_credentials, + 22, + TrackerConfig::default(), + self.prometheus_config.clone(), + // Grafana is only enabled when Prometheus is enabled (cross-service invariant) + self.prometheus_config + .as_ref() + .map(|_| GrafanaConfig::default()), + None, + ) + .expect("Test UserInputs should always be valid with defaults"); + let context = EnvironmentContext { created_at: test_timestamp(), - user_inputs: UserInputs { - name: env_name, - instance_name, - provider_config, - ssh_credentials, - ssh_port: 22, - tracker: TrackerConfig::default(), - prometheus: self.prometheus_config, - grafana: Some(GrafanaConfig::default()), - https: None, - }, + user_inputs, internal_config: InternalConfig { data_dir: data_dir.clone(), build_dir: build_dir.clone(), }, - runtime_outputs: crate::domain::environment::RuntimeOutputs { - instance_ip: None, - provision_method: None, - service_endpoints: None, - }, + runtime_outputs: crate::domain::environment::RuntimeOutputs::new(), }; let environment = Environment { diff --git a/src/domain/environment/user_inputs.rs b/src/domain/environment/user_inputs.rs index 9964c123..a57b8bbe 100644 --- a/src/domain/environment/user_inputs.rs +++ b/src/domain/environment/user_inputs.rs @@ -18,6 +18,10 @@ //! //! Add new fields here when: User needs to configure something at environment creation time. +use std::fmt; + +use serde::{Deserialize, Serialize}; + use crate::adapters::ssh::SshCredentials; use crate::domain::environment::EnvironmentName; use crate::domain::grafana::GrafanaConfig; @@ -26,7 +30,70 @@ use crate::domain::prometheus::PrometheusConfig; use crate::domain::provider::{Provider, ProviderConfig}; use crate::domain::tracker::TrackerConfig; use crate::domain::InstanceName; -use serde::{Deserialize, Serialize}; + +/// Errors for user inputs validation +/// +/// These errors represent cross-service invariant violations that can only be +/// detected when considering multiple service configurations together. +#[derive(Debug, Clone, PartialEq)] +pub enum UserInputsError { + /// Grafana requires Prometheus to be configured as its data source + GrafanaRequiresPrometheus, + + /// HTTPS section is defined but no service has TLS configured + HttpsSectionWithoutTlsServices, + + /// At least one service has TLS configured but HTTPS section is missing + TlsServicesWithoutHttpsSection, +} + +impl fmt::Display for UserInputsError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::GrafanaRequiresPrometheus => { + write!( + f, + "Grafana requires Prometheus to be configured as its data source" + ) + } + Self::HttpsSectionWithoutTlsServices => { + write!( + f, + "HTTPS section is defined but no service has TLS configured" + ) + } + Self::TlsServicesWithoutHttpsSection => { + write!( + f, + "At least one service has TLS configured but HTTPS section is missing" + ) + } + } + } +} + +impl std::error::Error for UserInputsError {} + +impl UserInputsError { + /// Provides actionable help text for fixing the error + #[must_use] + pub fn help(&self) -> &'static str { + match self { + Self::GrafanaRequiresPrometheus => { + "Add a 'prometheus' section to your configuration, or remove the 'grafana' section. \ + Grafana needs Prometheus as its metrics data source." + } + Self::HttpsSectionWithoutTlsServices => { + "Either remove the 'https' section, or set 'use_tls_proxy: true' on at least one \ + service (http_api, http_trackers, or health_check_api)." + } + Self::TlsServicesWithoutHttpsSection => { + "Add an 'https' section with 'admin_email' for Let's Encrypt certificate management. \ + Services with 'use_tls_proxy: true' require Caddy for TLS termination." + } + } + } +} /// User-provided configuration when creating an environment /// @@ -34,6 +101,13 @@ use serde::{Deserialize, Serialize}; /// an environment. These fields are immutable throughout the environment lifecycle /// and represent the user's configuration choices. /// +/// # Cross-Service Invariants +/// +/// The following invariants are validated at construction time: +/// - **Grafana requires Prometheus**: If Grafana is enabled, Prometheus must also be enabled +/// - **HTTPS requires TLS services**: If HTTPS section is present, at least one service must have TLS +/// - **TLS requires HTTPS**: If any service has TLS, HTTPS section must be present +/// /// # Examples /// /// ```rust @@ -50,69 +124,69 @@ use serde::{Deserialize, Serialize}; /// let provider_config = ProviderConfig::Lxd(LxdConfig { /// profile_name: ProfileName::new("torrust-profile-production".to_string())?, /// }); +/// let ssh_credentials = SshCredentials::new( +/// PathBuf::from("keys/prod_rsa"), +/// PathBuf::from("keys/prod_rsa.pub"), +/// Username::new("torrust".to_string())?, +/// ); +/// let env_name = EnvironmentName::new("production".to_string())?; /// -/// let user_inputs = UserInputs { -/// name: EnvironmentName::new("production".to_string())?, -/// instance_name: InstanceName::new("torrust-tracker-vm-production".to_string())?, -/// provider_config, -/// ssh_credentials: SshCredentials::new( -/// PathBuf::from("keys/prod_rsa"), -/// PathBuf::from("keys/prod_rsa.pub"), -/// Username::new("torrust".to_string())?, -/// ), -/// ssh_port: 22, -/// tracker: TrackerConfig::default(), -/// prometheus: Some(PrometheusConfig::default()), -/// grafana: Some(GrafanaConfig::default()), -/// https: None, -/// }; +/// // Create with defaults (includes Prometheus and Grafana) +/// let user_inputs = UserInputs::new(&env_name, provider_config, ssh_credentials, 22)?; +/// +/// assert_eq!(user_inputs.name().as_str(), "production"); +/// assert!(user_inputs.prometheus().is_some()); +/// assert!(user_inputs.grafana().is_some()); /// # Ok::<(), Box>(()) /// ``` #[derive(Debug, Clone, Serialize, Deserialize)] pub struct UserInputs { /// The validated environment name - pub name: EnvironmentName, + name: EnvironmentName, /// The instance name for this environment (auto-generated from name) - pub instance_name: InstanceName, + instance_name: InstanceName, /// Provider-specific configuration (e.g., LXD profile, Hetzner settings) - pub provider_config: ProviderConfig, + provider_config: ProviderConfig, /// SSH credentials for connecting to instances in this environment - pub ssh_credentials: SshCredentials, + ssh_credentials: SshCredentials, /// SSH port for connecting to instances in this environment - pub ssh_port: u16, + ssh_port: u16, /// Tracker deployment configuration - pub tracker: TrackerConfig, + tracker: TrackerConfig, /// Prometheus metrics collection configuration (optional) /// /// When present, Prometheus service is enabled in the deployment. /// When absent (`None`), Prometheus service is disabled. /// Default: `Some(PrometheusConfig::default())` in generated templates. - pub prometheus: Option, + prometheus: Option, /// Grafana visualization and dashboard configuration (optional) /// /// When present, Grafana service is enabled in the deployment. /// When absent (`None`), Grafana service is disabled. - /// Requires Prometheus to be enabled - dependency validated at configuration time. + /// Requires Prometheus to be enabled - dependency validated at construction time. /// Default: `Some(GrafanaConfig::default())` in generated templates. - pub grafana: Option, + grafana: Option, /// HTTPS/TLS configuration for Caddy reverse proxy (optional) /// /// When present, Caddy service is deployed as a TLS termination proxy. /// When absent (`None`), services are exposed directly over HTTP. /// Requires at least one service to have TLS configuration. - pub https: Option, + https: Option, } impl UserInputs { - /// Creates a new `UserInputs` with auto-generated instance name + /// Creates a new `UserInputs` with auto-generated instance name and default services + /// + /// Creates a `UserInputs` with default tracker configuration, Prometheus, and Grafana + /// enabled. This is the standard setup for most deployments. /// /// # Arguments /// @@ -125,6 +199,13 @@ impl UserInputs { /// /// A new `UserInputs` with: /// - Auto-generated instance name: `torrust-tracker-vm-{env_name}` + /// - Default tracker configuration + /// - Prometheus and Grafana enabled (satisfies cross-service invariants) + /// + /// # Errors + /// + /// This constructor with defaults cannot fail because the default configuration + /// (Prometheus + Grafana, no HTTPS) always satisfies cross-service invariants. /// /// # Examples /// @@ -147,45 +228,54 @@ impl UserInputs { /// profile_name: ProfileName::new("torrust-profile-production".to_string())?, /// }); /// - /// let user_inputs = UserInputs::new(&env_name, provider_config, ssh_credentials, 22); + /// let user_inputs = UserInputs::new(&env_name, provider_config, ssh_credentials, 22)?; /// - /// assert_eq!(user_inputs.instance_name.as_str(), "torrust-tracker-vm-production"); + /// assert_eq!(user_inputs.instance_name().as_str(), "torrust-tracker-vm-production"); /// assert_eq!(user_inputs.provider(), Provider::Lxd); /// /// # Ok::<(), Box>(()) /// ``` - /// - /// # Panics - /// - /// This function does not panic. All name generation is guaranteed to succeed - /// for valid environment names. - #[must_use] pub fn new( name: &EnvironmentName, provider_config: ProviderConfig, ssh_credentials: SshCredentials, ssh_port: u16, - ) -> Self { - let instance_name = Self::generate_instance_name(name); - - Self { - name: name.clone(), - instance_name, + ) -> Result { + // Default configuration: Prometheus + Grafana, no HTTPS + // This always passes validation (Grafana has Prometheus, no TLS configured) + Self::with_tracker( + name, provider_config, ssh_credentials, ssh_port, - tracker: TrackerConfig::default(), - prometheus: Some(PrometheusConfig::default()), - grafana: Some(GrafanaConfig::default()), - https: None, - } + TrackerConfig::default(), + Some(PrometheusConfig::default()), + Some(GrafanaConfig::default()), + None, + ) } - /// Creates a new `UserInputs` with custom tracker configuration + /// Creates a new `UserInputs` with custom tracker and service configuration /// - /// This is similar to `new` but allows specifying custom tracker, - /// prometheus, grafana, and https configurations instead of using defaults. - #[must_use] + /// This constructor allows full control over all service configurations. + /// Cross-service invariants are validated at construction time. + /// + /// # Arguments + /// + /// * `name` - The validated environment name + /// * `provider_config` - Provider-specific configuration + /// * `ssh_credentials` - SSH credentials for connecting to instances + /// * `ssh_port` - SSH port for connecting to instances + /// * `tracker` - Tracker deployment configuration + /// * `prometheus` - Optional Prometheus configuration + /// * `grafana` - Optional Grafana configuration (requires Prometheus) + /// * `https` - Optional HTTPS/TLS configuration (requires TLS services) + /// + /// # Errors + /// + /// - `GrafanaRequiresPrometheus` if Grafana is configured without Prometheus + /// - `HttpsSectionWithoutTlsServices` if HTTPS section exists but no service uses TLS + /// - `TlsServicesWithoutHttpsSection` if a service uses TLS but HTTPS section is missing #[allow(clippy::too_many_arguments)] pub fn with_tracker( name: &EnvironmentName, @@ -196,10 +286,26 @@ impl UserInputs { prometheus: Option, grafana: Option, https: Option, - ) -> Self { + ) -> Result { + // Cross-service invariant: Grafana requires Prometheus as data source + if grafana.is_some() && prometheus.is_none() { + return Err(UserInputsError::GrafanaRequiresPrometheus); + } + + // Cross-service invariant: HTTPS section requires at least one TLS service + let has_tls = tracker.has_any_tls_configured(); + if https.is_some() && !has_tls { + return Err(UserInputsError::HttpsSectionWithoutTlsServices); + } + + // Inverse: TLS services require HTTPS section + if has_tls && https.is_none() { + return Err(UserInputsError::TlsServicesWithoutHttpsSection); + } + let instance_name = Self::generate_instance_name(name); - Self { + Ok(Self { name: name.clone(), instance_name, provider_config, @@ -209,7 +315,59 @@ impl UserInputs { prometheus, grafana, https, - } + }) + } + + // ======================================================================== + // Getter Methods + // ======================================================================== + + /// Returns the environment name + #[must_use] + pub fn name(&self) -> &EnvironmentName { + &self.name + } + + /// Returns the instance name + #[must_use] + pub fn instance_name(&self) -> &InstanceName { + &self.instance_name + } + + /// Returns the SSH credentials + #[must_use] + pub fn ssh_credentials(&self) -> &SshCredentials { + &self.ssh_credentials + } + + /// Returns the SSH port + #[must_use] + pub fn ssh_port(&self) -> u16 { + self.ssh_port + } + + /// Returns the tracker configuration + #[must_use] + pub fn tracker(&self) -> &TrackerConfig { + &self.tracker + } + + /// Returns the Prometheus configuration if enabled + #[must_use] + pub fn prometheus(&self) -> Option<&PrometheusConfig> { + self.prometheus.as_ref() + } + + /// Returns the Grafana configuration if enabled + #[must_use] + pub fn grafana(&self) -> Option<&GrafanaConfig> { + self.grafana.as_ref() + } + + /// Returns the HTTPS configuration if enabled + #[must_use] + pub fn https(&self) -> Option<&HttpsConfig> { + self.https.as_ref() } // ======================================================================== @@ -238,7 +396,7 @@ impl UserInputs { /// profile_name: ProfileName::new("test-profile".to_string())?, /// }); /// - /// let user_inputs = UserInputs::new(&env_name, provider_config, ssh_credentials, 22); + /// let user_inputs = UserInputs::new(&env_name, provider_config, ssh_credentials, 22)?; /// assert_eq!(user_inputs.provider(), Provider::Lxd); /// /// # Ok::<(), Box>(()) @@ -282,11 +440,16 @@ impl UserInputs { #[cfg(test)] mod tests { + use std::path::PathBuf; + use super::*; use crate::domain::provider::LxdConfig; + use crate::domain::tracker::{ + DatabaseConfig, HealthCheckApiConfig, HttpApiConfig, SqliteConfig, TrackerCoreConfig, + UdpTrackerConfig, + }; use crate::domain::ProfileName; - use crate::shared::{ApiToken, Username}; - use std::path::PathBuf; + use crate::shared::{ApiToken, DomainName, Username}; fn create_test_ssh_credentials() -> SshCredentials { SshCredentials::new( @@ -302,31 +465,59 @@ mod tests { }) } + fn create_test_env_name() -> EnvironmentName { + EnvironmentName::new("test-env".to_string()).unwrap() + } + + fn create_tracker_config_with_tls() -> TrackerConfig { + TrackerConfig::new( + TrackerCoreConfig::new( + DatabaseConfig::Sqlite(SqliteConfig::new("tracker.db").unwrap()), + false, + ), + vec![UdpTrackerConfig::new("0.0.0.0:6969".parse().unwrap(), None).unwrap()], + vec![], + HttpApiConfig::new( + "0.0.0.0:1212".parse().unwrap(), + "token".to_string().into(), + Some(DomainName::new("api.example.com").unwrap()), + true, // TLS enabled + ) + .unwrap(), + HealthCheckApiConfig::new("127.0.0.1:1313".parse().unwrap(), None, false).unwrap(), + ) + .unwrap() + } + + fn create_tracker_config_without_tls() -> TrackerConfig { + TrackerConfig::default() + } + #[test] fn it_should_create_user_inputs_with_lxd_provider() { - let env_name = EnvironmentName::new("test-env".to_string()).unwrap(); + let env_name = create_test_env_name(); let provider_config = create_lxd_provider_config("test-profile"); let ssh_credentials = create_test_ssh_credentials(); - let user_inputs = UserInputs::new(&env_name, provider_config, ssh_credentials, 22); + let user_inputs = UserInputs::new(&env_name, provider_config, ssh_credentials, 22).unwrap(); - assert_eq!(user_inputs.name.as_str(), "test-env"); + assert_eq!(user_inputs.name().as_str(), "test-env"); assert_eq!( - user_inputs.instance_name.as_str(), + user_inputs.instance_name().as_str(), "torrust-tracker-vm-test-env" ); assert_eq!(user_inputs.provider(), Provider::Lxd); assert_eq!(user_inputs.provider_config().provider_name(), "lxd"); - assert_eq!(user_inputs.ssh_port, 22); + assert_eq!(user_inputs.ssh_port(), 22); } #[test] fn it_should_return_provider_config_for_lxd() { - let env_name = EnvironmentName::new("test-env".to_string()).unwrap(); + let env_name = create_test_env_name(); let provider_config = create_lxd_provider_config("my-custom-profile"); let ssh_credentials = create_test_ssh_credentials(); - let user_inputs = UserInputs::new(&env_name, provider_config, ssh_credentials, 22); + let user_inputs = UserInputs::new(&env_name, provider_config, ssh_credentials, 22).unwrap(); let lxd_config = user_inputs.provider_config().as_lxd().unwrap(); assert_eq!(lxd_config.profile_name.as_str(), "my-custom-profile"); @@ -336,7 +527,7 @@ mod tests { fn it_should_return_provider_config_for_hetzner() { use crate::domain::provider::HetznerConfig; - let env_name = EnvironmentName::new("test-env".to_string()).unwrap(); + let env_name = create_test_env_name(); let provider_config = ProviderConfig::Hetzner(HetznerConfig { api_token: ApiToken::from("test-token"), server_type: "cx22".to_string(), @@ -345,7 +536,7 @@ mod tests { }); let ssh_credentials = create_test_ssh_credentials(); - let user_inputs = UserInputs::new(&env_name, provider_config, ssh_credentials, 22); + let user_inputs = UserInputs::new(&env_name, provider_config, ssh_credentials, 22).unwrap(); assert_eq!(user_inputs.provider(), Provider::Hetzner); assert!(user_inputs.provider_config().as_lxd().is_none()); @@ -363,11 +554,163 @@ mod tests { let provider_config = create_lxd_provider_config("prod-profile"); let ssh_credentials = create_test_ssh_credentials(); - let user_inputs = UserInputs::new(&env_name, provider_config, ssh_credentials, 22); + let user_inputs = UserInputs::new(&env_name, provider_config, ssh_credentials, 22).unwrap(); assert_eq!( - user_inputs.instance_name.as_str(), + user_inputs.instance_name().as_str(), "torrust-tracker-vm-production" ); } + + // ======================================================================== + // Cross-Service Invariant Tests + // ======================================================================== + + #[test] + fn it_should_reject_grafana_without_prometheus() { + let env_name = create_test_env_name(); + let provider_config = create_lxd_provider_config("test-profile"); + let ssh_credentials = create_test_ssh_credentials(); + + let result = UserInputs::with_tracker( + &env_name, + provider_config, + ssh_credentials, + 22, + create_tracker_config_without_tls(), + None, // No Prometheus + Some(GrafanaConfig::default()), // Grafana enabled + None, + ); + + assert!( + matches!(result, Err(UserInputsError::GrafanaRequiresPrometheus)), + "Expected GrafanaRequiresPrometheus error, got {result:?}" + ); + } + + #[test] + fn it_should_accept_grafana_with_prometheus() { + let env_name = create_test_env_name(); + let provider_config = create_lxd_provider_config("test-profile"); + let ssh_credentials = create_test_ssh_credentials(); + + let result = UserInputs::with_tracker( + &env_name, + provider_config, + ssh_credentials, + 22, + create_tracker_config_without_tls(), + Some(PrometheusConfig::default()), // Prometheus enabled + Some(GrafanaConfig::default()), // Grafana enabled + None, + ); + + assert!(result.is_ok()); + } + + #[test] + fn it_should_reject_https_section_without_tls_services() { + let env_name = create_test_env_name(); + let provider_config = create_lxd_provider_config("test-profile"); + let ssh_credentials = create_test_ssh_credentials(); + + let result = UserInputs::with_tracker( + &env_name, + provider_config, + ssh_credentials, + 22, + create_tracker_config_without_tls(), // No TLS on any service + Some(PrometheusConfig::default()), + Some(GrafanaConfig::default()), + Some(HttpsConfig::new("admin@example.com", false).expect("valid email")), // HTTPS section present + ); + + assert!( + matches!(result, Err(UserInputsError::HttpsSectionWithoutTlsServices)), + "Expected HttpsSectionWithoutTlsServices error, got {result:?}" + ); + } + + #[test] + fn it_should_reject_tls_services_without_https_section() { + let env_name = create_test_env_name(); + let provider_config = create_lxd_provider_config("test-profile"); + let ssh_credentials = create_test_ssh_credentials(); + + let result = UserInputs::with_tracker( + &env_name, + provider_config, + ssh_credentials, + 22, + create_tracker_config_with_tls(), // Has TLS on HTTP API + Some(PrometheusConfig::default()), + Some(GrafanaConfig::default()), + None, // No HTTPS section + ); + + assert!( + matches!(result, Err(UserInputsError::TlsServicesWithoutHttpsSection)), + "Expected TlsServicesWithoutHttpsSection error, got {result:?}" + ); + } + + #[test] + fn it_should_accept_tls_services_with_https_section() { + let env_name = create_test_env_name(); + let provider_config = create_lxd_provider_config("test-profile"); + let ssh_credentials = create_test_ssh_credentials(); + + let result = UserInputs::with_tracker( + &env_name, + provider_config, + ssh_credentials, + 22, + create_tracker_config_with_tls(), + Some(PrometheusConfig::default()), + Some(GrafanaConfig::default()), + Some(HttpsConfig::new("admin@example.com", false).expect("valid email")), + ); + + assert!(result.is_ok()); + } + + #[test] + fn it_should_accept_no_tls_and_no_https() { + let env_name = create_test_env_name(); + let provider_config = create_lxd_provider_config("test-profile"); + let ssh_credentials = create_test_ssh_credentials(); + + let result = UserInputs::with_tracker( + &env_name, + provider_config, + ssh_credentials, + 22, + create_tracker_config_without_tls(), + Some(PrometheusConfig::default()), + Some(GrafanaConfig::default()), + None, // No HTTPS + ); + + assert!(result.is_ok()); + } + + #[test] + fn it_should_provide_helpful_error_messages() { + assert_eq!( + UserInputsError::GrafanaRequiresPrometheus.to_string(), + "Grafana requires Prometheus to be configured as its data source" + ); + assert!(UserInputsError::GrafanaRequiresPrometheus + .help() + .contains("prometheus")); + + assert!(UserInputsError::HttpsSectionWithoutTlsServices + .help() + .contains("use_tls_proxy")); + + assert!(UserInputsError::TlsServicesWithoutHttpsSection + .help() + .contains("https")); + } } diff --git a/src/domain/https/config.rs b/src/domain/https/config.rs index 97a551c1..e69c2b93 100644 --- a/src/domain/https/config.rs +++ b/src/domain/https/config.rs @@ -12,9 +12,56 @@ //! the configuration through the environment lifecycle. use serde::{Deserialize, Serialize}; +use thiserror::Error; use crate::shared::Email; +/// Error type for `HttpsConfig` construction failures +/// +/// Contains validation errors that can occur when constructing an `HttpsConfig`. +#[derive(Debug, Clone, Error, PartialEq, Eq)] +pub enum HttpsConfigError { + /// The admin email address is invalid + #[error("Invalid admin email '{email}': {reason}")] + InvalidEmail { + /// The invalid email that was provided + email: String, + /// The reason why the email is invalid + reason: String, + }, +} + +impl HttpsConfigError { + /// Returns actionable help text for this error + /// + /// Provides detailed guidance on how to fix the configuration issue. + #[must_use] + pub fn help(&self) -> &'static str { + match self { + Self::InvalidEmail { .. } => { + "Invalid admin email format.\n\ + \n\ + The admin email is used by Let's Encrypt to send:\n\ + - Certificate expiration warnings\n\ + - Renewal failure notifications\n\ + - Important security updates\n\ + \n\ + Requirements:\n\ + - Must be a valid email format (e.g., admin@example.com)\n\ + - Should be a monitored mailbox for security alerts\n\ + \n\ + Fix:\n\ + Update the admin_email in your HTTPS configuration:\n\ + \n\ + \"https\": {\n\ + \"admin_email\": \"admin@yourdomain.com\",\n\ + \"use_staging\": false\n\ + }" + } + } + } +} + /// Domain-level HTTPS configuration for TLS termination /// /// Contains validated HTTPS settings used for Caddy reverse proxy configuration. @@ -31,7 +78,7 @@ use crate::shared::Email; /// ```rust /// use torrust_tracker_deployer_lib::domain::https::HttpsConfig; /// -/// let config = HttpsConfig::new("admin@example.com", false); +/// let config = HttpsConfig::new("admin@example.com", false).unwrap(); /// assert_eq!(config.admin_email(), "admin@example.com"); /// assert!(!config.use_staging()); /// ``` @@ -50,38 +97,60 @@ pub struct HttpsConfig { } impl HttpsConfig { - /// Creates a new HTTPS configuration + /// Creates a new HTTPS configuration with validated email + /// + /// Validates the admin email format at construction time, ensuring + /// the configuration is always valid. /// /// # Arguments /// - /// * `admin_email` - Admin email for Let's Encrypt (already validated) + /// * `admin_email` - Admin email for Let's Encrypt notifications /// * `use_staging` - Whether to use staging environment /// + /// # Errors + /// + /// Returns `HttpsConfigError::InvalidEmail` if the email format is invalid. + /// /// # Examples /// /// ```rust /// use torrust_tracker_deployer_lib::domain::https::HttpsConfig; /// /// // Production configuration - /// let config = HttpsConfig::new("admin@example.com", false); + /// let config = HttpsConfig::new("admin@example.com", false).unwrap(); /// assert!(!config.use_staging()); /// /// // Staging configuration (for testing) - /// let staging = HttpsConfig::new("admin@example.com", true); + /// let staging = HttpsConfig::new("admin@example.com", true).unwrap(); /// assert!(staging.use_staging()); + /// + /// // Invalid email is rejected + /// let result = HttpsConfig::new("invalid-email", false); + /// assert!(result.is_err()); /// ``` - #[must_use] - pub fn new(admin_email: impl Into, use_staging: bool) -> Self { - Self { - admin_email: admin_email.into(), + pub fn new( + admin_email: impl Into, + use_staging: bool, + ) -> Result { + let email_str = admin_email.into(); + + // Validate email format using the shared Email type + Email::new(&email_str).map_err(|e| HttpsConfigError::InvalidEmail { + email: email_str.clone(), + reason: e.to_string(), + })?; + + Ok(Self { + admin_email: email_str, use_staging, - } + }) } /// Creates an HTTPS config from a validated email /// /// This is the preferred factory method when working with validated - /// email addresses from the application layer. + /// email addresses from the application layer. Since the email is + /// already validated, this method is infallible. /// /// # Arguments /// @@ -126,7 +195,8 @@ mod tests { #[test] fn it_should_create_https_config_with_production_ca() { - let config = HttpsConfig::new("admin@tracker.example.com", false); + let config = HttpsConfig::new("admin@tracker.example.com", false) + .expect("valid email should succeed"); assert_eq!(config.admin_email(), "admin@tracker.example.com"); assert!(!config.use_staging()); @@ -134,12 +204,29 @@ mod tests { #[test] fn it_should_create_https_config_with_staging_ca() { - let config = HttpsConfig::new("admin@tracker.example.com", true); + let config = HttpsConfig::new("admin@tracker.example.com", true) + .expect("valid email should succeed"); assert_eq!(config.admin_email(), "admin@tracker.example.com"); assert!(config.use_staging()); } + #[test] + fn it_should_reject_invalid_email() { + let result = HttpsConfig::new("invalid-email", false); + + assert!(result.is_err()); + let err = result.unwrap_err(); + assert!(matches!(err, HttpsConfigError::InvalidEmail { .. })); + } + + #[test] + fn it_should_reject_email_without_at_symbol() { + let result = HttpsConfig::new("admin.example.com", false); + + assert!(result.is_err()); + } + #[test] fn it_should_create_default_https_config() { let config = HttpsConfig::default(); @@ -150,7 +237,8 @@ mod tests { #[test] fn it_should_serialize_to_json() { - let config = HttpsConfig::new("admin@example.com", true); + let config = + HttpsConfig::new("admin@example.com", true).expect("valid email should succeed"); let json = serde_json::to_string(&config).expect("serialization should succeed"); @@ -171,9 +259,31 @@ mod tests { #[test] fn it_should_be_cloneable() { - let config = HttpsConfig::new("admin@example.com", true); + let config = + HttpsConfig::new("admin@example.com", true).expect("valid email should succeed"); let cloned = config.clone(); assert_eq!(config, cloned); } + + #[test] + fn it_should_create_from_validated_email() { + let email = Email::new("admin@example.com").expect("valid email"); + let config = HttpsConfig::from_validated_email(&email, false); + + assert_eq!(config.admin_email(), "admin@example.com"); + assert!(!config.use_staging()); + } + + #[test] + fn it_should_provide_help_for_invalid_email_error() { + let err = HttpsConfigError::InvalidEmail { + email: "bad".to_string(), + reason: "missing @".to_string(), + }; + + let help = err.help(); + assert!(help.contains("Invalid admin email format")); + assert!(help.contains("Let's Encrypt")); + } } diff --git a/src/domain/https/mod.rs b/src/domain/https/mod.rs index 84a137ce..25a8fd63 100644 --- a/src/domain/https/mod.rs +++ b/src/domain/https/mod.rs @@ -14,4 +14,4 @@ pub mod config; -pub use config::HttpsConfig; +pub use config::{HttpsConfig, HttpsConfigError}; diff --git a/src/domain/tracker/config/core/database/mod.rs b/src/domain/tracker/config/core/database/mod.rs index 14670dac..4bf8b285 100644 --- a/src/domain/tracker/config/core/database/mod.rs +++ b/src/domain/tracker/config/core/database/mod.rs @@ -16,8 +16,8 @@ use serde::{Deserialize, Serialize}; mod mysql; mod sqlite; -pub use mysql::MysqlConfig; -pub use sqlite::SqliteConfig; +pub use mysql::{MysqlConfig, MysqlConfigError}; +pub use sqlite::{SqliteConfig, SqliteConfigError}; /// `SQLite` driver name constant pub const DRIVER_SQLITE: &str = "sqlite3"; @@ -37,18 +37,14 @@ pub const DRIVER_MYSQL: &str = "mysql"; /// use torrust_tracker_deployer_lib::domain::tracker::{DatabaseConfig, SqliteConfig}; /// /// // SQLite configuration -/// let sqlite = DatabaseConfig::Sqlite(SqliteConfig { -/// database_name: "tracker.db".to_string(), -/// }); +/// let sqlite = DatabaseConfig::Sqlite(SqliteConfig::new("tracker.db").unwrap()); /// -/// // MySQL configuration (future) -/// // let mysql = DatabaseConfig::Mysql(MysqlConfig { -/// // host: "localhost".to_string(), -/// // port: 3306, -/// // database_name: "tracker".to_string(), -/// // username: "tracker_user".to_string(), -/// // password: "secure_password".to_string(), -/// // }); +/// // MySQL configuration +/// // use torrust_tracker_deployer_lib::domain::tracker::MysqlConfig; +/// // use torrust_tracker_deployer_lib::shared::Password; +/// // let mysql = DatabaseConfig::Mysql(MysqlConfig::new( +/// // "localhost", 3306, "tracker", "tracker_user", Password::from("secure_password") +/// // ).unwrap()); /// ``` #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] #[serde(tag = "driver", content = "config")] @@ -69,9 +65,7 @@ impl DatabaseConfig { /// ```rust /// use torrust_tracker_deployer_lib::domain::tracker::{DatabaseConfig, SqliteConfig}; /// - /// let config = DatabaseConfig::Sqlite(SqliteConfig { - /// database_name: "tracker.db".to_string(), - /// }); + /// let config = DatabaseConfig::Sqlite(SqliteConfig::new("tracker.db").unwrap()); /// assert_eq!(config.driver_name(), "sqlite3"); /// ``` #[must_use] @@ -89,16 +83,14 @@ impl DatabaseConfig { /// ```rust /// use torrust_tracker_deployer_lib::domain::tracker::{DatabaseConfig, SqliteConfig}; /// - /// let config = DatabaseConfig::Sqlite(SqliteConfig { - /// database_name: "tracker.db".to_string(), - /// }); + /// let config = DatabaseConfig::Sqlite(SqliteConfig::new("tracker.db").unwrap()); /// assert_eq!(config.database_name(), "tracker.db"); /// ``` #[must_use] pub fn database_name(&self) -> &str { match self { - Self::Sqlite(config) => &config.database_name, - Self::Mysql(config) => &config.database_name, + Self::Sqlite(config) => config.database_name(), + Self::Mysql(config) => config.database_name(), } } } @@ -110,9 +102,7 @@ mod tests { #[test] fn it_should_create_sqlite_database_config() { - let config = DatabaseConfig::Sqlite(SqliteConfig { - database_name: "test.db".to_string(), - }); + let config = DatabaseConfig::Sqlite(SqliteConfig::new("test.db").unwrap()); assert_eq!(config.driver_name(), "sqlite3"); assert_eq!(config.database_name(), "test.db"); @@ -120,9 +110,7 @@ mod tests { #[test] fn it_should_serialize_sqlite_config() { - let config = DatabaseConfig::Sqlite(SqliteConfig { - database_name: "tracker.db".to_string(), - }); + let config = DatabaseConfig::Sqlite(SqliteConfig::new("tracker.db").unwrap()); let json = serde_json::to_value(&config).unwrap(); assert_eq!(json["driver"], "sqlite3"); @@ -136,7 +124,7 @@ mod tests { match config { DatabaseConfig::Sqlite(sqlite_config) => { - assert_eq!(sqlite_config.database_name, "tracker.db"); + assert_eq!(sqlite_config.database_name(), "tracker.db"); } DatabaseConfig::Mysql(..) => panic!("Expected Sqlite variant"), } @@ -144,15 +132,16 @@ mod tests { #[test] fn it_should_create_mysql_database_config() { - use crate::shared::Password; - - let config = DatabaseConfig::Mysql(MysqlConfig { - host: "localhost".to_string(), - port: 3306, - database_name: "tracker".to_string(), - username: "tracker_user".to_string(), - password: Password::from("secure_password"), - }); + let config = DatabaseConfig::Mysql( + MysqlConfig::new( + "localhost", + 3306, + "tracker", + "tracker_user", + Password::from("secure_password"), + ) + .unwrap(), + ); assert_eq!(config.driver_name(), "mysql"); assert_eq!(config.database_name(), "tracker"); @@ -160,15 +149,16 @@ mod tests { #[test] fn it_should_serialize_mysql_config() { - use crate::shared::Password; - - let config = DatabaseConfig::Mysql(MysqlConfig { - host: "mysql".to_string(), - port: 3306, - database_name: "tracker".to_string(), - username: "tracker_user".to_string(), - password: Password::from("pass123"), - }); + let config = DatabaseConfig::Mysql( + MysqlConfig::new( + "mysql", + 3306, + "tracker", + "tracker_user", + Password::from("pass123"), + ) + .unwrap(), + ); let json = serde_json::to_value(&config).unwrap(); assert_eq!(json["driver"], "mysql"); @@ -195,11 +185,11 @@ mod tests { match config { DatabaseConfig::Mysql(mysql_config) => { - assert_eq!(mysql_config.host, "localhost"); - assert_eq!(mysql_config.port, 3306); - assert_eq!(mysql_config.database_name, "tracker"); - assert_eq!(mysql_config.username, "tracker_user"); - assert_eq!(mysql_config.password, Password::from("secure_password")); + assert_eq!(mysql_config.host(), "localhost"); + assert_eq!(mysql_config.port(), 3306); + assert_eq!(mysql_config.database_name(), "tracker"); + assert_eq!(mysql_config.username(), "tracker_user"); + assert_eq!(mysql_config.password(), &Password::from("secure_password")); } DatabaseConfig::Sqlite(..) => panic!("Expected Mysql variant"), } diff --git a/src/domain/tracker/config/core/database/mysql.rs b/src/domain/tracker/config/core/database/mysql.rs index 86969370..a3bf6d97 100644 --- a/src/domain/tracker/config/core/database/mysql.rs +++ b/src/domain/tracker/config/core/database/mysql.rs @@ -1,55 +1,289 @@ //! `MySQL` database configuration -use serde::{Deserialize, Serialize}; +use serde::{Deserialize, Deserializer, Serialize}; use crate::shared::Password; +/// Error type for `MySQL` configuration validation +#[derive(Debug, Clone, PartialEq, thiserror::Error)] +pub enum MysqlConfigError { + /// Database name cannot be empty + #[error("MySQL database name cannot be empty")] + EmptyDatabaseName, + /// Host cannot be empty + #[error("MySQL host cannot be empty")] + EmptyHost, + /// Port 0 is not valid + #[error("MySQL port 0 is not valid")] + InvalidPort, + /// Username cannot be empty + #[error("MySQL username cannot be empty")] + EmptyUsername, +} + +impl MysqlConfigError { + /// Returns detailed help text for resolving this error + #[must_use] + pub fn help(&self) -> &'static str { + match self { + Self::EmptyDatabaseName => { + "MySQL database name cannot be empty.\n\ + \n\ + The database_name field specifies the MySQL database to use.\n\ + \n\ + Fix:\n\ + Set the database_name field in your database configuration:\n\ + \n\ + \"database\": {\n\ + \"driver\": \"mysql\",\n\ + \"database_name\": \"tracker\",\n\ + ...\n\ + }" + } + Self::EmptyHost => { + "MySQL host cannot be empty.\n\ + \n\ + The host field specifies the MySQL server address.\n\ + \n\ + Common values:\n\ + - \"localhost\" for local MySQL server\n\ + - \"mysql\" when using Docker Compose service name\n\ + - IP address or hostname for remote servers\n\ + \n\ + Fix:\n\ + Set the host field in your database configuration:\n\ + \n\ + \"database\": {\n\ + \"driver\": \"mysql\",\n\ + \"host\": \"mysql\",\n\ + ...\n\ + }" + } + Self::InvalidPort => { + "MySQL port 0 is not valid.\n\ + \n\ + Port 0 means dynamic port assignment, which is not supported\n\ + for database connections.\n\ + \n\ + The default MySQL port is 3306.\n\ + \n\ + Fix:\n\ + Set a valid port in your database configuration:\n\ + \n\ + \"database\": {\n\ + \"driver\": \"mysql\",\n\ + \"port\": 3306,\n\ + ...\n\ + }" + } + Self::EmptyUsername => { + "MySQL username cannot be empty.\n\ + \n\ + The username field specifies the MySQL user for authentication.\n\ + \n\ + Fix:\n\ + Set the username field in your database configuration:\n\ + \n\ + \"database\": {\n\ + \"driver\": \"mysql\",\n\ + \"username\": \"tracker_user\",\n\ + ...\n\ + }" + } + } + } +} + /// `MySQL` database configuration -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[derive(Debug, Clone, Serialize, PartialEq)] pub struct MysqlConfig { /// `MySQL` server host (e.g., "localhost", "mysql") - pub host: String, + host: String, /// `MySQL` server port (typically 3306) - pub port: u16, + port: u16, /// Database name - pub database_name: String, + database_name: String, /// Database username - pub username: String, + username: String, /// Database password (redacted in debug output) - pub password: Password, + password: Password, +} + +impl MysqlConfig { + /// Creates a new `MySQL` configuration with validation + /// + /// # Errors + /// + /// - `EmptyDatabaseName` if database name is empty + /// - `EmptyHost` if host is empty + /// - `InvalidPort` if port is 0 + /// - `EmptyUsername` if username is empty + /// + /// # Examples + /// + /// ```rust + /// use torrust_tracker_deployer_lib::domain::tracker::MysqlConfig; + /// use torrust_tracker_deployer_lib::shared::Password; + /// + /// let config = MysqlConfig::new( + /// "localhost", + /// 3306, + /// "tracker", + /// "tracker_user", + /// Password::from("secure_password"), + /// ).unwrap(); + /// + /// assert_eq!(config.host(), "localhost"); + /// assert_eq!(config.port(), 3306); + /// assert_eq!(config.database_name(), "tracker"); + /// ``` + pub fn new( + host: impl Into, + port: u16, + database_name: impl Into, + username: impl Into, + password: Password, + ) -> Result { + let host = host.into(); + let database_name = database_name.into(); + let username = username.into(); + + if host.is_empty() { + return Err(MysqlConfigError::EmptyHost); + } + if port == 0 { + return Err(MysqlConfigError::InvalidPort); + } + if database_name.is_empty() { + return Err(MysqlConfigError::EmptyDatabaseName); + } + if username.is_empty() { + return Err(MysqlConfigError::EmptyUsername); + } + + Ok(Self { + host, + port, + database_name, + username, + password, + }) + } + + /// Returns the `MySQL` server host + #[must_use] + pub fn host(&self) -> &str { + &self.host + } + + /// Returns the `MySQL` server port + #[must_use] + pub fn port(&self) -> u16 { + self.port + } + + /// Returns the database name + #[must_use] + pub fn database_name(&self) -> &str { + &self.database_name + } + + /// Returns the database username + #[must_use] + pub fn username(&self) -> &str { + &self.username + } + + /// Returns a reference to the database password + #[must_use] + pub fn password(&self) -> &Password { + &self.password + } +} + +/// Intermediate struct for deserialization +#[derive(Deserialize)] +struct MysqlConfigRaw { + host: String, + port: u16, + database_name: String, + username: String, + password: Password, +} + +impl<'de> Deserialize<'de> for MysqlConfig { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let raw = MysqlConfigRaw::deserialize(deserializer)?; + Self::new( + raw.host, + raw.port, + raw.database_name, + raw.username, + raw.password, + ) + .map_err(serde::de::Error::custom) + } } #[cfg(test)] mod tests { - use super::*; #[test] - fn it_should_create_mysql_config() { - let config = MysqlConfig { - host: "localhost".to_string(), - port: 3306, - database_name: "tracker".to_string(), - username: "tracker_user".to_string(), - password: Password::from("secure_password"), - }; - - assert_eq!(config.host, "localhost"); - assert_eq!(config.port, 3306); - assert_eq!(config.database_name, "tracker"); - assert_eq!(config.username, "tracker_user"); - assert_eq!(config.password.expose_secret(), "secure_password"); + fn it_should_create_mysql_config_when_all_fields_are_valid() { + let config = MysqlConfig::new( + "localhost", + 3306, + "tracker", + "tracker_user", + Password::from("secure_password"), + ) + .unwrap(); + + assert_eq!(config.host(), "localhost"); + assert_eq!(config.port(), 3306); + assert_eq!(config.database_name(), "tracker"); + assert_eq!(config.username(), "tracker_user"); + assert_eq!(config.password().expose_secret(), "secure_password"); + } + + #[test] + fn it_should_reject_empty_host_when_creating_mysql_config() { + let result = MysqlConfig::new("", 3306, "tracker", "user", Password::from("pass")); + assert!(matches!(result, Err(MysqlConfigError::EmptyHost))); + } + + #[test] + fn it_should_reject_port_zero_when_creating_mysql_config() { + let result = MysqlConfig::new("localhost", 0, "tracker", "user", Password::from("pass")); + assert!(matches!(result, Err(MysqlConfigError::InvalidPort))); + } + + #[test] + fn it_should_reject_empty_database_name_when_creating_mysql_config() { + let result = MysqlConfig::new("localhost", 3306, "", "user", Password::from("pass")); + assert!(matches!(result, Err(MysqlConfigError::EmptyDatabaseName))); + } + + #[test] + fn it_should_reject_empty_username_when_creating_mysql_config() { + let result = MysqlConfig::new("localhost", 3306, "tracker", "", Password::from("pass")); + assert!(matches!(result, Err(MysqlConfigError::EmptyUsername))); } #[test] fn it_should_serialize_mysql_config() { - let config = MysqlConfig { - host: "mysql".to_string(), - port: 3306, - database_name: "tracker".to_string(), - username: "tracker_user".to_string(), - password: Password::from("pass123"), - }; + let config = MysqlConfig::new( + "mysql", + 3306, + "tracker", + "tracker_user", + Password::from("pass123"), + ) + .unwrap(); let json = serde_json::to_value(&config).unwrap(); assert_eq!(json["host"], "mysql"); @@ -60,7 +294,7 @@ mod tests { } #[test] - fn it_should_deserialize_mysql_config() { + fn it_should_deserialize_mysql_config_when_valid() { let json = r#"{ "host": "localhost", "port": 3306, @@ -70,10 +304,23 @@ mod tests { }"#; let config: MysqlConfig = serde_json::from_str(json).unwrap(); - assert_eq!(config.host, "localhost"); - assert_eq!(config.port, 3306); - assert_eq!(config.database_name, "tracker"); - assert_eq!(config.username, "tracker_user"); - assert_eq!(config.password.expose_secret(), "secure_password"); + assert_eq!(config.host(), "localhost"); + assert_eq!(config.port(), 3306); + assert_eq!(config.database_name(), "tracker"); + assert_eq!(config.username(), "tracker_user"); + assert_eq!(config.password().expose_secret(), "secure_password"); + } + + #[test] + fn it_should_reject_deserialization_when_port_is_zero() { + let json = r#"{ + "host": "localhost", + "port": 0, + "database_name": "tracker", + "username": "user", + "password": "pass" + }"#; + let result: Result = serde_json::from_str(json); + assert!(result.is_err()); } } diff --git a/src/domain/tracker/config/core/database/sqlite.rs b/src/domain/tracker/config/core/database/sqlite.rs index 05f24555..6b8476d1 100644 --- a/src/domain/tracker/config/core/database/sqlite.rs +++ b/src/domain/tracker/config/core/database/sqlite.rs @@ -1,13 +1,97 @@ //! `SQLite` database configuration -use serde::{Deserialize, Serialize}; +use serde::{Deserialize, Deserializer, Serialize}; + +/// Error type for `SQLite` configuration validation +#[derive(Debug, Clone, PartialEq, thiserror::Error)] +pub enum SqliteConfigError { + /// Database name cannot be empty + #[error("SQLite database name cannot be empty")] + EmptyDatabaseName, +} + +impl SqliteConfigError { + /// Returns detailed help text for resolving this error + #[must_use] + pub fn help(&self) -> &'static str { + match self { + Self::EmptyDatabaseName => { + "SQLite database name cannot be empty.\n\ + \n\ + The database_name field specifies the SQLite file name.\n\ + This file will be created in the tracker's data directory.\n\ + \n\ + Examples of valid database names:\n\ + - tracker.db\n\ + - sqlite3.db\n\ + - data.sqlite\n\ + \n\ + Fix:\n\ + Set the database_name field in your database configuration:\n\ + \n\ + \"database\": {\n\ + \"driver\": \"sqlite3\",\n\ + \"database_name\": \"tracker.db\"\n\ + }" + } + } + } +} /// `SQLite` database configuration -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[derive(Debug, Clone, Serialize, PartialEq)] pub struct SqliteConfig { /// Database file name (e.g., "tracker.db", "sqlite3.db") /// Path is relative to the tracker's data directory - pub database_name: String, + database_name: String, +} + +impl SqliteConfig { + /// Creates a new `SQLite` configuration with validation + /// + /// # Errors + /// + /// - `EmptyDatabaseName` if database name is empty + /// + /// # Examples + /// + /// ```rust + /// use torrust_tracker_deployer_lib::domain::tracker::SqliteConfig; + /// + /// let config = SqliteConfig::new("tracker.db").unwrap(); + /// assert_eq!(config.database_name(), "tracker.db"); + /// ``` + pub fn new(database_name: impl Into) -> Result { + let name = database_name.into(); + if name.is_empty() { + return Err(SqliteConfigError::EmptyDatabaseName); + } + Ok(Self { + database_name: name, + }) + } + + /// Returns the database file name + #[must_use] + pub fn database_name(&self) -> &str { + &self.database_name + } +} + +/// Intermediate struct for deserialization +#[derive(Deserialize)] +struct SqliteConfigRaw { + database_name: String, +} + +impl<'de> Deserialize<'de> for SqliteConfig { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let raw = SqliteConfigRaw::deserialize(deserializer)?; + Self::new(raw.database_name).map_err(serde::de::Error::custom) + } } #[cfg(test)] @@ -15,29 +99,35 @@ mod tests { use super::*; #[test] - fn it_should_create_sqlite_config() { - let config = SqliteConfig { - database_name: "test.db".to_string(), - }; + fn it_should_create_sqlite_config_when_database_name_is_valid() { + let config = SqliteConfig::new("test.db").unwrap(); + assert_eq!(config.database_name(), "test.db"); + } - assert_eq!(config.database_name, "test.db"); + #[test] + fn it_should_reject_empty_database_name_when_creating_sqlite_config() { + let result = SqliteConfig::new(""); + assert!(matches!(result, Err(SqliteConfigError::EmptyDatabaseName))); } #[test] fn it_should_serialize_sqlite_config() { - let config = SqliteConfig { - database_name: "tracker.db".to_string(), - }; - + let config = SqliteConfig::new("tracker.db").unwrap(); let json = serde_json::to_value(&config).unwrap(); assert_eq!(json["database_name"], "tracker.db"); } #[test] - fn it_should_deserialize_sqlite_config() { + fn it_should_deserialize_sqlite_config_when_valid() { let json = r#"{"database_name": "tracker.db"}"#; let config: SqliteConfig = serde_json::from_str(json).unwrap(); + assert_eq!(config.database_name(), "tracker.db"); + } - assert_eq!(config.database_name, "tracker.db"); + #[test] + fn it_should_reject_deserialization_when_database_name_is_empty() { + let json = r#"{"database_name": ""}"#; + let result: Result = serde_json::from_str(json); + assert!(result.is_err()); } } diff --git a/src/domain/tracker/config/core/mod.rs b/src/domain/tracker/config/core/mod.rs index 00ca89ab..fc8260ea 100644 --- a/src/domain/tracker/config/core/mod.rs +++ b/src/domain/tracker/config/core/mod.rs @@ -1,19 +1,74 @@ //! Core tracker configuration -use serde::{Deserialize, Serialize}; +use serde::{Deserialize, Deserializer, Serialize}; mod database; -pub use database::{DatabaseConfig, MysqlConfig, SqliteConfig}; +pub use database::{ + DatabaseConfig, MysqlConfig, MysqlConfigError, SqliteConfig, SqliteConfigError, +}; /// Core tracker configuration options -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[derive(Debug, Clone, Serialize, PartialEq)] pub struct TrackerCoreConfig { /// Database configuration (`SQLite`, `MySQL`, etc.) - pub database: DatabaseConfig, + database: DatabaseConfig, /// Tracker mode: true for private tracker, false for public - pub private: bool, + private: bool, +} + +impl TrackerCoreConfig { + /// Creates a new core tracker configuration + /// + /// # Examples + /// + /// ```rust + /// use torrust_tracker_deployer_lib::domain::tracker::{ + /// TrackerCoreConfig, DatabaseConfig, SqliteConfig, + /// }; + /// + /// let core = TrackerCoreConfig::new( + /// DatabaseConfig::Sqlite(SqliteConfig::new("tracker.db").unwrap()), + /// true, + /// ); + /// + /// assert_eq!(core.database().database_name(), "tracker.db"); + /// assert!(core.private()); + /// ``` + #[must_use] + pub fn new(database: DatabaseConfig, private: bool) -> Self { + Self { database, private } + } + + /// Returns a reference to the database configuration + #[must_use] + pub fn database(&self) -> &DatabaseConfig { + &self.database + } + + /// Returns whether this is a private tracker + #[must_use] + pub fn private(&self) -> bool { + self.private + } +} + +/// Intermediate struct for deserialization +#[derive(Deserialize)] +struct TrackerCoreConfigRaw { + database: DatabaseConfig, + private: bool, +} + +impl<'de> Deserialize<'de> for TrackerCoreConfig { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let raw = TrackerCoreConfigRaw::deserialize(deserializer)?; + Ok(Self::new(raw.database, raw.private)) + } } #[cfg(test)] @@ -22,25 +77,21 @@ mod tests { #[test] fn it_should_create_core_config() { - let core = TrackerCoreConfig { - database: DatabaseConfig::Sqlite(SqliteConfig { - database_name: "tracker.db".to_string(), - }), - private: true, - }; - - assert_eq!(core.database.database_name(), "tracker.db"); - assert!(core.private); + let core = TrackerCoreConfig::new( + DatabaseConfig::Sqlite(SqliteConfig::new("tracker.db").unwrap()), + true, + ); + + assert_eq!(core.database().database_name(), "tracker.db"); + assert!(core.private()); } #[test] fn it_should_serialize_core_config() { - let core = TrackerCoreConfig { - database: DatabaseConfig::Sqlite(SqliteConfig { - database_name: "test.db".to_string(), - }), - private: false, - }; + let core = TrackerCoreConfig::new( + DatabaseConfig::Sqlite(SqliteConfig::new("test.db").unwrap()), + false, + ); let json = serde_json::to_value(&core).unwrap(); assert_eq!(json["private"], false); diff --git a/src/domain/tracker/config/health_check_api.rs b/src/domain/tracker/config/health_check_api.rs index 1c951b5f..be1ec6b2 100644 --- a/src/domain/tracker/config/health_check_api.rs +++ b/src/domain/tracker/config/health_check_api.rs @@ -1,32 +1,203 @@ //! Health Check API configuration +//! +//! This module implements the **DDD validated constructor pattern** for Health Check +//! API configuration. The pattern ensures that Health Check API configs are always +//! valid after construction. +//! +//! ## Pattern Overview +//! +//! 1. **Private fields**: All fields are private to prevent bypassing validation +//! 2. **Validated constructor**: `new()` validates all invariants before creation +//! 3. **Getter methods**: Provide read-only access to field values +//! 4. **Domain error type**: Rich error enum for validation failures +//! 5. **Serde with validation**: Deserialization goes through the constructor +//! +//! ## Example +//! +//! ```rust +//! use torrust_tracker_deployer_lib::domain::tracker::HealthCheckApiConfig; +//! use torrust_tracker_deployer_lib::shared::DomainName; +//! +//! // Valid configuration without TLS - succeeds +//! let config = HealthCheckApiConfig::new( +//! "127.0.0.1:1313".parse().unwrap(), +//! None, +//! false, +//! ).expect("valid config"); +//! +//! // Invalid: port 0 - fails at construction +//! let result = HealthCheckApiConfig::new( +//! "0.0.0.0:0".parse().unwrap(), +//! None, +//! false, +//! ); +//! assert!(result.is_err()); +//! ``` +//! +//! ## Reference Implementation +//! +//! See `http_api.rs` for the original reference implementation of this pattern. +use std::fmt; use std::net::SocketAddr; use serde::{Deserialize, Serialize}; +use thiserror::Error; -use crate::shared::domain_name::DomainName; +use super::is_localhost; +use crate::shared::DomainName; -/// Health Check API configuration +/// Errors that can occur when creating a `HealthCheckApiConfig` +/// +/// These errors represent domain invariant violations. Each variant provides +/// context about what went wrong and enables the application layer to convert +/// to user-friendly error messages. +#[derive(Debug, Clone, PartialEq, Error)] +pub enum HealthCheckApiConfigError { + /// Dynamic port assignment (port 0) is not supported + /// + /// Port 0 tells the OS to assign a random available port, which is not + /// suitable for deployment configuration where ports must be known. + #[error("dynamic port (0) is not supported for Health Check API bind address '{0}'")] + DynamicPortNotSupported(SocketAddr), + + /// TLS proxy is enabled but no domain is configured + /// + /// When `use_tls_proxy` is true, a domain is required because Caddy needs + /// the domain name to obtain Let's Encrypt certificates. + #[error( + "TLS proxy requires a domain to be configured for Health Check API bind address '{0}'" + )] + TlsProxyRequiresDomain(SocketAddr), + + /// Localhost address cannot be used with TLS proxy + /// + /// Caddy runs in a separate container and cannot reach localhost addresses + /// in the tracker container. Use 0.0.0.0 or a specific IP instead. + #[error("localhost '{0}' cannot be used with TLS proxy for Health Check API (Caddy runs in separate container)")] + LocalhostWithTls(SocketAddr), +} + +impl HealthCheckApiConfigError { + /// Provides detailed troubleshooting guidance for this error + /// + /// This method follows the project's tiered help system pattern, + /// providing actionable guidance for resolving configuration issues. + #[must_use] + pub fn help(&self) -> &'static str { + match self { + Self::DynamicPortNotSupported(_) => { + "Dynamic port assignment (port 0) is not supported.\n\ + \n\ + Why: Port 0 tells the operating system to assign a random available port.\n\ + This is not suitable for deployment where ports must be known in advance\n\ + for firewall rules, load balancers, and client configuration.\n\ + \n\ + Fix: Specify an explicit port number (e.g., 1313, 1314, 8080).\n\ + \n\ + Example: \"bind_address\": \"127.0.0.1:1313\"" + } + Self::TlsProxyRequiresDomain(_) => { + "TLS proxy requires a domain name.\n\ + \n\ + Why: When use_tls_proxy is enabled, Caddy obtains TLS certificates from\n\ + Let's Encrypt using the ACME protocol. This requires a valid domain name.\n\ + \n\ + Fix (choose one):\n\ + 1. Add a domain: \"domain\": \"health.example.com\"\n\ + 2. Disable TLS: \"use_tls_proxy\": false\n\ + \n\ + Note: The domain must point to your server's IP address for certificate\n\ + acquisition to succeed." + } + Self::LocalhostWithTls(_) => { + "Localhost addresses cannot be used with TLS proxy.\n\ + \n\ + Why: Caddy runs in a separate Docker container and cannot reach localhost\n\ + addresses (127.0.0.1 or ::1) in the tracker container. Each container has\n\ + its own network namespace.\n\ + \n\ + Fix (choose one):\n\ + 1. Use a routable address: \"bind_address\": \"0.0.0.0:1313\"\n\ + 2. Disable TLS: \"use_tls_proxy\": false\n\ + \n\ + Note: Health check endpoints are often kept internal (localhost) for\n\ + security. Consider whether you really need external HTTPS access." + } + } + } +} + +/// Internal struct for serde deserialization that bypasses validation +/// +/// This allows us to deserialize JSON into the raw fields, then validate +/// through the constructor. This pattern ensures that even +/// deserialized configs are validated. +#[derive(Deserialize)] +struct HealthCheckApiConfigRaw { + #[serde(deserialize_with = "crate::domain::tracker::config::deserialize_socket_addr")] + bind_address: SocketAddr, + #[serde(default)] + domain: Option, + #[serde(default)] + use_tls_proxy: bool, +} + +/// Health Check API configuration with domain invariants enforced at construction /// /// The Health Check API is a minimal HTTP endpoint used by Docker and container /// orchestration tools to verify service health. It's separate from the main HTTP API. -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +/// +/// This type guarantees that any instance is valid according to domain rules: +/// - Bind address has a non-zero port +/// - If TLS proxy is enabled, a domain is configured +/// - If TLS proxy is enabled, bind address is not localhost +/// +/// # Construction +/// +/// Use `HealthCheckApiConfig::new()` to create instances with validation: +/// +/// ```rust +/// use torrust_tracker_deployer_lib::domain::tracker::HealthCheckApiConfig; +/// use torrust_tracker_deployer_lib::shared::DomainName; +/// +/// // Without TLS (typical for internal health checks) +/// let config = HealthCheckApiConfig::new( +/// "127.0.0.1:1313".parse().unwrap(), +/// None, +/// false, +/// )?; +/// +/// // With TLS for external monitoring (requires domain) +/// let tls_config = HealthCheckApiConfig::new( +/// "0.0.0.0:1313".parse().unwrap(), +/// Some(DomainName::new("health.example.com")?), +/// true, +/// )?; +/// # Ok::<(), Box>(()) +/// ``` +/// +/// # Invariants +/// +/// The following invariants are enforced at construction time: +/// +/// 1. **No dynamic ports**: `bind_address.port() != 0` +/// 2. **TLS requires domain**: `use_tls_proxy == true` implies `domain.is_some()` +/// 3. **No localhost with TLS**: `use_tls_proxy == true` implies `!is_localhost(bind_address)` +#[derive(Debug, Clone, Serialize, PartialEq)] pub struct HealthCheckApiConfig { /// Bind address (e.g., "127.0.0.1:1313") /// /// Conventionally uses port 1313, though this is configurable - #[serde( - serialize_with = "crate::domain::tracker::config::serialize_socket_addr", - deserialize_with = "crate::domain::tracker::config::deserialize_socket_addr" - )] - pub bind_address: SocketAddr, + #[serde(serialize_with = "crate::domain::tracker::config::serialize_socket_addr")] + bind_address: SocketAddr, /// Domain name for external HTTPS access (optional) /// /// When present, defines the domain at which this service will be accessible. /// Caddy uses this for automatic certificate management. #[serde(skip_serializing_if = "Option::is_none")] - pub domain: Option, + domain: Option, /// Whether to use TLS proxy via Caddy (default: false) /// @@ -35,17 +206,119 @@ pub struct HealthCheckApiConfig { /// - Requires a domain to be configured /// - Service receives plain HTTP from Caddy internally #[serde(default)] - pub use_tls_proxy: bool, + use_tls_proxy: bool, } impl HealthCheckApiConfig { + /// Creates a new Health Check API configuration with validation + /// + /// This is the primary way to construct a `HealthCheckApiConfig`. All domain + /// invariants are validated before the instance is created. + /// + /// # Arguments + /// + /// * `bind_address` - Socket address to bind to (e.g., "127.0.0.1:1313") + /// * `domain` - Optional domain for TLS certificate (required if `use_tls_proxy` is true) + /// * `use_tls_proxy` - Whether to enable TLS via Caddy reverse proxy + /// + /// # Errors + /// + /// Returns `HealthCheckApiConfigError` if any invariant is violated: + /// + /// - `DynamicPortNotSupported` - if port is 0 + /// - `TlsProxyRequiresDomain` - if `use_tls_proxy` is true but `domain` is None + /// - `LocalhostWithTls` - if `use_tls_proxy` is true and `bind_address` is localhost + /// + /// # Examples + /// + /// ```rust + /// use torrust_tracker_deployer_lib::domain::tracker::HealthCheckApiConfig; + /// use torrust_tracker_deployer_lib::shared::DomainName; + /// + /// // Basic configuration without TLS (typical) + /// let config = HealthCheckApiConfig::new( + /// "127.0.0.1:1313".parse().unwrap(), + /// None, + /// false, + /// )?; + /// + /// // Configuration with TLS for external monitoring + /// let tls_config = HealthCheckApiConfig::new( + /// "0.0.0.0:1313".parse().unwrap(), + /// Some(DomainName::new("health.example.com")?), + /// true, + /// )?; + /// # Ok::<(), Box>(()) + /// ``` + pub fn new( + bind_address: SocketAddr, + domain: Option, + use_tls_proxy: bool, + ) -> Result { + // Invariant 1: Port 0 (dynamic assignment) is not supported + if bind_address.port() == 0 { + return Err(HealthCheckApiConfigError::DynamicPortNotSupported( + bind_address, + )); + } + + // Invariant 2: TLS proxy requires a domain + if use_tls_proxy && domain.is_none() { + return Err(HealthCheckApiConfigError::TlsProxyRequiresDomain( + bind_address, + )); + } + + // Invariant 3: Localhost cannot use TLS (Caddy in separate container) + if use_tls_proxy && is_localhost(&bind_address) { + return Err(HealthCheckApiConfigError::LocalhostWithTls(bind_address)); + } + + Ok(Self { + bind_address, + domain, + use_tls_proxy, + }) + } + + // ------------------------------------------------------------------------- + // Getter methods - provide read-only access to fields + // ------------------------------------------------------------------------- + + /// Returns the bind address + #[must_use] + pub fn bind_address(&self) -> SocketAddr { + self.bind_address + } + + /// Returns a reference to the domain, if configured + #[must_use] + pub fn domain(&self) -> Option<&DomainName> { + self.domain.as_ref() + } + /// Returns whether TLS proxy is enabled #[must_use] + pub fn use_tls_proxy(&self) -> bool { + self.use_tls_proxy + } + + // ------------------------------------------------------------------------- + // Convenience methods + // ------------------------------------------------------------------------- + + /// Returns true if this API uses the TLS proxy + /// + /// Alias for `use_tls_proxy()` for semantic clarity. + #[must_use] pub fn uses_tls_proxy(&self) -> bool { self.use_tls_proxy } - /// Returns the TLS domain if TLS proxy is configured + /// Returns the TLS domain as a string if TLS proxy is configured + /// + /// Returns `None` if TLS is disabled, even if a domain is configured. + /// This is useful for determining the effective TLS domain. #[must_use] pub fn tls_domain(&self) -> Option<&str> { if self.use_tls_proxy { @@ -56,46 +329,184 @@ impl HealthCheckApiConfig { } } +/// Enables deserialization with validation through the constructor +/// +/// This ensures that JSON deserialization also validates the config, +/// maintaining the "always valid" invariant even for loaded data. +impl<'de> Deserialize<'de> for HealthCheckApiConfig { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let raw = HealthCheckApiConfigRaw::deserialize(deserializer)?; + Self::new(raw.bind_address, raw.domain, raw.use_tls_proxy).map_err(serde::de::Error::custom) + } +} + +impl fmt::Display for HealthCheckApiConfig { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "Health Check API at {}", self.bind_address)?; + if let Some(domain) = &self.domain { + write!(f, " ({})", domain.as_str())?; + } + if self.use_tls_proxy { + write!(f, " [TLS]")?; + } + Ok(()) + } +} + #[cfg(test)] mod tests { use super::*; + // ========================================================================= + // Valid construction tests + // ========================================================================= + #[test] fn it_should_create_health_check_api_config() { - let config = HealthCheckApiConfig { - bind_address: "127.0.0.1:1313".parse().unwrap(), - domain: None, - use_tls_proxy: false, - }; + let config = HealthCheckApiConfig::new("127.0.0.1:1313".parse().unwrap(), None, false) + .expect("valid config should succeed"); assert_eq!( - config.bind_address, + config.bind_address(), "127.0.0.1:1313".parse::().unwrap() ); - assert!(!config.use_tls_proxy); - assert!(config.domain.is_none()); + assert!(!config.use_tls_proxy()); + assert!(config.domain().is_none()); } #[test] fn it_should_create_health_check_api_config_with_tls_proxy() { let domain = DomainName::new("health.tracker.local").unwrap(); - let config = HealthCheckApiConfig { - bind_address: "0.0.0.0:1313".parse().unwrap(), - domain: Some(domain), - use_tls_proxy: true, - }; + let config = HealthCheckApiConfig::new("0.0.0.0:1313".parse().unwrap(), Some(domain), true) + .expect("valid config should succeed"); assert!(config.uses_tls_proxy()); assert_eq!(config.tls_domain(), Some("health.tracker.local")); } + #[test] + fn it_should_create_health_check_api_config_with_domain_but_no_tls() { + let domain = DomainName::new("health.tracker.local").unwrap(); + let config = + HealthCheckApiConfig::new("0.0.0.0:1313".parse().unwrap(), Some(domain), false) + .expect("valid config should succeed"); + + assert!(!config.uses_tls_proxy()); + // tls_domain returns None when TLS is disabled + assert!(config.tls_domain().is_none()); + // but domain() still returns the domain + assert!(config.domain().is_some()); + } + + // ========================================================================= + // Invariant violation tests + // ========================================================================= + + #[test] + fn it_should_reject_port_zero() { + let result = HealthCheckApiConfig::new("0.0.0.0:0".parse().unwrap(), None, false); + + assert!(result.is_err()); + let err = result.unwrap_err(); + assert!(matches!( + err, + HealthCheckApiConfigError::DynamicPortNotSupported(_) + )); + assert!(err.to_string().contains("dynamic port")); + } + + #[test] + fn it_should_reject_tls_without_domain() { + let result = HealthCheckApiConfig::new("0.0.0.0:1313".parse().unwrap(), None, true); + + assert!(result.is_err()); + let err = result.unwrap_err(); + assert!(matches!( + err, + HealthCheckApiConfigError::TlsProxyRequiresDomain(_) + )); + assert!(err.to_string().contains("TLS proxy requires a domain")); + } + + #[test] + fn it_should_reject_localhost_with_tls_ipv4() { + let domain = DomainName::new("health.tracker.local").unwrap(); + let result = + HealthCheckApiConfig::new("127.0.0.1:1313".parse().unwrap(), Some(domain), true); + + assert!(result.is_err()); + let err = result.unwrap_err(); + assert!(matches!( + err, + HealthCheckApiConfigError::LocalhostWithTls(_) + )); + assert!(err.to_string().contains("localhost")); + } + + #[test] + fn it_should_reject_localhost_with_tls_ipv6() { + let domain = DomainName::new("health.tracker.local").unwrap(); + let result = HealthCheckApiConfig::new("[::1]:1313".parse().unwrap(), Some(domain), true); + + assert!(result.is_err()); + let err = result.unwrap_err(); + assert!(matches!( + err, + HealthCheckApiConfigError::LocalhostWithTls(_) + )); + } + + #[test] + fn it_should_allow_localhost_without_tls() { + let result = HealthCheckApiConfig::new("127.0.0.1:1313".parse().unwrap(), None, false); + + assert!(result.is_ok()); + } + + // ========================================================================= + // Help text tests + // ========================================================================= + + #[test] + fn it_should_provide_help_text_for_port_zero_error() { + let err = HealthCheckApiConfigError::DynamicPortNotSupported("0.0.0.0:0".parse().unwrap()); + + let help = err.help(); + assert!(help.contains("Dynamic port assignment")); + assert!(help.contains("Fix:")); + assert!(help.contains("1313")); + } + + #[test] + fn it_should_provide_help_text_for_tls_without_domain() { + let err = + HealthCheckApiConfigError::TlsProxyRequiresDomain("0.0.0.0:1313".parse().unwrap()); + + let help = err.help(); + assert!(help.contains("TLS proxy requires a domain")); + assert!(help.contains("Fix")); + } + + #[test] + fn it_should_provide_help_text_for_localhost_with_tls() { + let err = HealthCheckApiConfigError::LocalhostWithTls("127.0.0.1:1313".parse().unwrap()); + + let help = err.help(); + assert!(help.contains("Localhost addresses cannot be used")); + assert!(help.contains("Docker container")); + } + + // ========================================================================= + // Serialization tests + // ========================================================================= + #[test] fn it_should_serialize_health_check_api_config() { - let config = HealthCheckApiConfig { - bind_address: "127.0.0.1:1313".parse().unwrap(), - domain: None, - use_tls_proxy: false, - }; + let config = + HealthCheckApiConfig::new("127.0.0.1:1313".parse().unwrap(), None, false).unwrap(); let json = serde_json::to_value(&config).unwrap(); assert_eq!(json["bind_address"], "127.0.0.1:1313"); @@ -108,11 +519,8 @@ mod tests { #[test] fn it_should_serialize_health_check_api_config_with_tls_proxy() { let domain = DomainName::new("health.tracker.local").unwrap(); - let config = HealthCheckApiConfig { - bind_address: "0.0.0.0:1313".parse().unwrap(), - domain: Some(domain), - use_tls_proxy: true, - }; + let config = + HealthCheckApiConfig::new("0.0.0.0:1313".parse().unwrap(), Some(domain), true).unwrap(); let json = serde_json::to_value(&config).unwrap(); assert_eq!(json["bind_address"], "0.0.0.0:1313"); @@ -120,16 +528,29 @@ mod tests { assert_eq!(json["use_tls_proxy"], true); } + // ========================================================================= + // Deserialization tests + // ========================================================================= + #[test] fn it_should_deserialize_health_check_api_config() { let json = r#"{"bind_address": "127.0.0.1:1313", "use_tls_proxy": false}"#; let config: HealthCheckApiConfig = serde_json::from_str(json).unwrap(); assert_eq!( - config.bind_address, + config.bind_address(), "127.0.0.1:1313".parse::().unwrap() ); - assert!(!config.use_tls_proxy); + assert!(!config.use_tls_proxy()); + } + + #[test] + fn it_should_deserialize_health_check_api_config_with_default_tls() { + // use_tls_proxy defaults to false + let json = r#"{"bind_address": "127.0.0.1:1313"}"#; + let config: HealthCheckApiConfig = serde_json::from_str(json).unwrap(); + + assert!(!config.use_tls_proxy()); } #[test] @@ -138,22 +559,80 @@ mod tests { let config: HealthCheckApiConfig = serde_json::from_str(json).unwrap(); assert_eq!( - config.bind_address, + config.bind_address(), "0.0.0.0:1313".parse::().unwrap() ); assert_eq!(config.tls_domain(), Some("health.tracker.local")); } + #[test] + fn it_should_reject_port_zero_during_deserialization() { + let json = r#"{"bind_address": "0.0.0.0:0", "use_tls_proxy": false}"#; + let result: Result = serde_json::from_str(json); + + assert!(result.is_err()); + let err_msg = result.unwrap_err().to_string(); + assert!(err_msg.contains("dynamic port")); + } + + #[test] + fn it_should_reject_tls_without_domain_during_deserialization() { + let json = r#"{"bind_address": "0.0.0.0:1313", "use_tls_proxy": true}"#; + let result: Result = serde_json::from_str(json); + + assert!(result.is_err()); + let err_msg = result.unwrap_err().to_string(); + assert!(err_msg.contains("TLS proxy requires a domain")); + } + #[test] fn it_should_return_none_for_tls_domain_when_tls_proxy_disabled() { let domain = DomainName::new("health.tracker.local").unwrap(); - let config = HealthCheckApiConfig { - bind_address: "0.0.0.0:1313".parse().unwrap(), - domain: Some(domain), - use_tls_proxy: false, - }; + let config = + HealthCheckApiConfig::new("0.0.0.0:1313".parse().unwrap(), Some(domain), false) + .unwrap(); assert!(!config.uses_tls_proxy()); assert!(config.tls_domain().is_none()); } + + // ========================================================================= + // Display tests + // ========================================================================= + + #[test] + fn it_should_display_without_tls() { + let config = + HealthCheckApiConfig::new("127.0.0.1:1313".parse().unwrap(), None, false).unwrap(); + + assert_eq!(config.to_string(), "Health Check API at 127.0.0.1:1313"); + } + + #[test] + fn it_should_display_with_tls() { + let domain = DomainName::new("health.tracker.local").unwrap(); + let config = + HealthCheckApiConfig::new("0.0.0.0:1313".parse().unwrap(), Some(domain), true).unwrap(); + + assert_eq!( + config.to_string(), + "Health Check API at 0.0.0.0:1313 (health.tracker.local) [TLS]" + ); + } + + // ========================================================================= + // Round-trip tests + // ========================================================================= + + #[test] + fn it_should_round_trip_through_json() { + let domain = DomainName::new("health.tracker.local").unwrap(); + let original = + HealthCheckApiConfig::new("0.0.0.0:1313".parse().unwrap(), Some(domain), true).unwrap(); + + let json = serde_json::to_string(&original).unwrap(); + let restored: HealthCheckApiConfig = serde_json::from_str(&json).unwrap(); + + assert_eq!(original, restored); + } } diff --git a/src/domain/tracker/config/http.rs b/src/domain/tracker/config/http.rs index 82087265..cde9363a 100644 --- a/src/domain/tracker/config/http.rs +++ b/src/domain/tracker/config/http.rs @@ -1,27 +1,195 @@ //! HTTP tracker configuration +//! +//! This module implements the **DDD validated constructor pattern** for HTTP tracker +//! configuration. The pattern ensures that HTTP tracker configs are always valid +//! after construction. +//! +//! ## Pattern Overview +//! +//! 1. **Private fields**: All fields are private to prevent bypassing validation +//! 2. **Validated constructor**: `new()` validates all invariants before creation +//! 3. **Getter methods**: Provide read-only access to field values +//! 4. **Domain error type**: Rich error enum for validation failures +//! 5. **Serde with validation**: Deserialization goes through the constructor +//! +//! ## Example +//! +//! ```rust +//! use torrust_tracker_deployer_lib::domain::tracker::HttpTrackerConfig; +//! use torrust_tracker_deployer_lib::shared::DomainName; +//! +//! // Valid configuration without TLS - succeeds +//! let config = HttpTrackerConfig::new( +//! "0.0.0.0:7070".parse().unwrap(), +//! None, +//! false, +//! ).expect("valid config"); +//! +//! // Invalid: port 0 - fails at construction +//! let result = HttpTrackerConfig::new( +//! "0.0.0.0:0".parse().unwrap(), +//! None, +//! false, +//! ); +//! assert!(result.is_err()); +//! ``` +//! +//! ## Reference Implementation +//! +//! See `http_api.rs` for the original reference implementation of this pattern. +use std::fmt; use std::net::SocketAddr; use serde::{Deserialize, Serialize}; +use thiserror::Error; +use super::is_localhost; use crate::shared::DomainName; -/// HTTP tracker bind configuration -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +/// Errors that can occur when creating an `HttpTrackerConfig` +/// +/// These errors represent domain invariant violations. Each variant provides +/// context about what went wrong and enables the application layer to convert +/// to user-friendly error messages. +#[derive(Debug, Clone, PartialEq, Error)] +pub enum HttpTrackerConfigError { + /// Dynamic port assignment (port 0) is not supported + /// + /// Port 0 tells the OS to assign a random available port, which is not + /// suitable for deployment configuration where ports must be known. + #[error("dynamic port (0) is not supported for HTTP tracker bind address '{0}'")] + DynamicPortNotSupported(SocketAddr), + + /// TLS proxy is enabled but no domain is configured + /// + /// When `use_tls_proxy` is true, a domain is required because Caddy needs + /// the domain name to obtain Let's Encrypt certificates. + #[error("TLS proxy requires a domain to be configured for HTTP tracker bind address '{0}'")] + TlsProxyRequiresDomain(SocketAddr), + + /// Localhost address cannot be used with TLS proxy + /// + /// Caddy runs in a separate container and cannot reach localhost addresses + /// in the tracker container. Use 0.0.0.0 or a specific IP instead. + #[error("localhost '{0}' cannot be used with TLS proxy for HTTP tracker (Caddy runs in separate container)")] + LocalhostWithTls(SocketAddr), +} + +impl HttpTrackerConfigError { + /// Provides detailed troubleshooting guidance for this error + /// + /// This method follows the project's tiered help system pattern, + /// providing actionable guidance for resolving configuration issues. + #[must_use] + pub fn help(&self) -> &'static str { + match self { + Self::DynamicPortNotSupported(_) => { + "Dynamic port assignment (port 0) is not supported.\n\ + \n\ + Why: Port 0 tells the operating system to assign a random available port.\n\ + This is not suitable for deployment where ports must be known in advance\n\ + for firewall rules, load balancers, and client configuration.\n\ + \n\ + Fix: Specify an explicit port number (e.g., 7070, 8080, 80).\n\ + \n\ + Example: \"bind_address\": \"0.0.0.0:7070\"" + } + Self::TlsProxyRequiresDomain(_) => { + "TLS proxy requires a domain name.\n\ + \n\ + Why: When use_tls_proxy is enabled, Caddy obtains TLS certificates from\n\ + Let's Encrypt using the ACME protocol. This requires a valid domain name.\n\ + \n\ + Fix (choose one):\n\ + 1. Add a domain: \"domain\": \"tracker.example.com\"\n\ + 2. Disable TLS: \"use_tls_proxy\": false\n\ + \n\ + Note: The domain must point to your server's IP address for certificate\n\ + acquisition to succeed." + } + Self::LocalhostWithTls(_) => { + "Localhost addresses cannot be used with TLS proxy.\n\ + \n\ + Why: Caddy runs in a separate Docker container and cannot reach localhost\n\ + addresses (127.0.0.1 or ::1) in the tracker container. Each container has\n\ + its own network namespace.\n\ + \n\ + Fix (choose one):\n\ + 1. Use a routable address: \"bind_address\": \"0.0.0.0:7070\"\n\ + 2. Disable TLS: \"use_tls_proxy\": false\n\ + \n\ + Note: If you need localhost-only access without TLS, you can use SSH\n\ + tunneling: ssh -L 7070:localhost:7070 user@server" + } + } + } +} + +/// Internal struct for serde deserialization that bypasses validation +/// +/// This allows us to deserialize JSON into the raw fields, then validate +/// through the constructor. This pattern ensures that even +/// deserialized configs are validated. +#[derive(Deserialize)] +struct HttpTrackerConfigRaw { + #[serde(deserialize_with = "crate::domain::tracker::config::deserialize_socket_addr")] + bind_address: SocketAddr, + #[serde(default)] + domain: Option, + use_tls_proxy: bool, +} + +/// HTTP tracker bind configuration with domain invariants enforced at construction +/// +/// This type guarantees that any instance is valid according to domain rules: +/// - Bind address has a non-zero port +/// - If TLS proxy is enabled, a domain is configured +/// - If TLS proxy is enabled, bind address is not localhost +/// +/// # Construction +/// +/// Use `HttpTrackerConfig::new()` to create instances with validation: +/// +/// ```rust +/// use torrust_tracker_deployer_lib::domain::tracker::HttpTrackerConfig; +/// use torrust_tracker_deployer_lib::shared::DomainName; +/// +/// // Without TLS +/// let config = HttpTrackerConfig::new( +/// "0.0.0.0:7070".parse().unwrap(), +/// None, +/// false, +/// )?; +/// +/// // With TLS (requires domain) +/// let tls_config = HttpTrackerConfig::new( +/// "0.0.0.0:7070".parse().unwrap(), +/// Some(DomainName::new("tracker.example.com")?), +/// true, +/// )?; +/// # Ok::<(), Box>(()) +/// ``` +/// +/// # Invariants +/// +/// The following invariants are enforced at construction time: +/// +/// 1. **No dynamic ports**: `bind_address.port() != 0` +/// 2. **TLS requires domain**: `use_tls_proxy == true` implies `domain.is_some()` +/// 3. **No localhost with TLS**: `use_tls_proxy == true` implies `!is_localhost(bind_address)` +#[derive(Debug, Clone, Serialize, PartialEq)] pub struct HttpTrackerConfig { /// Bind address (e.g., "0.0.0.0:7070") - #[serde( - serialize_with = "crate::domain::tracker::config::serialize_socket_addr", - deserialize_with = "crate::domain::tracker::config::deserialize_socket_addr" - )] - pub bind_address: SocketAddr, + #[serde(serialize_with = "crate::domain::tracker::config::serialize_socket_addr")] + bind_address: SocketAddr, /// Domain name for HTTPS certificate acquisition (optional) /// /// When present along with `use_tls_proxy: true`, this HTTP tracker will be /// accessible via HTTPS through the Caddy reverse proxy using this domain. #[serde(skip_serializing_if = "Option::is_none")] - pub domain: Option, + domain: Option, /// Whether to proxy this service through Caddy with TLS termination /// @@ -30,17 +198,117 @@ pub struct HttpTrackerConfig { /// - `domain` field is required /// - Cannot be used with localhost bind addresses (`127.0.0.1`, `::1`) /// - Implies the tracker's `on_reverse_proxy` should be `true` - pub use_tls_proxy: bool, + use_tls_proxy: bool, } impl HttpTrackerConfig { + /// Creates a new HTTP tracker configuration with validation + /// + /// This is the primary way to construct an `HttpTrackerConfig`. All domain + /// invariants are validated before the instance is created. + /// + /// # Arguments + /// + /// * `bind_address` - Socket address to bind to (e.g., "0.0.0.0:7070") + /// * `domain` - Optional domain for TLS certificate (required if `use_tls_proxy` is true) + /// * `use_tls_proxy` - Whether to enable TLS via Caddy reverse proxy + /// + /// # Errors + /// + /// Returns `HttpTrackerConfigError` if any invariant is violated: + /// + /// - `DynamicPortNotSupported` - if port is 0 + /// - `TlsProxyRequiresDomain` - if `use_tls_proxy` is true but `domain` is None + /// - `LocalhostWithTls` - if `use_tls_proxy` is true and `bind_address` is localhost + /// + /// # Examples + /// + /// ```rust + /// use torrust_tracker_deployer_lib::domain::tracker::HttpTrackerConfig; + /// use torrust_tracker_deployer_lib::shared::DomainName; + /// + /// // Basic configuration without TLS + /// let config = HttpTrackerConfig::new( + /// "0.0.0.0:7070".parse().unwrap(), + /// None, + /// false, + /// )?; + /// + /// // Configuration with TLS (requires domain) + /// let tls_config = HttpTrackerConfig::new( + /// "0.0.0.0:7070".parse().unwrap(), + /// Some(DomainName::new("tracker.example.com")?), + /// true, + /// )?; + /// # Ok::<(), Box>(()) + /// ``` + pub fn new( + bind_address: SocketAddr, + domain: Option, + use_tls_proxy: bool, + ) -> Result { + // Invariant 1: Port 0 (dynamic assignment) is not supported + if bind_address.port() == 0 { + return Err(HttpTrackerConfigError::DynamicPortNotSupported( + bind_address, + )); + } + + // Invariant 2: TLS proxy requires a domain + if use_tls_proxy && domain.is_none() { + return Err(HttpTrackerConfigError::TlsProxyRequiresDomain(bind_address)); + } + + // Invariant 3: Localhost cannot use TLS (Caddy in separate container) + if use_tls_proxy && is_localhost(&bind_address) { + return Err(HttpTrackerConfigError::LocalhostWithTls(bind_address)); + } + + Ok(Self { + bind_address, + domain, + use_tls_proxy, + }) + } + + // ------------------------------------------------------------------------- + // Getter methods - provide read-only access to fields + // ------------------------------------------------------------------------- + + /// Returns the bind address + #[must_use] + pub fn bind_address(&self) -> SocketAddr { + self.bind_address + } + + /// Returns a reference to the domain, if configured + #[must_use] + pub fn domain(&self) -> Option<&DomainName> { + self.domain.as_ref() + } + + /// Returns whether TLS proxy is enabled + #[must_use] + pub fn use_tls_proxy(&self) -> bool { + self.use_tls_proxy + } + + // ------------------------------------------------------------------------- + // Convenience methods + // ------------------------------------------------------------------------- + /// Returns true if this tracker uses the TLS proxy + /// + /// Alias for `use_tls_proxy()` for semantic clarity. #[must_use] pub fn uses_tls_proxy(&self) -> bool { self.use_tls_proxy } /// Returns the domain name if TLS proxy is enabled + /// + /// Returns `None` if TLS is disabled, even if a domain is configured. + /// This is useful for determining the effective TLS domain. #[must_use] pub fn tls_domain(&self) -> Option<&DomainName> { if self.use_tls_proxy { @@ -51,20 +319,48 @@ impl HttpTrackerConfig { } } +/// Enables deserialization with validation through the constructor +/// +/// This ensures that JSON deserialization also validates the config, +/// maintaining the "always valid" invariant even for loaded data. +impl<'de> Deserialize<'de> for HttpTrackerConfig { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let raw = HttpTrackerConfigRaw::deserialize(deserializer)?; + Self::new(raw.bind_address, raw.domain, raw.use_tls_proxy).map_err(serde::de::Error::custom) + } +} + +impl fmt::Display for HttpTrackerConfig { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "HTTP tracker at {}", self.bind_address)?; + if let Some(domain) = &self.domain { + write!(f, " ({})", domain.as_str())?; + } + if self.use_tls_proxy { + write!(f, " [TLS]")?; + } + Ok(()) + } +} + #[cfg(test)] mod tests { use super::*; + // ========================================================================= + // Valid construction tests + // ========================================================================= + #[test] fn it_should_create_http_tracker_config_without_tls() { - let config = HttpTrackerConfig { - bind_address: "0.0.0.0:7070".parse().unwrap(), - domain: None, - use_tls_proxy: false, - }; + let config = HttpTrackerConfig::new("0.0.0.0:7070".parse().unwrap(), None, false) + .expect("valid config should succeed"); assert_eq!( - config.bind_address, + config.bind_address(), "0.0.0.0:7070".parse::().unwrap() ); assert!(!config.uses_tls_proxy()); @@ -73,11 +369,9 @@ mod tests { #[test] fn it_should_create_http_tracker_config_with_tls() { - let config = HttpTrackerConfig { - bind_address: "0.0.0.0:7070".parse().unwrap(), - domain: Some(DomainName::new("tracker.example.com").unwrap()), - use_tls_proxy: true, - }; + let domain = DomainName::new("tracker.example.com").unwrap(); + let config = HttpTrackerConfig::new("0.0.0.0:7070".parse().unwrap(), Some(domain), true) + .expect("valid config should succeed"); assert!(config.uses_tls_proxy()); assert_eq!( @@ -86,28 +380,222 @@ mod tests { ); } + #[test] + fn it_should_create_http_tracker_config_with_domain_but_no_tls() { + let domain = DomainName::new("tracker.example.com").unwrap(); + let config = HttpTrackerConfig::new("0.0.0.0:7070".parse().unwrap(), Some(domain), false) + .expect("valid config should succeed"); + + assert!(!config.uses_tls_proxy()); + // tls_domain returns None when TLS is disabled + assert!(config.tls_domain().is_none()); + // but domain() still returns the domain + assert!(config.domain().is_some()); + } + + // ========================================================================= + // Invariant violation tests + // ========================================================================= + + #[test] + fn it_should_reject_port_zero() { + let result = HttpTrackerConfig::new("0.0.0.0:0".parse().unwrap(), None, false); + + assert!(result.is_err()); + let err = result.unwrap_err(); + assert!(matches!( + err, + HttpTrackerConfigError::DynamicPortNotSupported(_) + )); + assert!(err.to_string().contains("dynamic port")); + } + + #[test] + fn it_should_reject_tls_without_domain() { + let result = HttpTrackerConfig::new("0.0.0.0:7070".parse().unwrap(), None, true); + + assert!(result.is_err()); + let err = result.unwrap_err(); + assert!(matches!( + err, + HttpTrackerConfigError::TlsProxyRequiresDomain(_) + )); + assert!(err.to_string().contains("TLS proxy requires a domain")); + } + + #[test] + fn it_should_reject_localhost_with_tls_ipv4() { + let domain = DomainName::new("tracker.example.com").unwrap(); + let result = HttpTrackerConfig::new("127.0.0.1:7070".parse().unwrap(), Some(domain), true); + + assert!(result.is_err()); + let err = result.unwrap_err(); + assert!(matches!(err, HttpTrackerConfigError::LocalhostWithTls(_))); + assert!(err.to_string().contains("localhost")); + } + + #[test] + fn it_should_reject_localhost_with_tls_ipv6() { + let domain = DomainName::new("tracker.example.com").unwrap(); + let result = HttpTrackerConfig::new("[::1]:7070".parse().unwrap(), Some(domain), true); + + assert!(result.is_err()); + let err = result.unwrap_err(); + assert!(matches!(err, HttpTrackerConfigError::LocalhostWithTls(_))); + } + + #[test] + fn it_should_allow_localhost_without_tls() { + let result = HttpTrackerConfig::new("127.0.0.1:7070".parse().unwrap(), None, false); + + assert!(result.is_ok()); + } + + // ========================================================================= + // Help text tests + // ========================================================================= + + #[test] + fn it_should_provide_help_text_for_port_zero_error() { + let err = HttpTrackerConfigError::DynamicPortNotSupported("0.0.0.0:0".parse().unwrap()); + + let help = err.help(); + assert!(help.contains("Dynamic port assignment")); + assert!(help.contains("Fix:")); + assert!(help.contains("7070")); + } + + #[test] + fn it_should_provide_help_text_for_tls_without_domain() { + let err = HttpTrackerConfigError::TlsProxyRequiresDomain("0.0.0.0:7070".parse().unwrap()); + + let help = err.help(); + assert!(help.contains("TLS proxy requires a domain")); + assert!(help.contains("Fix")); + } + + #[test] + fn it_should_provide_help_text_for_localhost_with_tls() { + let err = HttpTrackerConfigError::LocalhostWithTls("127.0.0.1:7070".parse().unwrap()); + + let help = err.help(); + assert!(help.contains("Localhost addresses cannot be used")); + assert!(help.contains("Docker container")); + } + + // ========================================================================= + // Serialization tests + // ========================================================================= + #[test] fn it_should_serialize_http_tracker_config() { - let json = serde_json::to_value(&HttpTrackerConfig { - bind_address: "0.0.0.0:7070".parse().unwrap(), - domain: None, - use_tls_proxy: false, - }) - .unwrap(); + let config = HttpTrackerConfig::new("0.0.0.0:7070".parse().unwrap(), None, false).unwrap(); + let json = serde_json::to_value(&config).unwrap(); assert_eq!(json["bind_address"], "0.0.0.0:7070"); assert_eq!(json["use_tls_proxy"], false); } + #[test] + fn it_should_serialize_http_tracker_config_with_tls() { + let domain = DomainName::new("tracker.example.com").unwrap(); + let config = + HttpTrackerConfig::new("0.0.0.0:7070".parse().unwrap(), Some(domain), true).unwrap(); + + let json = serde_json::to_value(&config).unwrap(); + assert_eq!(json["bind_address"], "0.0.0.0:7070"); + assert_eq!(json["domain"], "tracker.example.com"); + assert_eq!(json["use_tls_proxy"], true); + } + + // ========================================================================= + // Deserialization tests + // ========================================================================= + #[test] fn it_should_deserialize_http_tracker_config() { let json = r#"{"bind_address": "0.0.0.0:7070", "use_tls_proxy": false}"#; let config: HttpTrackerConfig = serde_json::from_str(json).unwrap(); assert_eq!( - config.bind_address, + config.bind_address(), "0.0.0.0:7070".parse::().unwrap() ); - assert!(!config.use_tls_proxy); + assert!(!config.use_tls_proxy()); + } + + #[test] + fn it_should_deserialize_http_tracker_config_with_tls() { + let json = r#"{"bind_address": "0.0.0.0:7070", "domain": "tracker.example.com", "use_tls_proxy": true}"#; + let config: HttpTrackerConfig = serde_json::from_str(json).unwrap(); + + assert_eq!( + config.bind_address(), + "0.0.0.0:7070".parse::().unwrap() + ); + assert!(config.use_tls_proxy()); + assert_eq!( + config.domain().map(DomainName::as_str), + Some("tracker.example.com") + ); + } + + #[test] + fn it_should_reject_port_zero_during_deserialization() { + let json = r#"{"bind_address": "0.0.0.0:0", "use_tls_proxy": false}"#; + let result: Result = serde_json::from_str(json); + + assert!(result.is_err()); + let err_msg = result.unwrap_err().to_string(); + assert!(err_msg.contains("dynamic port")); + } + + #[test] + fn it_should_reject_tls_without_domain_during_deserialization() { + let json = r#"{"bind_address": "0.0.0.0:7070", "use_tls_proxy": true}"#; + let result: Result = serde_json::from_str(json); + + assert!(result.is_err()); + let err_msg = result.unwrap_err().to_string(); + assert!(err_msg.contains("TLS proxy requires a domain")); + } + + // ========================================================================= + // Display tests + // ========================================================================= + + #[test] + fn it_should_display_without_tls() { + let config = HttpTrackerConfig::new("0.0.0.0:7070".parse().unwrap(), None, false).unwrap(); + + assert_eq!(config.to_string(), "HTTP tracker at 0.0.0.0:7070"); + } + + #[test] + fn it_should_display_with_tls() { + let domain = DomainName::new("tracker.example.com").unwrap(); + let config = + HttpTrackerConfig::new("0.0.0.0:7070".parse().unwrap(), Some(domain), true).unwrap(); + + assert_eq!( + config.to_string(), + "HTTP tracker at 0.0.0.0:7070 (tracker.example.com) [TLS]" + ); + } + + // ========================================================================= + // Round-trip tests + // ========================================================================= + + #[test] + fn it_should_round_trip_through_json() { + let domain = DomainName::new("tracker.example.com").unwrap(); + let original = + HttpTrackerConfig::new("0.0.0.0:7070".parse().unwrap(), Some(domain), true).unwrap(); + + let json = serde_json::to_string(&original).unwrap(); + let restored: HttpTrackerConfig = serde_json::from_str(&json).unwrap(); + + assert_eq!(original, restored); } } diff --git a/src/domain/tracker/config/mod.rs b/src/domain/tracker/config/mod.rs index 1d16a37d..cca91ae6 100644 --- a/src/domain/tracker/config/mod.rs +++ b/src/domain/tracker/config/mod.rs @@ -18,11 +18,14 @@ mod http; mod http_api; mod udp; -pub use core::{DatabaseConfig, MysqlConfig, SqliteConfig, TrackerCoreConfig}; -pub use health_check_api::HealthCheckApiConfig; -pub use http::HttpTrackerConfig; +pub use core::{ + DatabaseConfig, MysqlConfig, MysqlConfigError, SqliteConfig, SqliteConfigError, + TrackerCoreConfig, +}; +pub use health_check_api::{HealthCheckApiConfig, HealthCheckApiConfigError}; +pub use http::{HttpTrackerConfig, HttpTrackerConfigError}; pub use http_api::{HttpApiConfig, HttpApiConfigError}; -pub use udp::UdpTrackerConfig; +pub use udp::{UdpTrackerConfig, UdpTrackerConfigError}; /// Checks if a socket address is bound to localhost (127.0.0.1 or `::1`). /// @@ -59,48 +62,42 @@ pub fn is_localhost(addr: &SocketAddr) -> bool { /// UdpTrackerConfig, HttpTrackerConfig, HttpApiConfig, HealthCheckApiConfig /// }; /// -/// let tracker_config = TrackerConfig { -/// core: TrackerCoreConfig { -/// database: DatabaseConfig::Sqlite(SqliteConfig { -/// database_name: "tracker.db".to_string(), -/// }), -/// private: false, -/// }, -/// udp_trackers: vec![ -/// UdpTrackerConfig { bind_address: "0.0.0.0:6969".parse().unwrap(), domain: None }, -/// ], -/// http_trackers: vec![ -/// HttpTrackerConfig { bind_address: "0.0.0.0:7070".parse().unwrap(), domain: None, use_tls_proxy: false }, -/// ], -/// http_api: HttpApiConfig::new( +/// let tracker_config = TrackerConfig::new( +/// TrackerCoreConfig::new( +/// DatabaseConfig::Sqlite(SqliteConfig::new("tracker.db").unwrap()), +/// false, +/// ), +/// vec![UdpTrackerConfig::new("0.0.0.0:6969".parse().unwrap(), None).unwrap()], +/// vec![HttpTrackerConfig::new("0.0.0.0:7070".parse().unwrap(), None, false).unwrap()], +/// HttpApiConfig::new( /// "0.0.0.0:1212".parse().unwrap(), /// "MyAccessToken".to_string().into(), /// None, /// false, /// ).expect("valid config"), -/// health_check_api: HealthCheckApiConfig { -/// bind_address: "127.0.0.1:1313".parse().unwrap(), -/// domain: None, -/// use_tls_proxy: false, -/// }, -/// }; +/// HealthCheckApiConfig::new( +/// "127.0.0.1:1313".parse().unwrap(), +/// None, +/// false, +/// ).expect("valid config"), +/// ).expect("valid config"); /// ``` -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[derive(Debug, Clone, Serialize, PartialEq)] pub struct TrackerConfig { /// Core tracker configuration - pub core: TrackerCoreConfig, + core: TrackerCoreConfig, /// UDP tracker instances - pub udp_trackers: Vec, + udp_trackers: Vec, /// HTTP tracker instances - pub http_trackers: Vec, + http_trackers: Vec, /// HTTP API configuration - pub http_api: HttpApiConfig, + http_api: HttpApiConfig, /// Health Check API configuration - pub health_check_api: HealthCheckApiConfig, + health_check_api: HealthCheckApiConfig, } /// Error type for tracker configuration validation failures @@ -115,18 +112,6 @@ pub enum TrackerConfigError { /// Names of services attempting to bind to this address services: Vec, }, - - /// A service is bound to localhost but has TLS configured - /// - /// When a service binds to localhost (127.0.0.1 or `::1`), it cannot be proxied - /// through Caddy because Caddy runs in a separate container and localhost - /// addresses are not routable between containers. - LocalhostWithTls { - /// The service name (e.g., "HTTP API", "Health Check API", "HTTP Tracker #1") - service_name: String, - /// The bind address that uses localhost - bind_address: SocketAddr, - }, } impl fmt::Display for TrackerConfigError { @@ -148,16 +133,6 @@ impl fmt::Display for TrackerConfigError { Tip: Assign different port numbers to each service" ) } - Self::LocalhostWithTls { - service_name, - bind_address, - } => { - write!( - f, - "Localhost with TLS: '{service_name}' binds to {bind_address} which is not accessible for TLS proxy\n\ - Tip: Use a non-localhost address (e.g., 0.0.0.0) or remove TLS configuration" - ) - } } } } @@ -208,38 +183,6 @@ impl TrackerConfigError { See: docs/external-issues/tracker/udp-tcp-port-sharing-allowed.md\n", ); - help - } - Self::LocalhostWithTls { - service_name, - bind_address, - } => { - use std::fmt::Write; - - let mut help = String::from("Localhost with TLS - Detailed Troubleshooting:\n\n"); - - help.push_str("Affected service:\n"); - let _ = writeln!(help, " - {service_name}: {bind_address}\n"); - - help.push_str("Why this fails:\n"); - help.push_str( - "When a service binds to localhost (127.0.0.1 or `::1`), it is only accessible\n\ - from the same container. Caddy runs in a separate container and cannot\n\ - reach localhost addresses in the tracker container.\n\n", - ); - - help.push_str("Fix (choose one):\n"); - help.push_str( - "1. Change bind address to 0.0.0.0 to allow external access\n\ - 2. Remove TLS configuration if external access is not needed\n\n", - ); - - help.push_str("Note:\n"); - help.push_str( - "Services bound to localhost without TLS can be accessed via SSH tunnel.\n\ - Use: ssh -L local_port:localhost:remote_port user@host\n", - ); - help } } @@ -247,17 +190,22 @@ impl TrackerConfigError { } impl TrackerConfig { - /// Validates the tracker configuration for socket address conflicts + /// Creates a new `TrackerConfig` with validated aggregate invariants. /// - /// Checks that no two services using the same protocol attempt to bind - /// to the same socket address (IP + port). Services using different - /// protocols (UDP vs TCP) can share the same port number. + /// This constructor validates that no socket address conflicts exist + /// (multiple services binding to the same IP:port:protocol combination). /// /// # Errors /// /// Returns `TrackerConfigError::DuplicateSocketAddress` if multiple services /// using the same protocol attempt to bind to the same socket address. /// + /// # Note + /// + /// Individual component validation (port != 0, TLS requires domain, localhost + /// cannot use TLS) is enforced by the child config types at their construction + /// time. This constructor only validates aggregate-level invariants. + /// /// # Examples /// /// ```rust @@ -266,81 +214,85 @@ impl TrackerConfig { /// UdpTrackerConfig, HttpTrackerConfig, HttpApiConfig, HealthCheckApiConfig /// }; /// - /// let config = TrackerConfig { - /// core: TrackerCoreConfig { - /// database: DatabaseConfig::Sqlite(SqliteConfig { - /// database_name: "tracker.db".to_string(), - /// }), - /// private: false, - /// }, - /// udp_trackers: vec![ - /// UdpTrackerConfig { bind_address: "0.0.0.0:6969".parse().unwrap(), domain: None }, - /// ], - /// http_trackers: vec![ - /// HttpTrackerConfig { bind_address: "0.0.0.0:7070".parse().unwrap(), domain: None, use_tls_proxy: false }, - /// ], - /// http_api: HttpApiConfig::new( + /// let config = TrackerConfig::new( + /// TrackerCoreConfig::new( + /// DatabaseConfig::Sqlite(SqliteConfig::new("tracker.db").unwrap()), + /// false, + /// ), + /// vec![UdpTrackerConfig::new("0.0.0.0:6969".parse().unwrap(), None).unwrap()], + /// vec![HttpTrackerConfig::new("0.0.0.0:7070".parse().unwrap(), None, false).unwrap()], + /// HttpApiConfig::new( /// "0.0.0.0:1212".parse().unwrap(), /// "MyAccessToken".to_string().into(), /// None, /// false, - /// ).expect("valid config"), - /// health_check_api: HealthCheckApiConfig { - /// bind_address: "127.0.0.1:1313".parse().unwrap(), - /// domain: None, - /// use_tls_proxy: false, - /// }, - /// }; - /// - /// assert!(config.validate().is_ok()); + /// ).unwrap(), + /// HealthCheckApiConfig::new( + /// "127.0.0.1:1313".parse().unwrap(), + /// None, + /// false, + /// ).unwrap(), + /// ).expect("valid config"); /// ``` - pub fn validate(&self) -> Result<(), TrackerConfigError> { - // Check for localhost + TLS combinations - self.check_localhost_with_tls()?; + pub fn new( + core: TrackerCoreConfig, + udp_trackers: Vec, + http_trackers: Vec, + http_api: HttpApiConfig, + health_check_api: HealthCheckApiConfig, + ) -> Result { + let config = Self { + core, + udp_trackers, + http_trackers, + http_api, + health_check_api, + }; - // Check for socket address conflicts - let bindings = self.collect_bindings(); - Self::check_for_conflicts(bindings) + // Validate aggregate-level invariants + // (Child components are already validated at their construction) + config.check_socket_address_conflicts()?; + + Ok(config) } - /// Checks that no service has both localhost binding and TLS configured - /// - /// When a service binds to localhost (127.0.0.1 or `::1`), it cannot be - /// proxied through Caddy because Caddy runs in a separate container. - /// - /// # Errors - /// - /// Returns `TrackerConfigError::LocalhostWithTls` if any service has - /// both a localhost binding and TLS configuration. - fn check_localhost_with_tls(&self) -> Result<(), TrackerConfigError> { - // Check HTTP API - if self.http_api.use_tls_proxy() && is_localhost(&self.http_api.bind_address()) { - return Err(TrackerConfigError::LocalhostWithTls { - service_name: "HTTP API".to_string(), - bind_address: self.http_api.bind_address(), - }); - } + /// Returns the core tracker configuration. + #[must_use] + pub fn core(&self) -> &TrackerCoreConfig { + &self.core + } - // Check Health Check API - if self.health_check_api.use_tls_proxy && is_localhost(&self.health_check_api.bind_address) - { - return Err(TrackerConfigError::LocalhostWithTls { - service_name: "Health Check API".to_string(), - bind_address: self.health_check_api.bind_address, - }); - } + /// Returns the UDP tracker configurations. + #[must_use] + pub fn udp_trackers(&self) -> &[UdpTrackerConfig] { + &self.udp_trackers + } - // Check HTTP trackers - for (i, tracker) in self.http_trackers.iter().enumerate() { - if tracker.use_tls_proxy && is_localhost(&tracker.bind_address) { - return Err(TrackerConfigError::LocalhostWithTls { - service_name: format!("HTTP Tracker #{}", i + 1), - bind_address: tracker.bind_address, - }); - } - } + /// Returns the HTTP tracker configurations. + #[must_use] + pub fn http_trackers(&self) -> &[HttpTrackerConfig] { + &self.http_trackers + } - Ok(()) + /// Returns the HTTP API configuration. + #[must_use] + pub fn http_api(&self) -> &HttpApiConfig { + &self.http_api + } + + /// Returns the Health Check API configuration. + #[must_use] + pub fn health_check_api(&self) -> &HealthCheckApiConfig { + &self.health_check_api + } + + /// Checks for socket address conflicts + /// + /// Validates that no two services using the same protocol attempt to bind + /// to the same socket address (IP + port). + fn check_socket_address_conflicts(&self) -> Result<(), TrackerConfigError> { + let bindings = self.collect_bindings(); + Self::check_for_conflicts(bindings) } /// Checks for socket address conflicts in the collected bindings @@ -403,7 +355,7 @@ impl TrackerConfig { // Add Health Check API Self::register_binding( &mut bindings, - self.health_check_api.bind_address, + self.health_check_api.bind_address(), Protocol::Tcp, "Health Check API", ); @@ -465,7 +417,7 @@ impl TrackerConfig { /// Returns the Health Check API port number #[must_use] pub fn health_check_api_port(&self) -> u16 { - self.health_check_api.bind_address.port() + self.health_check_api.bind_address().port() } /// Returns HTTP trackers that have TLS proxy enabled @@ -476,12 +428,11 @@ impl TrackerConfig { pub fn http_trackers_with_tls(&self) -> Vec<(&str, u16)> { self.http_trackers .iter() - .filter(|tracker| tracker.use_tls_proxy) + .filter(|tracker| tracker.use_tls_proxy()) .filter_map(|tracker| { tracker - .domain - .as_ref() - .map(|domain| (domain.as_str(), tracker.bind_address.port())) + .domain() + .map(|domain| (domain.as_str(), tracker.bind_address().port())) }) .collect() } @@ -492,7 +443,28 @@ impl TrackerConfig { /// setting should be enabled in the tracker configuration template. #[must_use] pub fn any_http_tracker_uses_tls_proxy(&self) -> bool { - self.http_trackers.iter().any(|t| t.use_tls_proxy) + self.http_trackers + .iter() + .any(http::HttpTrackerConfig::use_tls_proxy) + } + + /// Returns true if any service has TLS proxy configured + /// + /// Checks if at least one of the following services has TLS enabled: + /// - HTTP API (`use_tls_proxy: true`) + /// - Any HTTP tracker (`use_tls_proxy: true`) + /// - Health Check API (`use_tls_proxy: true`) + /// + /// This is used for cross-service validation to ensure that when the HTTPS + /// section is defined, at least one service actually uses TLS. + #[must_use] + pub fn has_any_tls_configured(&self) -> bool { + self.http_api.use_tls_proxy() + || self + .http_trackers + .iter() + .any(http::HttpTrackerConfig::use_tls_proxy) + || self.health_check_api.use_tls_proxy() } } @@ -506,13 +478,13 @@ trait HasBindAddress { impl HasBindAddress for UdpTrackerConfig { fn bind_address(&self) -> SocketAddr { - self.bind_address + UdpTrackerConfig::bind_address(self) } } impl HasBindAddress for HttpTrackerConfig { fn bind_address(&self) -> SocketAddr { - self.bind_address + HttpTrackerConfig::bind_address(self) } } @@ -528,35 +500,38 @@ impl Default for TrackerConfig { /// - HTTP API: Bind address 0.0.0.0:1212 /// - Admin token: `MyAccessToken` fn default() -> Self { - Self { - core: TrackerCoreConfig { - database: DatabaseConfig::Sqlite(SqliteConfig { - database_name: "tracker.db".to_string(), - }), - private: false, - }, - udp_trackers: vec![UdpTrackerConfig { - bind_address: "0.0.0.0:6969".parse().expect("valid address"), - domain: None, - }], - http_trackers: vec![HttpTrackerConfig { - bind_address: "0.0.0.0:7070".parse().expect("valid address"), - domain: None, - use_tls_proxy: false, - }], - http_api: HttpApiConfig::new( + Self::new( + TrackerCoreConfig::new( + DatabaseConfig::Sqlite( + SqliteConfig::new("tracker.db").expect("default sqlite config is valid"), + ), + false, + ), + vec![ + UdpTrackerConfig::new("0.0.0.0:6969".parse().expect("valid address"), None) + .expect("default UdpTrackerConfig values are always valid"), + ], + vec![HttpTrackerConfig::new( + "0.0.0.0:7070".parse().expect("valid address"), + None, + false, + ) + .expect("default HttpTrackerConfig values are always valid")], + HttpApiConfig::new( "0.0.0.0:1212".parse().expect("valid address"), "MyAccessToken".to_string().into(), None, false, ) .expect("default HttpApiConfig values are always valid"), - health_check_api: HealthCheckApiConfig { - bind_address: "127.0.0.1:1313".parse().expect("valid address"), - domain: None, - use_tls_proxy: false, - }, - } + HealthCheckApiConfig::new( + "127.0.0.1:1313".parse().expect("valid address"), + None, + false, + ) + .expect("default HealthCheckApiConfig values are always valid"), + ) + .expect("default TrackerConfig values have no socket address conflicts") } } @@ -575,10 +550,96 @@ where s.parse().map_err(serde::de::Error::custom) } +/// Raw struct for deserializing `TrackerConfig` before validation. +#[derive(Deserialize)] +struct TrackerConfigRaw { + core: TrackerCoreConfig, + udp_trackers: Vec, + http_trackers: Vec, + http_api: HttpApiConfig, + health_check_api: HealthCheckApiConfig, +} + +impl<'de> Deserialize<'de> for TrackerConfig { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let raw = TrackerConfigRaw::deserialize(deserializer)?; + TrackerConfig::new( + raw.core, + raw.udp_trackers, + raw.http_trackers, + raw.http_api, + raw.health_check_api, + ) + .map_err(serde::de::Error::custom) + } +} + #[cfg(test)] mod tests { use super::*; + /// Test helper to create a `UdpTrackerConfig` with default values. + /// Uses the validated constructor, making tests more realistic. + fn test_udp_tracker_config(bind_address: &str) -> UdpTrackerConfig { + UdpTrackerConfig::new(bind_address.parse().expect("valid address"), None) + .expect("test values should be valid") + } + + /// Test helper to create a `UdpTrackerConfig` with domain. + #[allow(dead_code)] + fn test_udp_tracker_config_with_domain( + bind_address: &str, + domain: DomainName, + ) -> UdpTrackerConfig { + UdpTrackerConfig::new(bind_address.parse().expect("valid address"), Some(domain)) + .expect("test values should be valid") + } + + /// Test helper to create an `HttpTrackerConfig` with default values (no TLS). + fn test_http_tracker_config(bind_address: &str) -> HttpTrackerConfig { + HttpTrackerConfig::new(bind_address.parse().expect("valid address"), None, false) + .expect("test values should be valid") + } + + /// Test helper with TLS options for HTTP trackers. + #[allow(dead_code)] + fn test_http_tracker_config_with_tls( + bind_address: &str, + domain: Option, + use_tls_proxy: bool, + ) -> HttpTrackerConfig { + HttpTrackerConfig::new( + bind_address.parse().expect("valid address"), + domain, + use_tls_proxy, + ) + .expect("test values should be valid") + } + + /// Test helper to create a `HealthCheckApiConfig` with default values (no TLS). + fn test_health_check_api_config(bind_address: &str) -> HealthCheckApiConfig { + HealthCheckApiConfig::new(bind_address.parse().expect("valid address"), None, false) + .expect("test values should be valid") + } + + /// Test helper with TLS options for Health Check API. + #[allow(dead_code)] + fn test_health_check_api_config_with_tls( + bind_address: &str, + domain: Option, + use_tls_proxy: bool, + ) -> HealthCheckApiConfig { + HealthCheckApiConfig::new( + bind_address.parse().expect("valid address"), + domain, + use_tls_proxy, + ) + .expect("test values should be valid") + } + /// Test helper to create an `HttpApiConfig` with default or custom values. /// Uses the validated constructor, making tests more realistic. fn test_http_api_config(bind_address: &str, admin_token: &str) -> HttpApiConfig { @@ -607,6 +668,61 @@ mod tests { .expect("test values should be valid") } + /// Test helper to create a `TrackerConfig` with default values. + /// Uses the validated constructor, making tests more realistic. + fn test_tracker_config( + udp_trackers: Vec, + http_trackers: Vec, + http_api: HttpApiConfig, + health_check_api: HealthCheckApiConfig, + ) -> TrackerConfig { + TrackerConfig::new( + TrackerCoreConfig::new( + DatabaseConfig::Sqlite(SqliteConfig::new("tracker.db").unwrap()), + false, + ), + udp_trackers, + http_trackers, + http_api, + health_check_api, + ) + .expect("test values should be valid") + } + + /// Test helper to create a `TrackerConfig` with custom core config. + fn test_tracker_config_with_core( + core: TrackerCoreConfig, + udp_trackers: Vec, + http_trackers: Vec, + http_api: HttpApiConfig, + health_check_api: HealthCheckApiConfig, + ) -> TrackerConfig { + TrackerConfig::new( + core, + udp_trackers, + http_trackers, + http_api, + health_check_api, + ) + .expect("test values should be valid") + } + + /// Test helper to create a private core config (`SQLite`) + fn test_private_core_config() -> TrackerCoreConfig { + TrackerCoreConfig::new( + DatabaseConfig::Sqlite(SqliteConfig::new("tracker.db").unwrap()), + true, + ) + } + + /// Test helper to create a core config with custom database name + fn test_core_config_with_db(database_name: &str) -> TrackerCoreConfig { + TrackerCoreConfig::new( + DatabaseConfig::Sqlite(SqliteConfig::new(database_name).unwrap()), + false, + ) + } + mod is_localhost_tests { use super::*; @@ -650,54 +766,29 @@ mod tests { #[test] fn it_should_create_tracker_config() { - let config = TrackerConfig { - core: TrackerCoreConfig { - database: DatabaseConfig::Sqlite(SqliteConfig { - database_name: "tracker.db".to_string(), - }), - private: true, - }, - udp_trackers: vec![UdpTrackerConfig { - bind_address: "0.0.0.0:6868".parse().unwrap(), - domain: None, - }], - http_trackers: vec![HttpTrackerConfig { - bind_address: "0.0.0.0:7070".parse().unwrap(), - domain: None, - use_tls_proxy: false, - }], - http_api: test_http_api_config("0.0.0.0:1212", "test_token"), - health_check_api: HealthCheckApiConfig { - bind_address: "127.0.0.1:1313".parse().unwrap(), - domain: None, - use_tls_proxy: false, - }, - }; + let config = test_tracker_config_with_core( + test_private_core_config(), + vec![test_udp_tracker_config("0.0.0.0:6868")], + vec![test_http_tracker_config("0.0.0.0:7070")], + test_http_api_config("0.0.0.0:1212", "test_token"), + test_health_check_api_config("127.0.0.1:1313"), + ); - assert_eq!(config.core.database.database_name(), "tracker.db"); - assert!(config.core.private); - assert_eq!(config.udp_trackers.len(), 1); - assert_eq!(config.http_trackers.len(), 1); + assert_eq!(config.core().database().database_name(), "tracker.db"); + assert!(config.core().private()); + assert_eq!(config.udp_trackers().len(), 1); + assert_eq!(config.http_trackers().len(), 1); } #[test] fn it_should_serialize_tracker_config() { - let config = TrackerConfig { - core: TrackerCoreConfig { - database: DatabaseConfig::Sqlite(SqliteConfig { - database_name: "test.db".to_string(), - }), - private: false, - }, - udp_trackers: vec![], - http_trackers: vec![], - http_api: test_http_api_config("0.0.0.0:1212", "token123"), - health_check_api: HealthCheckApiConfig { - bind_address: "127.0.0.1:1313".parse().unwrap(), - domain: None, - use_tls_proxy: false, - }, - }; + let config = test_tracker_config_with_core( + test_core_config_with_db("test.db"), + vec![], + vec![], + test_http_api_config("0.0.0.0:1212", "token123"), + test_health_check_api_config("127.0.0.1:1313"), + ); let json = serde_json::to_value(&config).unwrap(); assert_eq!(json["core"]["private"], false); @@ -709,33 +800,33 @@ mod tests { let config = TrackerConfig::default(); // Verify default database configuration - assert_eq!(config.core.database.database_name(), "tracker.db"); - assert_eq!(config.core.database.driver_name(), "sqlite3"); + assert_eq!(config.core().database().database_name(), "tracker.db"); + assert_eq!(config.core().database().driver_name(), "sqlite3"); // Verify public tracker mode - assert!(!config.core.private); + assert!(!config.core().private()); // Verify UDP trackers (1 instance) - assert_eq!(config.udp_trackers.len(), 1); + assert_eq!(config.udp_trackers().len(), 1); assert_eq!( - config.udp_trackers[0].bind_address, + config.udp_trackers()[0].bind_address(), "0.0.0.0:6969".parse::().unwrap() ); // Verify HTTP trackers (1 instance) - assert_eq!(config.http_trackers.len(), 1); + assert_eq!(config.http_trackers().len(), 1); assert_eq!( - config.http_trackers[0].bind_address, + config.http_trackers()[0].bind_address(), "0.0.0.0:7070".parse::().unwrap() ); // Verify HTTP API configuration assert_eq!( - config.http_api.bind_address(), + config.http_api().bind_address(), "0.0.0.0:1212".parse::().unwrap() ); assert_eq!( - config.http_api.admin_token().expose_secret(), + config.http_api().admin_token().expose_secret(), "MyAccessToken" ); } @@ -745,62 +836,36 @@ mod tests { #[test] fn it_should_accept_valid_configuration_with_unique_addresses() { - let config = TrackerConfig { - core: TrackerCoreConfig { - database: DatabaseConfig::Sqlite(SqliteConfig { - database_name: "tracker.db".to_string(), - }), - private: false, - }, - udp_trackers: vec![UdpTrackerConfig { - bind_address: "0.0.0.0:6969".parse().unwrap(), - domain: None, - }], - http_trackers: vec![HttpTrackerConfig { - bind_address: "0.0.0.0:7070".parse().unwrap(), - domain: None, - use_tls_proxy: false, - }], - http_api: test_http_api_config("0.0.0.0:1212", "token"), - health_check_api: HealthCheckApiConfig { - bind_address: "127.0.0.1:1313".parse().unwrap(), - domain: None, - use_tls_proxy: false, - }, - }; - - assert!(config.validate().is_ok()); + let result = TrackerConfig::new( + TrackerCoreConfig::new( + DatabaseConfig::Sqlite(SqliteConfig::new("tracker.db").unwrap()), + false, + ), + vec![test_udp_tracker_config("0.0.0.0:6969")], + vec![test_http_tracker_config("0.0.0.0:7070")], + test_http_api_config("0.0.0.0:1212", "token"), + test_health_check_api_config("127.0.0.1:1313"), + ); + + assert!(result.is_ok()); } #[test] fn it_should_reject_duplicate_udp_tracker_ports() { - let config = TrackerConfig { - core: TrackerCoreConfig { - database: DatabaseConfig::Sqlite(SqliteConfig { - database_name: "tracker.db".to_string(), - }), - private: false, - }, - udp_trackers: vec![ - UdpTrackerConfig { - bind_address: "0.0.0.0:7070".parse().unwrap(), - domain: None, - }, - UdpTrackerConfig { - bind_address: "0.0.0.0:7070".parse().unwrap(), - domain: None, - }, + let result = TrackerConfig::new( + TrackerCoreConfig::new( + DatabaseConfig::Sqlite(SqliteConfig::new("tracker.db").unwrap()), + false, + ), + vec![ + test_udp_tracker_config("0.0.0.0:7070"), + test_udp_tracker_config("0.0.0.0:7070"), ], - http_trackers: vec![], - http_api: test_http_api_config("0.0.0.0:1212", "token"), - health_check_api: HealthCheckApiConfig { - bind_address: "127.0.0.1:1313".parse().unwrap(), - domain: None, - use_tls_proxy: false, - }, - }; - - let result = config.validate(); + vec![], + test_http_api_config("0.0.0.0:1212", "token"), + test_health_check_api_config("127.0.0.1:1313"), + ); + assert!(result.is_err()); if let Err(TrackerConfigError::DuplicateSocketAddress { @@ -821,35 +886,20 @@ mod tests { #[test] fn it_should_reject_duplicate_http_tracker_ports() { - let config = TrackerConfig { - core: TrackerCoreConfig { - database: DatabaseConfig::Sqlite(SqliteConfig { - database_name: "tracker.db".to_string(), - }), - private: false, - }, - udp_trackers: vec![], - http_trackers: vec![ - HttpTrackerConfig { - bind_address: "0.0.0.0:7070".parse().unwrap(), - domain: None, - use_tls_proxy: false, - }, - HttpTrackerConfig { - bind_address: "0.0.0.0:7070".parse().unwrap(), - domain: None, - use_tls_proxy: false, - }, + let result = TrackerConfig::new( + TrackerCoreConfig::new( + DatabaseConfig::Sqlite(SqliteConfig::new("tracker.db").unwrap()), + false, + ), + vec![], + vec![ + test_http_tracker_config("0.0.0.0:7070"), + test_http_tracker_config("0.0.0.0:7070"), ], - http_api: test_http_api_config("0.0.0.0:1212", "token"), - health_check_api: HealthCheckApiConfig { - bind_address: "127.0.0.1:1313".parse().unwrap(), - domain: None, - use_tls_proxy: false, - }, - }; - - let result = config.validate(); + test_http_api_config("0.0.0.0:1212", "token"), + test_health_check_api_config("127.0.0.1:1313"), + ); + assert!(result.is_err()); if let Err(TrackerConfigError::DuplicateSocketAddress { @@ -868,28 +918,17 @@ mod tests { #[test] fn it_should_reject_http_tracker_and_api_conflict() { - let config = TrackerConfig { - core: TrackerCoreConfig { - database: DatabaseConfig::Sqlite(SqliteConfig { - database_name: "tracker.db".to_string(), - }), - private: false, - }, - udp_trackers: vec![], - http_trackers: vec![HttpTrackerConfig { - bind_address: "0.0.0.0:7070".parse().unwrap(), - domain: None, - use_tls_proxy: false, - }], - http_api: test_http_api_config("0.0.0.0:7070", "token"), - health_check_api: HealthCheckApiConfig { - bind_address: "127.0.0.1:1313".parse().unwrap(), - domain: None, - use_tls_proxy: false, - }, - }; - - let result = config.validate(); + let result = TrackerConfig::new( + TrackerCoreConfig::new( + DatabaseConfig::Sqlite(SqliteConfig::new("tracker.db").unwrap()), + false, + ), + vec![], + vec![test_http_tracker_config("0.0.0.0:7070")], + test_http_api_config("0.0.0.0:7070", "token"), + test_health_check_api_config("127.0.0.1:1313"), + ); + assert!(result.is_err()); if let Err(TrackerConfigError::DuplicateSocketAddress { @@ -910,28 +949,17 @@ mod tests { #[test] fn it_should_reject_http_tracker_and_health_check_api_conflict() { - let config = TrackerConfig { - core: TrackerCoreConfig { - database: DatabaseConfig::Sqlite(SqliteConfig { - database_name: "tracker.db".to_string(), - }), - private: false, - }, - udp_trackers: vec![], - http_trackers: vec![HttpTrackerConfig { - bind_address: "0.0.0.0:1313".parse().unwrap(), - domain: None, - use_tls_proxy: false, - }], - http_api: test_http_api_config("0.0.0.0:1212", "token"), - health_check_api: HealthCheckApiConfig { - bind_address: "0.0.0.0:1313".parse().unwrap(), - domain: None, - use_tls_proxy: false, - }, - }; - - let result = config.validate(); + let result = TrackerConfig::new( + TrackerCoreConfig::new( + DatabaseConfig::Sqlite(SqliteConfig::new("tracker.db").unwrap()), + false, + ), + vec![], + vec![test_http_tracker_config("0.0.0.0:1313")], + test_http_api_config("0.0.0.0:1212", "token"), + test_health_check_api_config("0.0.0.0:1313"), + ); + assert!(result.is_err()); if let Err(TrackerConfigError::DuplicateSocketAddress { @@ -953,90 +981,53 @@ mod tests { #[test] fn it_should_allow_udp_and_http_on_same_port() { // This is valid because UDP and TCP use separate port spaces - let config = TrackerConfig { - core: TrackerCoreConfig { - database: DatabaseConfig::Sqlite(SqliteConfig { - database_name: "tracker.db".to_string(), - }), - private: false, - }, - udp_trackers: vec![UdpTrackerConfig { - bind_address: "0.0.0.0:7070".parse().unwrap(), - domain: None, - }], - http_trackers: vec![HttpTrackerConfig { - bind_address: "0.0.0.0:7070".parse().unwrap(), - domain: None, - use_tls_proxy: false, - }], - http_api: test_http_api_config("0.0.0.0:1212", "token"), - health_check_api: HealthCheckApiConfig { - bind_address: "127.0.0.1:1313".parse().unwrap(), - domain: None, - use_tls_proxy: false, - }, - }; - - assert!(config.validate().is_ok()); + let result = TrackerConfig::new( + TrackerCoreConfig::new( + DatabaseConfig::Sqlite(SqliteConfig::new("tracker.db").unwrap()), + false, + ), + vec![test_udp_tracker_config("0.0.0.0:7070")], + vec![test_http_tracker_config("0.0.0.0:7070")], + test_http_api_config("0.0.0.0:1212", "token"), + test_health_check_api_config("127.0.0.1:1313"), + ); + + assert!(result.is_ok()); } #[test] fn it_should_allow_same_port_different_ips() { - let config = TrackerConfig { - core: TrackerCoreConfig { - database: DatabaseConfig::Sqlite(SqliteConfig { - database_name: "tracker.db".to_string(), - }), - private: false, - }, - udp_trackers: vec![], - http_trackers: vec![ - HttpTrackerConfig { - bind_address: "192.168.1.10:7070".parse().unwrap(), - domain: None, - use_tls_proxy: false, - }, - HttpTrackerConfig { - bind_address: "192.168.1.20:7070".parse().unwrap(), - domain: None, - use_tls_proxy: false, - }, + let result = TrackerConfig::new( + TrackerCoreConfig::new( + DatabaseConfig::Sqlite(SqliteConfig::new("tracker.db").unwrap()), + false, + ), + vec![], + vec![ + test_http_tracker_config("192.168.1.10:7070"), + test_http_tracker_config("192.168.1.20:7070"), ], - http_api: test_http_api_config("0.0.0.0:1212", "token"), - health_check_api: HealthCheckApiConfig { - bind_address: "127.0.0.1:1313".parse().unwrap(), - domain: None, - use_tls_proxy: false, - }, - }; - - assert!(config.validate().is_ok()); + test_http_api_config("0.0.0.0:1212", "token"), + test_health_check_api_config("127.0.0.1:1313"), + ); + + assert!(result.is_ok()); } #[test] fn it_should_provide_clear_error_message_with_fix_instructions() { - let config = TrackerConfig { - core: TrackerCoreConfig { - database: DatabaseConfig::Sqlite(SqliteConfig { - database_name: "tracker.db".to_string(), - }), - private: false, - }, - udp_trackers: vec![], - http_trackers: vec![HttpTrackerConfig { - bind_address: "0.0.0.0:7070".parse().unwrap(), - domain: None, - use_tls_proxy: false, - }], - http_api: test_http_api_config("0.0.0.0:7070", "token"), - health_check_api: HealthCheckApiConfig { - bind_address: "127.0.0.1:1313".parse().unwrap(), - domain: None, - use_tls_proxy: false, - }, - }; - - let error = config.validate().unwrap_err(); + let result = TrackerConfig::new( + TrackerCoreConfig::new( + DatabaseConfig::Sqlite(SqliteConfig::new("tracker.db").unwrap()), + false, + ), + vec![], + vec![test_http_tracker_config("0.0.0.0:7070")], + test_http_api_config("0.0.0.0:7070", "token"), + test_health_check_api_config("127.0.0.1:1313"), + ); + + let error = result.unwrap_err(); let error_message = error.to_string(); // Verify brief error message contains essential information @@ -1063,103 +1054,44 @@ mod tests { use super::*; fn base_config() -> TrackerConfig { - TrackerConfig { - core: TrackerCoreConfig { - database: DatabaseConfig::Sqlite(SqliteConfig { - database_name: "tracker.db".to_string(), - }), - private: false, - }, - udp_trackers: vec![], - http_trackers: vec![], - http_api: test_http_api_config("0.0.0.0:1212", "token"), - health_check_api: HealthCheckApiConfig { - bind_address: "127.0.0.1:1313".parse().unwrap(), - domain: None, - use_tls_proxy: false, - }, - } - } - - // NOTE: Tests for HTTP API localhost + TLS rejection have been removed because - // this validation is now enforced at construction time by HttpApiConfig::new(). - // See http_api.rs for the corresponding tests. - - #[test] - fn it_should_reject_health_check_api_localhost_with_tls() { - let domain = crate::shared::DomainName::new("health.tracker.local").unwrap(); - let mut config = base_config(); - config.health_check_api = HealthCheckApiConfig { - bind_address: "127.0.0.1:1313".parse().unwrap(), - domain: Some(domain), - use_tls_proxy: true, - }; - - let result = config.validate(); - assert!(result.is_err()); - - if let Err(TrackerConfigError::LocalhostWithTls { - service_name, - bind_address, - }) = result - { - assert_eq!(service_name, "Health Check API"); - assert_eq!( - bind_address, - "127.0.0.1:1313".parse::().unwrap() - ); - } else { - panic!("Expected LocalhostWithTls error"); - } + test_tracker_config( + vec![], + vec![], + test_http_api_config("0.0.0.0:1212", "token"), + test_health_check_api_config("127.0.0.1:1313"), + ) } - #[test] - fn it_should_reject_http_tracker_localhost_with_tls() { - let domain = crate::shared::DomainName::new("tracker.local").unwrap(); - let mut config = base_config(); - config.http_trackers = vec![HttpTrackerConfig { - bind_address: "127.0.0.1:7070".parse().unwrap(), - domain: Some(domain), - use_tls_proxy: true, - }]; - - let result = config.validate(); - assert!(result.is_err()); - - if let Err(TrackerConfigError::LocalhostWithTls { - service_name, - bind_address, - }) = result - { - assert_eq!(service_name, "HTTP Tracker #1"); - assert_eq!( - bind_address, - "127.0.0.1:7070".parse::().unwrap() - ); - } else { - panic!("Expected LocalhostWithTls error"); - } - } + // NOTE: Tests for localhost + TLS rejection have been moved to the individual + // config type tests (health_check_api.rs, http.rs, http_api.rs) because + // validation is now enforced at construction time by their respective ::new() + // methods. TrackerConfig::new() no longer needs to check for localhost + TLS + // as it's impossible to construct invalid child configs. #[test] fn it_should_allow_localhost_without_tls() { - let config = base_config(); // base_config has http_api on 0.0.0.0 and health_check_api on 127.0.0.1 without TLS - assert!(config.validate().is_ok()); + let config = base_config(); + // If we got here without error, the config is valid + assert_eq!(config.http_api().bind_address().port(), 1212); } #[test] fn it_should_allow_non_localhost_with_tls() { let domain = crate::shared::DomainName::new("api.tracker.local").unwrap(); - let mut config = base_config(); - config.http_api = - test_http_api_config_with_tls("0.0.0.0:1212", "token", Some(domain), true); + let config = TrackerConfig::new( + TrackerCoreConfig::new( + DatabaseConfig::Sqlite(SqliteConfig::new("tracker.db").unwrap()), + false, + ), + vec![], + vec![], + test_http_api_config_with_tls("0.0.0.0:1212", "token", Some(domain), true), + test_health_check_api_config("127.0.0.1:1313"), + ) + .expect("valid config"); - assert!(config.validate().is_ok()); + assert!(config.http_api().use_tls_proxy()); } - - // NOTE: The test it_should_provide_clear_error_message_for_localhost_with_tls - // has been removed because HttpApiConfig::new() now validates this invariant - // at construction time. See http_api.rs for error message tests. } } diff --git a/src/domain/tracker/config/udp.rs b/src/domain/tracker/config/udp.rs index 1bdadefc..f4c86e24 100644 --- a/src/domain/tracker/config/udp.rs +++ b/src/domain/tracker/config/udp.rs @@ -1,20 +1,132 @@ //! UDP tracker configuration +//! +//! This module implements the **DDD validated constructor pattern** for UDP tracker +//! configuration. The pattern ensures that UDP tracker configs are always valid +//! after construction. +//! +//! ## Pattern Overview +//! +//! 1. **Private fields**: All fields are private to prevent bypassing validation +//! 2. **Validated constructor**: `new()` validates all invariants before creation +//! 3. **Getter methods**: Provide read-only access to field values +//! 4. **Domain error type**: Rich error enum for validation failures +//! 5. **Serde with validation**: Deserialization goes through the constructor +//! +//! ## Example +//! +//! ```rust +//! use torrust_tracker_deployer_lib::domain::tracker::UdpTrackerConfig; +//! use torrust_tracker_deployer_lib::shared::DomainName; +//! +//! // Valid configuration - succeeds +//! let config = UdpTrackerConfig::new( +//! "0.0.0.0:6969".parse().unwrap(), +//! None, +//! ).expect("valid config"); +//! +//! // Invalid: port 0 - fails at construction +//! let result = UdpTrackerConfig::new( +//! "0.0.0.0:0".parse().unwrap(), +//! None, +//! ); +//! assert!(result.is_err()); +//! ``` +//! +//! ## Reference Implementation +//! +//! See `http_api.rs` for the original reference implementation of this pattern. +use std::fmt; use std::net::SocketAddr; use serde::{Deserialize, Serialize}; +use thiserror::Error; use crate::shared::DomainName; -/// UDP tracker bind configuration -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +/// Errors that can occur when creating a `UdpTrackerConfig` +/// +/// These errors represent domain invariant violations. Each variant provides +/// context about what went wrong and enables the application layer to convert +/// to user-friendly error messages. +#[derive(Debug, Clone, PartialEq, Error)] +pub enum UdpTrackerConfigError { + /// Dynamic port assignment (port 0) is not supported + /// + /// Port 0 tells the OS to assign a random available port, which is not + /// suitable for deployment configuration where ports must be known. + #[error("dynamic port (0) is not supported for UDP tracker bind address '{0}'")] + DynamicPortNotSupported(SocketAddr), +} + +impl UdpTrackerConfigError { + /// Provides detailed troubleshooting guidance for this error + /// + /// This method follows the project's tiered help system pattern, + /// providing actionable guidance for resolving configuration issues. + #[must_use] + pub fn help(&self) -> &'static str { + match self { + Self::DynamicPortNotSupported(_) => { + "Dynamic port assignment (port 0) is not supported.\n\ + \n\ + Why: Port 0 tells the operating system to assign a random available port.\n\ + This is not suitable for deployment where ports must be known in advance\n\ + for firewall rules, load balancers, and client configuration.\n\ + \n\ + Fix: Specify an explicit port number (e.g., 6969, 6868, 6881).\n\ + \n\ + Example: \"bind_address\": \"0.0.0.0:6969\"" + } + } + } +} + +/// Internal struct for serde deserialization that bypasses validation +/// +/// This allows us to deserialize JSON into the raw fields, then validate +/// through the constructor. This pattern ensures that even +/// deserialized configs are validated. +#[derive(Deserialize)] +struct UdpTrackerConfigRaw { + #[serde(deserialize_with = "crate::domain::tracker::config::deserialize_socket_addr")] + bind_address: SocketAddr, + #[serde(default)] + domain: Option, +} + +/// UDP tracker bind configuration with domain invariants enforced at construction +/// +/// This type guarantees that any instance is valid according to domain rules: +/// - Bind address has a non-zero port +/// +/// Note: Unlike HTTP trackers, UDP does not support TLS, so there are no +/// TLS-related validation rules. +/// +/// # Construction +/// +/// Use `UdpTrackerConfig::new()` to create instances with validation: +/// +/// ```rust +/// use torrust_tracker_deployer_lib::domain::tracker::UdpTrackerConfig; +/// +/// let config = UdpTrackerConfig::new( +/// "0.0.0.0:6969".parse().unwrap(), +/// None, +/// )?; +/// # Ok::<(), Box>(()) +/// ``` +/// +/// # Invariants +/// +/// The following invariants are enforced at construction time: +/// +/// 1. **No dynamic ports**: `bind_address.port() != 0` +#[derive(Debug, Clone, Serialize, PartialEq)] pub struct UdpTrackerConfig { /// Bind address (e.g., "0.0.0.0:6868") - #[serde( - serialize_with = "crate::domain::tracker::config::serialize_socket_addr", - deserialize_with = "crate::domain::tracker::config::deserialize_socket_addr" - )] - pub bind_address: SocketAddr, + #[serde(serialize_with = "crate::domain::tracker::config::serialize_socket_addr")] + bind_address: SocketAddr, /// Domain name for announce URLs (optional) /// @@ -24,50 +136,171 @@ pub struct UdpTrackerConfig { /// Note: Unlike HTTP trackers, UDP does not support TLS, so there is no /// `use_tls_proxy` field for UDP trackers. #[serde(default, skip_serializing_if = "Option::is_none")] - pub domain: Option, + domain: Option, +} + +impl UdpTrackerConfig { + /// Creates a new UDP tracker configuration with validation + /// + /// This is the primary way to construct a `UdpTrackerConfig`. All domain + /// invariants are validated before the instance is created. + /// + /// # Arguments + /// + /// * `bind_address` - Socket address to bind to (e.g., "0.0.0.0:6969") + /// * `domain` - Optional domain for announce URLs + /// + /// # Errors + /// + /// Returns `UdpTrackerConfigError` if any invariant is violated: + /// + /// - `DynamicPortNotSupported` - if port is 0 + /// + /// # Examples + /// + /// ```rust + /// use torrust_tracker_deployer_lib::domain::tracker::UdpTrackerConfig; + /// use torrust_tracker_deployer_lib::shared::DomainName; + /// + /// // Basic configuration without domain + /// let config = UdpTrackerConfig::new( + /// "0.0.0.0:6969".parse().unwrap(), + /// None, + /// )?; + /// + /// // Configuration with domain + /// let config_with_domain = UdpTrackerConfig::new( + /// "0.0.0.0:6969".parse().unwrap(), + /// Some(DomainName::new("tracker.example.com")?), + /// )?; + /// # Ok::<(), Box>(()) + /// ``` + pub fn new( + bind_address: SocketAddr, + domain: Option, + ) -> Result { + // Invariant 1: Port 0 (dynamic assignment) is not supported + if bind_address.port() == 0 { + return Err(UdpTrackerConfigError::DynamicPortNotSupported(bind_address)); + } + + Ok(Self { + bind_address, + domain, + }) + } + + // ------------------------------------------------------------------------- + // Getter methods - provide read-only access to fields + // ------------------------------------------------------------------------- + + /// Returns the bind address + #[must_use] + pub fn bind_address(&self) -> SocketAddr { + self.bind_address + } + + /// Returns a reference to the domain, if configured + #[must_use] + pub fn domain(&self) -> Option<&DomainName> { + self.domain.as_ref() + } +} + +/// Enables deserialization with validation through the constructor +/// +/// This ensures that JSON deserialization also validates the config, +/// maintaining the "always valid" invariant even for loaded data. +impl<'de> Deserialize<'de> for UdpTrackerConfig { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let raw = UdpTrackerConfigRaw::deserialize(deserializer)?; + Self::new(raw.bind_address, raw.domain).map_err(serde::de::Error::custom) + } +} + +impl fmt::Display for UdpTrackerConfig { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "UDP tracker at {}", self.bind_address)?; + if let Some(domain) = &self.domain { + write!(f, " ({})", domain.as_str())?; + } + Ok(()) + } } #[cfg(test)] mod tests { use super::*; + // ========================================================================= + // Valid construction tests + // ========================================================================= + #[test] fn it_should_create_udp_tracker_config_without_domain() { - let config = UdpTrackerConfig { - bind_address: "0.0.0.0:6868".parse().unwrap(), - domain: None, - }; + let config = UdpTrackerConfig::new("0.0.0.0:6868".parse().unwrap(), None) + .expect("valid config should succeed"); assert_eq!( - config.bind_address, + config.bind_address(), "0.0.0.0:6868".parse::().unwrap() ); - assert!(config.domain.is_none()); + assert!(config.domain().is_none()); } #[test] fn it_should_create_udp_tracker_config_with_domain() { - let config = UdpTrackerConfig { - bind_address: "0.0.0.0:6969".parse().unwrap(), - domain: Some(DomainName::new("tracker.example.com").unwrap()), - }; + let domain = DomainName::new("tracker.example.com").unwrap(); + let config = UdpTrackerConfig::new("0.0.0.0:6969".parse().unwrap(), Some(domain)) + .expect("valid config should succeed"); assert_eq!( - config.bind_address, + config.bind_address(), "0.0.0.0:6969".parse::().unwrap() ); assert_eq!( - config.domain.as_ref().map(DomainName::as_str), + config.domain().map(DomainName::as_str), Some("tracker.example.com") ); } + // ========================================================================= + // Invariant violation tests + // ========================================================================= + + #[test] + fn it_should_reject_port_zero() { + let result = UdpTrackerConfig::new("0.0.0.0:0".parse().unwrap(), None); + + assert!(result.is_err()); + let err = result.unwrap_err(); + assert!(matches!( + err, + UdpTrackerConfigError::DynamicPortNotSupported(_) + )); + assert!(err.to_string().contains("dynamic port")); + } + + #[test] + fn it_should_provide_help_text_for_port_zero_error() { + let err = UdpTrackerConfigError::DynamicPortNotSupported("0.0.0.0:0".parse().unwrap()); + + let help = err.help(); + assert!(help.contains("Dynamic port assignment")); + assert!(help.contains("Fix:")); + assert!(help.contains("6969")); + } + + // ========================================================================= + // Serialization tests + // ========================================================================= + #[test] fn it_should_serialize_udp_tracker_config_without_domain() { - let config = UdpTrackerConfig { - bind_address: "0.0.0.0:6969".parse().unwrap(), - domain: None, - }; + let config = UdpTrackerConfig::new("0.0.0.0:6969".parse().unwrap(), None).unwrap(); let json = serde_json::to_value(&config).unwrap(); assert_eq!(json["bind_address"], "0.0.0.0:6969"); @@ -77,26 +310,28 @@ mod tests { #[test] fn it_should_serialize_udp_tracker_config_with_domain() { - let config = UdpTrackerConfig { - bind_address: "0.0.0.0:6969".parse().unwrap(), - domain: Some(DomainName::new("udp.tracker.local").unwrap()), - }; + let domain = DomainName::new("udp.tracker.local").unwrap(); + let config = UdpTrackerConfig::new("0.0.0.0:6969".parse().unwrap(), Some(domain)).unwrap(); let json = serde_json::to_value(&config).unwrap(); assert_eq!(json["bind_address"], "0.0.0.0:6969"); assert_eq!(json["domain"], "udp.tracker.local"); } + // ========================================================================= + // Deserialization tests + // ========================================================================= + #[test] fn it_should_deserialize_udp_tracker_config_without_domain() { let json = r#"{"bind_address": "0.0.0.0:6969"}"#; let config: UdpTrackerConfig = serde_json::from_str(json).unwrap(); assert_eq!( - config.bind_address, + config.bind_address(), "0.0.0.0:6969".parse::().unwrap() ); - assert!(config.domain.is_none()); + assert!(config.domain().is_none()); } #[test] @@ -105,12 +340,60 @@ mod tests { let config: UdpTrackerConfig = serde_json::from_str(json).unwrap(); assert_eq!( - config.bind_address, + config.bind_address(), "0.0.0.0:6969".parse::().unwrap() ); assert_eq!( - config.domain.as_ref().map(DomainName::as_str), + config.domain().map(DomainName::as_str), Some("udp.tracker.local") ); } + + #[test] + fn it_should_reject_port_zero_during_deserialization() { + let json = r#"{"bind_address": "0.0.0.0:0"}"#; + let result: Result = serde_json::from_str(json); + + assert!(result.is_err()); + let err_msg = result.unwrap_err().to_string(); + assert!(err_msg.contains("dynamic port")); + } + + // ========================================================================= + // Display tests + // ========================================================================= + + #[test] + fn it_should_display_without_domain() { + let config = UdpTrackerConfig::new("0.0.0.0:6969".parse().unwrap(), None).unwrap(); + + assert_eq!(config.to_string(), "UDP tracker at 0.0.0.0:6969"); + } + + #[test] + fn it_should_display_with_domain() { + let domain = DomainName::new("tracker.example.com").unwrap(); + let config = UdpTrackerConfig::new("0.0.0.0:6969".parse().unwrap(), Some(domain)).unwrap(); + + assert_eq!( + config.to_string(), + "UDP tracker at 0.0.0.0:6969 (tracker.example.com)" + ); + } + + // ========================================================================= + // Round-trip tests + // ========================================================================= + + #[test] + fn it_should_round_trip_through_json() { + let domain = DomainName::new("tracker.example.com").unwrap(); + let original = + UdpTrackerConfig::new("0.0.0.0:6969".parse().unwrap(), Some(domain)).unwrap(); + + let json = serde_json::to_string(&original).unwrap(); + let restored: UdpTrackerConfig = serde_json::from_str(&json).unwrap(); + + assert_eq!(original, restored); + } } diff --git a/src/domain/tracker/mod.rs b/src/domain/tracker/mod.rs index 90dc006a..81154d47 100644 --- a/src/domain/tracker/mod.rs +++ b/src/domain/tracker/mod.rs @@ -23,31 +23,29 @@ //! UdpTrackerConfig, HttpTrackerConfig, HttpApiConfig, HealthCheckApiConfig //! }; //! -//! let config = TrackerConfig { -//! core: TrackerCoreConfig { -//! database: DatabaseConfig::Sqlite(SqliteConfig { -//! database_name: "tracker.db".to_string(), -//! }), -//! private: false, -//! }, -//! udp_trackers: vec![ -//! UdpTrackerConfig { bind_address: "0.0.0.0:6868".parse().unwrap(), domain: None }, +//! let config = TrackerConfig::new( +//! TrackerCoreConfig::new( +//! DatabaseConfig::Sqlite(SqliteConfig::new("tracker.db").unwrap()), +//! false, +//! ), +//! vec![ +//! UdpTrackerConfig::new("0.0.0.0:6868".parse().unwrap(), None).unwrap(), //! ], -//! http_trackers: vec![ -//! HttpTrackerConfig { bind_address: "0.0.0.0:7070".parse().unwrap(), domain: None, use_tls_proxy: false }, +//! vec![ +//! HttpTrackerConfig::new("0.0.0.0:7070".parse().unwrap(), None, false).unwrap(), //! ], -//! http_api: HttpApiConfig::new( +//! HttpApiConfig::new( //! "0.0.0.0:1212".parse().unwrap(), //! "MyToken".to_string().into(), //! None, //! false, //! ).expect("valid config"), -//! health_check_api: HealthCheckApiConfig { -//! bind_address: "127.0.0.1:1313".parse().unwrap(), -//! domain: None, -//! use_tls_proxy: false, -//! }, -//! }; +//! HealthCheckApiConfig::new( +//! "127.0.0.1:1313".parse().unwrap(), +//! None, +//! false, +//! ).expect("valid config"), +//! ).expect("valid tracker config"); //! ``` mod binding_address; @@ -56,8 +54,9 @@ mod protocol; pub use binding_address::BindingAddress; pub use config::{ - is_localhost, DatabaseConfig, HealthCheckApiConfig, HttpApiConfig, HttpApiConfigError, - HttpTrackerConfig, MysqlConfig, SqliteConfig, TrackerConfig, TrackerConfigError, - TrackerCoreConfig, UdpTrackerConfig, + is_localhost, DatabaseConfig, HealthCheckApiConfig, HealthCheckApiConfigError, HttpApiConfig, + HttpApiConfigError, HttpTrackerConfig, HttpTrackerConfigError, MysqlConfig, MysqlConfigError, + SqliteConfig, SqliteConfigError, TrackerConfig, TrackerConfigError, TrackerCoreConfig, + UdpTrackerConfig, UdpTrackerConfigError, }; pub use protocol::{Protocol, ProtocolParseError}; diff --git a/src/infrastructure/templating/ansible/template/wrappers/variables/context.rs b/src/infrastructure/templating/ansible/template/wrappers/variables/context.rs index 0faf86d9..5fb4cb83 100644 --- a/src/infrastructure/templating/ansible/template/wrappers/variables/context.rs +++ b/src/infrastructure/templating/ansible/template/wrappers/variables/context.rs @@ -78,16 +78,16 @@ impl AnsibleVariablesContext { // Extract UDP tracker ports let udp_ports: Vec = config - .udp_trackers + .udp_trackers() .iter() - .map(|tracker| Self::extract_port(&tracker.bind_address)) + .map(|tracker| Self::extract_port(&tracker.bind_address())) .collect(); // Extract HTTP tracker ports let http_ports: Vec = config - .http_trackers + .http_trackers() .iter() - .map(|tracker| Self::extract_port(&tracker.bind_address)) + .map(|tracker| Self::extract_port(&tracker.bind_address())) .collect(); // Extract HTTP API port (hardcoded to 1212 for now - can be made configurable later) @@ -188,41 +188,30 @@ mod tests { TrackerCoreConfig, UdpTrackerConfig, }; - let tracker_config = TrackerConfig { - core: TrackerCoreConfig { - database: DatabaseConfig::Sqlite(SqliteConfig { - database_name: "tracker.db".to_string(), - }), - private: false, - }, - udp_trackers: vec![ - UdpTrackerConfig { - bind_address: "0.0.0.0:6868".parse().unwrap(), - domain: None, - }, - UdpTrackerConfig { - bind_address: "0.0.0.0:6969".parse().unwrap(), - domain: None, - }, + let tracker_config = TrackerConfig::new( + TrackerCoreConfig::new( + DatabaseConfig::Sqlite(SqliteConfig::new("tracker.db").unwrap()), + false, + ), + vec![ + UdpTrackerConfig::new("0.0.0.0:6868".parse().unwrap(), None).expect("valid config"), + UdpTrackerConfig::new("0.0.0.0:6969".parse().unwrap(), None).expect("valid config"), + ], + vec![ + HttpTrackerConfig::new("0.0.0.0:7070".parse().unwrap(), None, false) + .expect("valid config"), ], - http_trackers: vec![HttpTrackerConfig { - bind_address: "0.0.0.0:7070".parse().unwrap(), - domain: None, - use_tls_proxy: false, - }], - http_api: HttpApiConfig::new( + HttpApiConfig::new( "0.0.0.0:1212".parse().unwrap(), "MyAccessToken".to_string().into(), None, false, ) .expect("valid config"), - health_check_api: HealthCheckApiConfig { - bind_address: "127.0.0.1:1313".parse().unwrap(), - domain: None, - use_tls_proxy: false, - }, - }; + HealthCheckApiConfig::new("127.0.0.1:1313".parse().unwrap(), None, false) + .expect("valid config"), + ) + .expect("valid tracker config"); let context = AnsibleVariablesContext::new(22, Some(&tracker_config), None).unwrap(); @@ -237,28 +226,24 @@ mod tests { DatabaseConfig, HealthCheckApiConfig, HttpApiConfig, SqliteConfig, TrackerCoreConfig, }; - let tracker_config = TrackerConfig { - core: TrackerCoreConfig { - database: DatabaseConfig::Sqlite(SqliteConfig { - database_name: "tracker.db".to_string(), - }), - private: true, - }, - udp_trackers: vec![], - http_trackers: vec![], - http_api: HttpApiConfig::new( + let tracker_config = TrackerConfig::new( + TrackerCoreConfig::new( + DatabaseConfig::Sqlite(SqliteConfig::new("tracker.db").unwrap()), + true, + ), + vec![], + vec![], + HttpApiConfig::new( "0.0.0.0:1212".parse().unwrap(), "Token123".to_string().into(), None, false, ) .expect("valid config"), - health_check_api: HealthCheckApiConfig { - bind_address: "127.0.0.1:1313".parse().unwrap(), - domain: None, - use_tls_proxy: false, - }, - }; + HealthCheckApiConfig::new("127.0.0.1:1313".parse().unwrap(), None, false) + .expect("valid config"), + ) + .expect("valid tracker config"); let context = AnsibleVariablesContext::new(22, Some(&tracker_config), None).unwrap(); @@ -274,41 +259,30 @@ mod tests { TrackerCoreConfig, UdpTrackerConfig, }; - let tracker_config = TrackerConfig { - core: TrackerCoreConfig { - database: DatabaseConfig::Sqlite(SqliteConfig { - database_name: "tracker.db".to_string(), - }), - private: false, - }, - udp_trackers: vec![ - UdpTrackerConfig { - bind_address: "0.0.0.0:6868".parse().unwrap(), // Valid address - domain: None, - }, - UdpTrackerConfig { - bind_address: "0.0.0.0:6969".parse().unwrap(), // Valid address - domain: None, - }, + let tracker_config = TrackerConfig::new( + TrackerCoreConfig::new( + DatabaseConfig::Sqlite(SqliteConfig::new("tracker.db").unwrap()), + false, + ), + vec![ + UdpTrackerConfig::new("0.0.0.0:6868".parse().unwrap(), None).expect("valid config"), + UdpTrackerConfig::new("0.0.0.0:6969".parse().unwrap(), None).expect("valid config"), + ], + vec![ + HttpTrackerConfig::new("0.0.0.0:7070".parse().unwrap(), None, false) + .expect("valid config"), ], - http_trackers: vec![HttpTrackerConfig { - bind_address: "0.0.0.0:7070".parse().unwrap(), // Valid address - domain: None, - use_tls_proxy: false, - }], - http_api: HttpApiConfig::new( + HttpApiConfig::new( "0.0.0.0:1212".parse().unwrap(), "Token".to_string().into(), None, false, ) .expect("valid config"), - health_check_api: HealthCheckApiConfig { - bind_address: "127.0.0.1:1313".parse().unwrap(), - domain: None, - use_tls_proxy: false, - }, - }; + HealthCheckApiConfig::new("127.0.0.1:1313".parse().unwrap(), None, false) + .expect("valid config"), + ) + .expect("valid tracker config"); let context = AnsibleVariablesContext::new(22, Some(&tracker_config), None).unwrap(); diff --git a/src/infrastructure/templating/prometheus/template/renderer/project_generator.rs b/src/infrastructure/templating/prometheus/template/renderer/project_generator.rs index d4c6a9e0..6608465f 100644 --- a/src/infrastructure/templating/prometheus/template/renderer/project_generator.rs +++ b/src/infrastructure/templating/prometheus/template/renderer/project_generator.rs @@ -154,13 +154,13 @@ impl PrometheusProjectGenerator { ) -> PrometheusContext { let scrape_interval = prometheus_config.scrape_interval_in_secs().to_string(); let api_token = tracker_config - .http_api + .http_api() .admin_token() .expose_secret() .to_string(); // Extract port from SocketAddr - let api_port = tracker_config.http_api.bind_address().port(); + let api_port = tracker_config.http_api().bind_address().port(); PrometheusContext::new(scrape_interval, api_token, api_port) } @@ -212,16 +212,28 @@ scrape_configs: bind_address: &str, admin_token: &str, ) -> TrackerConfig { - TrackerConfig { - http_api: HttpApiConfig::new( + use crate::domain::tracker::{ + DatabaseConfig, HealthCheckApiConfig, SqliteConfig, TrackerCoreConfig, + }; + + TrackerConfig::new( + TrackerCoreConfig::new( + DatabaseConfig::Sqlite(SqliteConfig::new("tracker.db").unwrap()), + false, + ), + vec![], + vec![], + HttpApiConfig::new( bind_address.parse().expect("valid address"), admin_token.to_string().into(), None, false, ) .expect("valid config"), - ..Default::default() - } + HealthCheckApiConfig::new("127.0.0.1:1313".parse().unwrap(), None, false) + .expect("valid config"), + ) + .expect("valid tracker config") } #[test] diff --git a/src/infrastructure/templating/tracker/template/renderer/project_generator.rs b/src/infrastructure/templating/tracker/template/renderer/project_generator.rs index 41df7ce6..89cc1015 100644 --- a/src/infrastructure/templating/tracker/template/renderer/project_generator.rs +++ b/src/infrastructure/templating/tracker/template/renderer/project_generator.rs @@ -212,35 +212,29 @@ mod tests { let template_manager = create_test_template_manager(); let generator = TrackerProjectGenerator::new(&build_dir, template_manager); - let tracker_config = TrackerConfig { - core: TrackerCoreConfig { - database: DatabaseConfig::Sqlite(SqliteConfig { - database_name: "tracker.db".to_string(), - }), - private: false, - }, - udp_trackers: vec![UdpTrackerConfig { - bind_address: "0.0.0.0:6969".parse().unwrap(), - domain: None, - }], - http_trackers: vec![HttpTrackerConfig { - bind_address: "0.0.0.0:7070".parse().unwrap(), - domain: None, - use_tls_proxy: false, - }], - http_api: HttpApiConfig::new( + let tracker_config = TrackerConfig::new( + TrackerCoreConfig::new( + DatabaseConfig::Sqlite(SqliteConfig::new("tracker.db").unwrap()), + false, + ), + vec![ + UdpTrackerConfig::new("0.0.0.0:6969".parse().unwrap(), None).expect("valid config") + ], + vec![ + HttpTrackerConfig::new("0.0.0.0:7070".parse().unwrap(), None, false) + .expect("valid config"), + ], + HttpApiConfig::new( "0.0.0.0:1212".parse().unwrap(), "test_token".to_string().into(), None, false, ) .expect("valid config"), - health_check_api: HealthCheckApiConfig { - bind_address: "127.0.0.1:1313".parse().unwrap(), - domain: None, - use_tls_proxy: false, - }, - }; + HealthCheckApiConfig::new("127.0.0.1:1313".parse().unwrap(), None, false) + .expect("valid config"), + ) + .expect("valid tracker config"); generator .render(Some(&tracker_config)) @@ -266,39 +260,38 @@ mod tests { let template_manager = create_test_template_manager(); let generator = TrackerProjectGenerator::new(&build_dir, template_manager); - let tracker_config = TrackerConfig { - core: TrackerCoreConfig { - database: DatabaseConfig::Mysql(MysqlConfig { - host: "mysql".to_string(), - port: 3306, - database_name: "tracker_db".to_string(), - username: "tracker_user".to_string(), - password: Password::from("secure_pass"), - }), - private: false, - }, - udp_trackers: vec![UdpTrackerConfig { - bind_address: "0.0.0.0:6969".parse().unwrap(), - domain: None, - }], - http_trackers: vec![HttpTrackerConfig { - bind_address: "0.0.0.0:7070".parse().unwrap(), - domain: None, - use_tls_proxy: false, - }], - http_api: HttpApiConfig::new( + let tracker_config = TrackerConfig::new( + TrackerCoreConfig::new( + DatabaseConfig::Mysql( + MysqlConfig::new( + "mysql", + 3306, + "tracker_db", + "tracker_user", + Password::from("secure_pass"), + ) + .unwrap(), + ), + false, + ), + vec![ + UdpTrackerConfig::new("0.0.0.0:6969".parse().unwrap(), None).expect("valid config") + ], + vec![ + HttpTrackerConfig::new("0.0.0.0:7070".parse().unwrap(), None, false) + .expect("valid config"), + ], + HttpApiConfig::new( "0.0.0.0:1212".parse().unwrap(), "test_token".to_string().into(), None, false, ) .expect("valid config"), - health_check_api: HealthCheckApiConfig { - bind_address: "127.0.0.1:1313".parse().unwrap(), - domain: None, - use_tls_proxy: false, - }, - }; + HealthCheckApiConfig::new("127.0.0.1:1313".parse().unwrap(), None, false) + .expect("valid config"), + ) + .expect("valid tracker config"); generator .render(Some(&tracker_config)) diff --git a/src/infrastructure/templating/tracker/template/wrapper/tracker_config/context.rs b/src/infrastructure/templating/tracker/template/wrapper/tracker_config/context.rs index 21254894..cfad0784 100644 --- a/src/infrastructure/templating/tracker/template/wrapper/tracker_config/context.rs +++ b/src/infrastructure/templating/tracker/template/wrapper/tracker_config/context.rs @@ -26,32 +26,30 @@ use crate::domain::environment::TrackerConfig; /// use torrust_tracker_deployer_lib::infrastructure::templating::tracker::TrackerContext; /// use torrust_tracker_deployer_lib::domain::environment::{TrackerConfig, TrackerCoreConfig, DatabaseConfig, SqliteConfig, UdpTrackerConfig, HttpTrackerConfig, HttpApiConfig, HealthCheckApiConfig}; /// -/// let tracker_config = TrackerConfig { -/// core: TrackerCoreConfig { -/// database: DatabaseConfig::Sqlite(SqliteConfig { -/// database_name: "tracker.db".to_string(), -/// }), -/// private: true, -/// }, -/// udp_trackers: vec![ -/// UdpTrackerConfig { bind_address: "0.0.0.0:6868".parse().unwrap(), domain: None }, -/// UdpTrackerConfig { bind_address: "0.0.0.0:6969".parse().unwrap(), domain: None }, +/// let tracker_config = TrackerConfig::new( +/// TrackerCoreConfig::new( +/// DatabaseConfig::Sqlite(SqliteConfig::new("tracker.db").unwrap()), +/// true, +/// ), +/// vec![ +/// UdpTrackerConfig::new("0.0.0.0:6868".parse().unwrap(), None).expect("valid config"), +/// UdpTrackerConfig::new("0.0.0.0:6969".parse().unwrap(), None).expect("valid config"), /// ], -/// http_trackers: vec![ -/// HttpTrackerConfig { bind_address: "0.0.0.0:7070".parse().unwrap(), domain: None, use_tls_proxy: false }, +/// vec![ +/// HttpTrackerConfig::new("0.0.0.0:7070".parse().unwrap(), None, false).expect("valid config"), /// ], -/// http_api: HttpApiConfig::new( +/// HttpApiConfig::new( /// "0.0.0.0:1212".parse().unwrap(), /// "MyToken".to_string().into(), /// None, /// false, /// ).expect("valid config"), -/// health_check_api: HealthCheckApiConfig { -/// bind_address: "127.0.0.1:1313".parse().unwrap(), -/// domain: None, -/// use_tls_proxy: false, -/// }, -/// }; +/// HealthCheckApiConfig::new( +/// "127.0.0.1:1313".parse().unwrap(), +/// None, +/// false, +/// ).expect("valid config"), +/// ).expect("valid tracker config"); /// let context = TrackerContext::from_config(&tracker_config); /// ``` #[derive(Debug, Clone, Serialize)] @@ -131,43 +129,43 @@ impl TrackerContext { use crate::domain::tracker::DatabaseConfig; let (mysql_host, mysql_port, mysql_database, mysql_user, mysql_password) = - match &config.core.database { + match config.core().database() { DatabaseConfig::Mysql(mysql_config) => ( - Some(mysql_config.host.clone()), - Some(mysql_config.port), - Some(mysql_config.database_name.clone()), - Some(mysql_config.username.clone()), - Some(mysql_config.password.expose_secret().to_string()), + Some(mysql_config.host().to_string()), + Some(mysql_config.port()), + Some(mysql_config.database_name().to_string()), + Some(mysql_config.username().to_string()), + Some(mysql_config.password().expose_secret().to_string()), ), DatabaseConfig::Sqlite(..) => (None, None, None, None, None), }; Self { - database_driver: config.core.database.driver_name().to_string(), - tracker_database_name: config.core.database.database_name().to_string(), + database_driver: config.core().database().driver_name().to_string(), + tracker_database_name: config.core().database().database_name().to_string(), mysql_host, mysql_port, mysql_database, mysql_user, mysql_password, - tracker_core_private: config.core.private, + tracker_core_private: config.core().private(), on_reverse_proxy: config.any_http_tracker_uses_tls_proxy(), udp_trackers: config - .udp_trackers + .udp_trackers() .iter() .map(|t| UdpTrackerEntry { - bind_address: t.bind_address.to_string(), + bind_address: t.bind_address().to_string(), }) .collect(), http_trackers: config - .http_trackers + .http_trackers() .iter() .map(|t| HttpTrackerEntry { - bind_address: t.bind_address.to_string(), + bind_address: t.bind_address().to_string(), }) .collect(), - http_api_bind_address: config.http_api.bind_address().to_string(), - health_check_api_bind_address: config.health_check_api.bind_address.to_string(), + http_api_bind_address: config.http_api().bind_address().to_string(), + health_check_api_bind_address: config.health_check_api().bind_address().to_string(), } } @@ -224,41 +222,30 @@ mod tests { use crate::shared::Password; fn create_test_tracker_config() -> TrackerConfig { - TrackerConfig { - core: TrackerCoreConfig { - database: DatabaseConfig::Sqlite(SqliteConfig { - database_name: "test_tracker.db".to_string(), - }), - private: true, - }, - udp_trackers: vec![ - UdpTrackerConfig { - bind_address: "0.0.0.0:6868".parse().unwrap(), - domain: None, - }, - UdpTrackerConfig { - bind_address: "0.0.0.0:6969".parse().unwrap(), - domain: None, - }, + TrackerConfig::new( + TrackerCoreConfig::new( + DatabaseConfig::Sqlite(SqliteConfig::new("test_tracker.db").unwrap()), + true, + ), + vec![ + UdpTrackerConfig::new("0.0.0.0:6868".parse().unwrap(), None).expect("valid config"), + UdpTrackerConfig::new("0.0.0.0:6969".parse().unwrap(), None).expect("valid config"), ], - http_trackers: vec![HttpTrackerConfig { - bind_address: "0.0.0.0:7070".parse().unwrap(), - domain: None, - use_tls_proxy: false, - }], - http_api: HttpApiConfig::new( + vec![ + HttpTrackerConfig::new("0.0.0.0:7070".parse().unwrap(), None, false) + .expect("valid config"), + ], + HttpApiConfig::new( "0.0.0.0:1212".parse().unwrap(), "test_admin_token".to_string().into(), None, false, ) .expect("valid config"), - health_check_api: HealthCheckApiConfig { - bind_address: "127.0.0.1:1313".parse().unwrap(), - domain: None, - use_tls_proxy: false, - }, - } + HealthCheckApiConfig::new("127.0.0.1:1313".parse().unwrap(), None, false) + .expect("valid config"), + ) + .expect("valid tracker config") } #[test] @@ -283,39 +270,38 @@ mod tests { #[test] fn it_should_create_context_from_mysql_tracker_config() { - let config = TrackerConfig { - core: TrackerCoreConfig { - database: DatabaseConfig::Mysql(MysqlConfig { - host: "mysql".to_string(), - port: 3306, - database_name: "tracker_db".to_string(), - username: "tracker_user".to_string(), - password: Password::from("secure_pass"), - }), - private: false, - }, - udp_trackers: vec![UdpTrackerConfig { - bind_address: "0.0.0.0:6969".parse().unwrap(), - domain: None, - }], - http_trackers: vec![HttpTrackerConfig { - bind_address: "0.0.0.0:7070".parse().unwrap(), - domain: None, - use_tls_proxy: false, - }], - http_api: HttpApiConfig::new( + let config = TrackerConfig::new( + TrackerCoreConfig::new( + DatabaseConfig::Mysql( + MysqlConfig::new( + "mysql", + 3306, + "tracker_db", + "tracker_user", + Password::from("secure_pass"), + ) + .unwrap(), + ), + false, + ), + vec![ + UdpTrackerConfig::new("0.0.0.0:6969".parse().unwrap(), None).expect("valid config") + ], + vec![ + HttpTrackerConfig::new("0.0.0.0:7070".parse().unwrap(), None, false) + .expect("valid config"), + ], + HttpApiConfig::new( "0.0.0.0:1212".parse().unwrap(), "test_token".to_string().into(), None, false, ) .expect("valid config"), - health_check_api: HealthCheckApiConfig { - bind_address: "127.0.0.1:1313".parse().unwrap(), - domain: None, - use_tls_proxy: false, - }, - }; + HealthCheckApiConfig::new("127.0.0.1:1313".parse().unwrap(), None, false) + .expect("valid config"), + ) + .expect("valid tracker config"); let context = TrackerContext::from_config(&config); diff --git a/src/presentation/controllers/create/subcommands/environment/config_loader.rs b/src/presentation/controllers/create/subcommands/environment/config_loader.rs index 0adcad1a..c9e1822f 100644 --- a/src/presentation/controllers/create/subcommands/environment/config_loader.rs +++ b/src/presentation/controllers/create/subcommands/environment/config_loader.rs @@ -4,6 +4,7 @@ //! Figment is used only in the presentation layer as a delivery mechanism, //! following DDD architecture boundaries. +use std::convert::TryInto; use std::path::Path; use figment::{ @@ -12,6 +13,7 @@ use figment::{ }; use crate::application::command_handlers::create::config::EnvironmentCreationConfig; +use crate::domain::environment::EnvironmentParams; use super::errors::{ConfigFormat, CreateEnvironmentCommandError}; @@ -90,9 +92,9 @@ impl ConfigLoader { // Step 3: Validate using domain rules // This converts string-based config to domain types and validates - config + let _validated: EnvironmentParams = config .clone() - .to_environment_params() + .try_into() .map_err(|source| CreateEnvironmentCommandError::ConfigValidationFailed { source })?; Ok(config) diff --git a/src/presentation/views/commands/show/environment_info/tracker_services.rs b/src/presentation/views/commands/show/environment_info/tracker_services.rs index c6fd954b..fef270d0 100644 --- a/src/presentation/views/commands/show/environment_info/tracker_services.rs +++ b/src/presentation/views/commands/show/environment_info/tracker_services.rs @@ -28,14 +28,26 @@ impl TrackerServicesView { "Tracker Services:".to_string(), ]; - // UDP Trackers - if !services.udp_trackers.is_empty() { - lines.push(" UDP Trackers:".to_string()); - for url in &services.udp_trackers { - lines.push(format!(" - {url}")); - } + Self::render_udp_trackers(services, &mut lines); + Self::render_http_trackers(services, &mut lines); + Self::render_api_endpoint(services, &mut lines); + Self::render_health_check(services, &mut lines); + + lines + } + + fn render_udp_trackers(services: &ServiceInfo, lines: &mut Vec) { + if services.udp_trackers.is_empty() { + return; } + lines.push(" UDP Trackers:".to_string()); + for url in &services.udp_trackers { + lines.push(format!(" - {url}")); + } + } + + fn render_http_trackers(services: &ServiceInfo, lines: &mut Vec) { // HTTPS-enabled HTTP trackers (via Caddy) if !services.https_http_trackers.is_empty() { lines.push(" HTTP Trackers (HTTPS via Caddy):".to_string()); @@ -62,8 +74,9 @@ impl TrackerServicesView { )); } } + } - // API endpoint with HTTPS indicator and localhost-only marker + fn render_api_endpoint(services: &ServiceInfo, lines: &mut Vec) { if services.api_is_localhost_only { lines.push(" API Endpoint (internal only):".to_string()); lines.push(format!( @@ -77,8 +90,9 @@ impl TrackerServicesView { lines.push(" API Endpoint:".to_string()); lines.push(format!(" - {}", services.api_endpoint)); } + } - // Health check with HTTPS indicator and localhost-only marker + fn render_health_check(services: &ServiceInfo, lines: &mut Vec) { if services.health_check_is_localhost_only { lines.push(" Health Check (internal only):".to_string()); lines.push(format!( @@ -92,8 +106,6 @@ impl TrackerServicesView { lines.push(" Health Check:".to_string()); lines.push(format!(" - {}", services.health_check_url)); } - - lines } }