diff --git a/docs/issues/301-phase-4-service-topology-ddd-alignment.md b/docs/issues/301-phase-4-service-topology-ddd-alignment.md index 558b3007..fac2b754 100644 --- a/docs/issues/301-phase-4-service-topology-ddd-alignment.md +++ b/docs/issues/301-phase-4-service-topology-ddd-alignment.md @@ -28,21 +28,21 @@ These are business rules that should be in the domain layer: ## Goals -- [ ] Move port derivation logic to domain layer using `PortDerivation` trait -- [ ] Move network computation logic to domain `DockerComposeTopologyBuilder` -- [ ] Convert infrastructure context types to pure DTOs (no business logic) -- [ ] Maintain all existing functionality and E2E tests passing +- [x] Move port derivation logic to domain layer using `PortDerivation` trait +- [x] Move network computation logic to domain layer using `NetworkDerivation` trait +- [x] Convert infrastructure context types to pure DTOs (use domain traits, no `compute_*()` methods) +- [x] Maintain all existing functionality and E2E tests passing ## 🏗️ Architecture Requirements **DDD Layer**: Domain (for business logic) + Infrastructure (for DTOs) **Module Paths**: -- `src/domain/topology/traits.rs` - `PortDerivation` trait -- `src/domain/topology/builder.rs` - `DockerComposeTopologyBuilder` +- `src/domain/topology/traits.rs` - `PortDerivation`, `NetworkDerivation` traits +- `src/domain/topology/enabled_services.rs` - `EnabledServices` topology context - `src/domain/topology/fixed_ports.rs` - Caddy/MySQL port functions -**Pattern**: Trait-based port derivation + Builder for topology construction +**Pattern**: Trait-based port and network derivation + `EnabledServices` for topology context ### Design Principles Applied @@ -297,66 +297,119 @@ impl TrackerServiceContext { ## Implementation Plan -### P4.1: Add Trait and Implement in Domain - -- [ ] Create `src/domain/topology/traits.rs` with `PortDerivation` trait -- [ ] Implement `PortDerivation` for `TrackerConfig` -- [ ] Implement `PortDerivation` for `GrafanaConfig` -- [ ] Implement `PortDerivation` for `PrometheusConfig` -- [ ] Create `src/domain/topology/fixed_ports.rs` for Caddy and MySQL -- [ ] Add unit tests for each implementation - -### P4.2: Create Domain Topology Builder - -- [ ] Create `src/domain/topology/builder.rs` with `DockerComposeTopologyBuilder` -- [ ] Move network computation logic from infrastructure to domain builder -- [ ] Wire up port derivation via trait calls -- [ ] Add integration tests - -### P4.3: Refactor Infrastructure to Pure DTOs - -- [ ] Remove `compute_networks()` from `TrackerServiceConfig` -- [ ] Remove `compute_networks()` from `GrafanaServiceConfig` -- [ ] Remove `compute_networks()` from `PrometheusServiceConfig` -- [ ] Rename types to `*Context` (e.g., `TrackerServiceContext`) -- [ ] Update `DockerComposeContextBuilder` to receive domain topology -- [ ] Update tests - -### P4.4: Cleanup - -- [ ] Delete `src/infrastructure/.../context/port_derivation.rs` -- [ ] Remove any remaining business logic from infrastructure -- [ ] Update documentation -- [ ] Run full E2E test suite +> **Approach**: Single PR with incremental commits. Each step is a logical commit point. +> Progress tracked with checkboxes below. + +### Step 1: Create PortDerivation Trait Foundation + +- [x] 1.1 Create `src/domain/topology/traits.rs` with `PortDerivation` trait +- [x] 1.2 Export trait from `src/domain/topology/mod.rs` + +### Step 2: Implement PortDerivation for Prometheus (Simplest) + +- [x] 2.1 Implement `PortDerivation` for `PrometheusConfig` in domain +- [x] 2.2 Add unit tests for Prometheus port derivation +- [x] 2.3 Update infrastructure `PrometheusServiceConfig` to use domain trait +- [x] 2.4 Remove `derive_prometheus_ports()` calls from infrastructure + +### Step 3: Implement PortDerivation for Grafana + +- [x] 3.1 Implement `PortDerivation` for `GrafanaConfig` in domain +- [x] 3.2 Add unit tests for Grafana port derivation +- [x] 3.3 Update infrastructure `GrafanaServiceConfig` to use domain trait +- [x] 3.4 Remove `derive_grafana_ports()` calls from infrastructure + +### Step 4: Implement PortDerivation for Tracker (Most Complex) + +- [x] 4.1 Implement `PortDerivation` for `TrackerConfig` in domain +- [x] 4.2 Add unit tests for Tracker port derivation +- [x] 4.3 Update infrastructure `TrackerServiceConfig` to use domain trait +- [x] 4.4 Remove `derive_tracker_ports()` calls from infrastructure +- [x] 4.5 Remove `TrackerServiceConfig::new()` - all callers migrated to `from_domain_config()` + - Application layer (`docker_compose_templates.rs`) now uses domain `TrackerConfig` directly + - All test code updated with domain config helper functions + - Deleted `port_derivation.rs` entirely + +### Step 5: Fixed Port Services (Caddy, MySQL) + +- [x] 5.1 ~~Create `src/domain/topology/fixed_ports.rs` with `caddy_ports()` and `mysql_ports()`~~ - **Replaced**: Created proper domain types instead +- [x] 5.2 ~~Add unit tests for fixed port functions~~ - **Replaced**: Domain types have their own unit tests +- [x] 5.3 Update infrastructure to use domain fixed port functions +- [x] 5.4 Remove `derive_caddy_ports()` and `derive_mysql_ports()` from infrastructure +- [x] 5.5 Create `src/domain/caddy/config.rs` with `CaddyConfig` implementing `PortDerivation` and `NetworkDerivation` +- [x] 5.6 Create `src/domain/mysql/config.rs` with `MysqlServiceConfig` implementing `PortDerivation` and `NetworkDerivation` +- [x] 5.7 Delete `src/domain/topology/fixed_ports.rs` - no longer needed +- [x] 5.8 Update infrastructure `CaddyDockerServiceConfig` and `MysqlDockerServiceConfig` to use `from_domain_config()` + +**Rationale for change**: Even though Caddy and MySQL have fixed port/network behavior, they should follow the same trait-based patterns as other services for consistency and Open/Closed compliance. + +### Step 6: Network Computation - Domain Topology Builder + +- [x] 6.1 Create `NetworkDerivation` trait for network assignment logic +- [x] 6.2 Implement `NetworkDerivation` for `TrackerConfig` +- [x] 6.3 Implement `NetworkDerivation` for `PrometheusConfig` +- [x] 6.4 Implement `NetworkDerivation` for `GrafanaConfig` +- [x] 6.5 Create `EnabledServices` abstraction (renamed from `TopologyContext`) + - Uses `HashSet` for Open/Closed compliance + - Provides only `has(Service)` method - no convenience methods +- [x] 6.6 Add unit tests for `EnabledServices` (10 tests covering constructor, has method, Default, Clone) +- [x] 6.7 ~~Create `DockerComposeTopologyBuilder`~~ - **Not needed**: Caddy/MySQL have static networks (NET-08, NET-09), infrastructure builder handles collection +- [x] 6.8 ~~Move Caddy network computation~~ - **Not needed**: Caddy always connects to Proxy network only (no conditional logic) +- [x] 6.9 ~~Move MySQL network computation~~ - **Not needed**: MySQL always connects to Database network only (no conditional logic) +- [x] 6.10 ~~Add builder unit tests~~ - **Not needed**: Existing infrastructure tests cover network derivation + +### Step 7: Refactor Infrastructure to Pure DTOs + +- [x] 7.1 Remove `compute_networks()` from `TrackerServiceConfig` - **Done**: Uses `NetworkDerivation` trait +- [x] 7.2 Remove `compute_networks()` from `PrometheusServiceConfig` - **Done**: Uses `NetworkDerivation` trait +- [x] 7.3 Remove `compute_networks()` from `GrafanaServiceConfig` - **Done**: Uses `NetworkDerivation` trait +- [x] 7.4 Update `DockerComposeContextBuilder` to use domain traits - **Done**: Passes `EnabledServices` to `from_domain_config` +- [x] 7.5 Rename infrastructure service config types to `*Context` - **Done** + - **What**: Renamed infrastructure DTOs to better reflect their purpose as template contexts + - **Renamed**: `TrackerServiceConfig` → `TrackerServiceContext`, `GrafanaServiceConfig` → `GrafanaServiceContext`, `PrometheusServiceConfig` → `PrometheusServiceContext`, `CaddyDockerServiceConfig` → `CaddyServiceContext`, `MysqlDockerServiceConfig` → `MysqlServiceContext` + - **Benefit**: Clear naming distinction between domain configs (`*Config`) and infrastructure contexts (`*Context`) + - **No backward compatibility aliases**: Clean break for readability (project not in production yet) + +### Step 8: Cleanup and Verification + +- [x] 8.1 Delete `src/infrastructure/.../context/port_derivation.rs` +- [x] 8.2 Remove unused imports and dead code +- [x] 8.3 Run full E2E test suite +- [x] 8.4 Run pre-commit checks: `./scripts/pre-commit.sh` ## Files Changed ### New Files -| File | Purpose | -| ------------------------------------ | ------------------------------ | -| `src/domain/topology/traits.rs` | `PortDerivation` trait | -| `src/domain/topology/builder.rs` | `DockerComposeTopologyBuilder` | -| `src/domain/topology/fixed_ports.rs` | Caddy/MySQL port functions | +| File | Purpose | +| ----------------------------------------- | --------------------------------------------------------------- | +| `src/domain/topology/traits.rs` | `PortDerivation`, `NetworkDerivation` traits | +| `src/domain/topology/enabled_services.rs` | `EnabledServices` set for topology queries | +| `src/domain/caddy/config.rs` | `CaddyConfig` with `PortDerivation`, `NetworkDerivation` | +| `src/domain/mysql/config.rs` | `MysqlServiceConfig` with `PortDerivation`, `NetworkDerivation` | ### Modified Files -| File | Change | -| ---------------------------------------------- | --------------------------------------- | -| `src/domain/topology/mod.rs` | Export new modules | -| `src/domain/tracker/config.rs` | Implement `PortDerivation` | -| `src/domain/grafana/config.rs` | Implement `PortDerivation` | -| `src/domain/prometheus/config.rs` | Implement `PortDerivation` | -| `src/infrastructure/.../context/tracker.rs` | Remove `compute_networks()`, become DTO | -| `src/infrastructure/.../context/grafana.rs` | Remove `compute_networks()`, become DTO | -| `src/infrastructure/.../context/prometheus.rs` | Remove `compute_networks()`, become DTO | -| `src/infrastructure/.../context/builder.rs` | Receive domain topology | +| File | Change | +| ---------------------------------------------- | ------------------------------------------------------------------- | +| `src/domain/mod.rs` | Export new `caddy` and `mysql` modules | +| `src/domain/topology/mod.rs` | Export new modules | +| `src/domain/tracker/config.rs` | Implement `PortDerivation`, `NetworkDerivation` | +| `src/domain/grafana/config.rs` | Implement `PortDerivation`, `NetworkDerivation` | +| `src/domain/prometheus/config.rs` | Implement `PortDerivation`, `NetworkDerivation` | +| `src/infrastructure/.../context/caddy.rs` | Renamed to `CaddyServiceContext`, use `from_domain_config()` | +| `src/infrastructure/.../context/mysql.rs` | Renamed to `MysqlServiceContext`, use `from_domain_config()` | +| `src/infrastructure/.../context/tracker.rs` | Renamed to `TrackerServiceContext`, removed `compute_networks()` | +| `src/infrastructure/.../context/grafana.rs` | Renamed to `GrafanaServiceContext`, removed `compute_networks()` | +| `src/infrastructure/.../context/prometheus.rs` | Renamed to `PrometheusServiceContext`, removed `compute_networks()` | +| `src/infrastructure/.../context/builder.rs` | Receive domain topology, use new `*ServiceContext` types | ### Deleted Files -| File | Reason | -| --------------------------------------------------- | --------------------- | -| `src/infrastructure/.../context/port_derivation.rs` | Logic moved to domain | +| File | Reason | +| --------------------------------------------------- | --------------------------------------------- | +| `src/infrastructure/.../context/port_derivation.rs` | Logic moved to domain | +| `src/domain/topology/fixed_ports.rs` | Replaced by proper domain types (Caddy/MySQL) | ## Acceptance Criteria @@ -364,25 +417,27 @@ impl TrackerServiceContext { **Quality Checks**: -- [ ] Pre-commit checks pass: `./scripts/pre-commit.sh` +- [x] Pre-commit checks pass: `./scripts/pre-commit.sh` **Task-Specific Criteria**: -- [ ] `PortDerivation` trait defined in `domain/topology/traits.rs` -- [ ] All service configs (`TrackerConfig`, `GrafanaConfig`, `PrometheusConfig`) implement `PortDerivation` -- [ ] `DockerComposeTopologyBuilder` computes networks in domain layer -- [ ] Infrastructure context types are pure DTOs with no `compute_*()` methods -- [ ] `port_derivation.rs` deleted from infrastructure -- [ ] All existing E2E tests pass -- [ ] Unit tests cover port derivation for each service +- [x] `PortDerivation` trait defined in `domain/topology/traits.rs` +- [x] All service configs (`TrackerConfig`, `GrafanaConfig`, `PrometheusConfig`, `CaddyConfig`, `MysqlServiceConfig`) implement `PortDerivation` +- [x] `NetworkDerivation` trait defined in `domain/topology/traits.rs` for network computation +- [x] Infrastructure context types are pure DTOs with no `compute_*()` methods +- [x] `port_derivation.rs` deleted from infrastructure +- [x] All existing E2E tests pass (2060 tests) +- [x] Unit tests cover port derivation for each service +- [x] Unit tests cover network derivation for `EnabledServices` ## Design Decisions (Resolved) -| Question | Decision | Rationale | -| --------------------------------------------------------------------------------- | ------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------- | -| Should `PortDerivation` trait be in `domain/topology/` or a shared traits module? | `domain/topology/traits.rs` | The trait exists for topology purposes. Consumer (builder) defines it, implementers import it. Keeps topology concerns cohesive. | -| Should we rename infrastructure context types to `*Context` now or defer? | Phase 4 (P4.3) | Directly related to "refactor to pure DTOs" goal. One coherent refactoring story. | -| Should `fixed_ports.rs` functions be in the builder or a separate module? | Separate module `domain/topology/fixed_ports.rs` | Keeps builder focused on orchestration. Single responsibility. Easy to find, extend, test. | +| Question | Decision | Rationale | +| --------------------------------------------------------------------------------- | ------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------- | +| Should `PortDerivation` trait be in `domain/topology/` or a shared traits module? | `domain/topology/traits.rs` | The trait exists for topology purposes. Consumer (builder) defines it, implementers import it. Keeps topology concerns cohesive. | +| Should we rename infrastructure context types to `*Context` now or defer? | Done - renamed to `*ServiceContext` | Clearer naming: domain uses `*Config`, infrastructure uses `*ServiceContext`. No backward compatibility aliases for readability. | +| Should fixed-port services (Caddy, MySQL) use `fixed_ports.rs` or domain types? | Domain types with `PortDerivation`/`NetworkDerivation` | Consistency with other services. All services follow same trait-based pattern, even if behavior is static. Open/Closed compliance. | +| Should we create `DockerComposeTopologyBuilder` for network computation? | Not needed | Caddy/MySQL have static networks (NET-08, NET-09). Trait-based approach with `NetworkDerivation` + `EnabledServices` is sufficient. | ## Related Documentation diff --git a/docs/issues/304-clippy-large-stack-arrays-false-positive.md b/docs/issues/304-clippy-large-stack-arrays-false-positive.md new file mode 100644 index 00000000..de82f998 --- /dev/null +++ b/docs/issues/304-clippy-large-stack-arrays-false-positive.md @@ -0,0 +1,114 @@ +# Issue #304: Clippy `large_stack_arrays` False Positive with `vec![]` Macro + +**GitHub Issue**: + +## Problem + +Clippy reports a false positive `large_stack_arrays` lint when using the `vec![]` macro +to create vectors of `ServiceTopology` items in tests. + +### Error Message + +```text +error: allocating a local array larger than 16384 bytes + | + = help: for further information visit https://rust-lang.github.io/rust-clippy/rust-1.93.0/index.html#large_stack_arrays + = note: `-D clippy::large-stack-arrays` implied by `-D clippy::pedantic` + = help: to override `-D clippy::pedantic` add `#[allow(clippy::large_stack_arrays)]` +``` + +### Root Cause + +This is a known clippy bug where the `vec![]` macro is incorrectly flagged for creating +a large stack array. The `vec![]` macro creates a `Vec` which allocates on the heap, +not the stack. The lint is a false positive. + +**Upstream Issue**: + +### Affected Code + +Tests in `src/domain/topology/aggregate.rs` that create vectors of `ServiceTopology`: + +```rust +let topology = DockerComposeTopology::new(vec![ + ServiceTopology::with_networks(Service::Tracker, vec![Network::Database]), + ServiceTopology::with_networks(Service::MySQL, vec![Network::Database]), +]) +.unwrap(); +``` + +### Workarounds Attempted (Did Not Work) + +1. **Outer attribute on module**: `#[allow(clippy::large_stack_arrays)]` on `mod tests` +2. **Inner attribute on submodule**: `#![allow(clippy::large_stack_arrays)]` inside submodules +3. **Outer attribute on test function**: `#[allow(clippy::large_stack_arrays)]` on `#[test]` functions +4. **Inner attribute in function body**: `#![allow(clippy::large_stack_arrays)]` inside function + +None of these local suppression methods worked because the lint fires during macro +expansion before the allow attributes are processed. + +## Solution + +Add a **crate-level allow attribute** in `src/lib.rs`: + +```rust +// False positive: clippy reports large_stack_arrays for vec![] macro with ServiceTopology +// This is a known issue: https://github.com/rust-lang/rust-clippy/issues/12586 +#![allow(clippy::large_stack_arrays)] +``` + +This is the only approach that successfully suppresses the false positive. + +### Trade-offs + +- **Downside**: Suppresses the lint crate-wide, potentially hiding legitimate issues +- **Mitigation**: This lint is specifically for stack allocations. Since we use `Vec` + (heap-allocated) throughout the codebase, legitimate triggers are unlikely +- **Future**: Remove this allow once the upstream clippy issue is fixed + +## Affected Rust Versions + +- Rust 1.93.0 stable (confirmed on GitHub Actions CI) +- Local nightly 1.95.0 does **not** reproduce the issue + +### Fix Timeline + +| Event | Date | Version | +| ---------------------------------------------------------------------------------------------- | -------------- | ---------------------------------- | +| Fix merged to clippy master ([PR #12624](https://github.com/rust-lang/rust-clippy/pull/12624)) | April 27, 2024 | - | +| Rust 1.79.0 released | June 13, 2024 | Nightly at merge time | +| Rust 1.80.0 released | July 25, 2024 | **Expected first stable with fix** | + +### Potential Regression + +The fix was merged in April 2024 and should be in Rust 1.80.0+. However, we're still +seeing this error on Rust 1.93.0 (January 2026). This suggests either: + +1. **Regression**: The bug may have regressed in a later clippy version +2. **Different code pattern**: Our `vec![]` with `ServiceTopology` (large struct with + `EnumSet` fields) might trigger a variant not covered by the original fix +3. **CI environment**: Some discrepancy in the clippy version used in CI + +Consider reporting this as a potential regression if the issue persists after verifying +the clippy version matches the expected behavior. + +## Related Clippy Issues + +Other `large_stack_arrays` issues (not directly related to our problem): + +- [#13774](https://github.com/rust-lang/rust-clippy/issues/13774) - No span for error (closed) +- [#13529](https://github.com/rust-lang/rust-clippy/issues/13529) - Nested const items (closed) +- [#9460](https://github.com/rust-lang/rust-clippy/issues/9460) - Static struct (closed) +- [#4520](https://github.com/rust-lang/rust-clippy/issues/4520) - Original lint creation (open) + +## References + +- Upstream clippy issue: +- Related PR: #303 (Phase 4 Service Topology DDD Alignment) + +## Labels + +- `bug` +- `workaround` +- `clippy` +- `technical-debt` diff --git a/src/application/steps/rendering/docker_compose_templates.rs b/src/application/steps/rendering/docker_compose_templates.rs index a986cf61..4646445b 100644 --- a/src/application/steps/rendering/docker_compose_templates.rs +++ b/src/application/steps/rendering/docker_compose_templates.rs @@ -29,12 +29,12 @@ use std::sync::Arc; use tracing::{info, instrument}; -use crate::domain::environment::user_inputs::UserInputs; use crate::domain::environment::Environment; use crate::domain::template::TemplateManager; -use crate::domain::tracker::{DatabaseConfig, TrackerConfig}; +use crate::domain::topology::EnabledServices; +use crate::domain::tracker::DatabaseConfig; use crate::infrastructure::templating::docker_compose::template::wrappers::docker_compose::{ - DockerComposeContext, DockerComposeContextBuilder, MysqlSetupConfig, TrackerServiceConfig, + DockerComposeContext, DockerComposeContextBuilder, MysqlSetupConfig, TrackerServiceContext, }; use crate::infrastructure::templating::docker_compose::template::wrappers::env::EnvContext; use crate::infrastructure::templating::docker_compose::{ @@ -155,12 +155,8 @@ impl RenderDockerComposeTemplatesStep { self.environment.admin_token().to_string() } - fn build_tracker_config(&self) -> TrackerServiceConfig { + fn build_tracker_config(&self) -> TrackerServiceContext { let tracker_config = self.environment.tracker_config(); - let user_inputs = &self.environment.context().user_inputs; - - let (udp_tracker_ports, http_tracker_ports_without_tls, http_api_port, http_api_has_tls) = - Self::extract_tracker_ports(tracker_config, user_inputs); // Determine which features are enabled (affects tracker networks) let has_prometheus = self.environment.prometheus_config().is_some(); @@ -169,16 +165,26 @@ impl RenderDockerComposeTemplatesStep { DatabaseConfig::Mysql(..) ); let has_caddy = self.has_caddy_enabled(); + let has_grafana = self.environment.grafana_config().is_some(); - TrackerServiceConfig::new( - udp_tracker_ports, - http_tracker_ports_without_tls, - http_api_port, - http_api_has_tls, - has_prometheus, - has_mysql, - has_caddy, - ) + // Build list of enabled services for topology context + let mut enabled_services = Vec::new(); + if has_prometheus { + enabled_services.push(crate::domain::topology::Service::Prometheus); + } + if has_grafana { + enabled_services.push(crate::domain::topology::Service::Grafana); + } + if has_mysql { + enabled_services.push(crate::domain::topology::Service::MySQL); + } + if has_caddy { + enabled_services.push(crate::domain::topology::Service::Caddy); + } + + let topology_context = EnabledServices::from(&enabled_services); + + TrackerServiceContext::from_domain_config(tracker_config, &topology_context) } /// Check if Caddy is enabled (HTTPS with at least one TLS-configured service) @@ -205,7 +211,7 @@ impl RenderDockerComposeTemplatesStep { fn create_sqlite_contexts( admin_token: String, - tracker: TrackerServiceConfig, + tracker: TrackerServiceContext, ) -> (EnvContext, DockerComposeContextBuilder) { let env_context = EnvContext::new(admin_token); let builder = DockerComposeContext::builder(tracker); @@ -215,7 +221,7 @@ impl RenderDockerComposeTemplatesStep { fn create_mysql_contexts( admin_token: String, - tracker: TrackerServiceConfig, + tracker: TrackerServiceContext, port: u16, database_name: String, username: String, @@ -312,47 +318,6 @@ impl RenderDockerComposeTemplatesStep { env_context } } - - fn extract_tracker_ports( - tracker_config: &TrackerConfig, - user_inputs: &UserInputs, - ) -> (Vec, Vec, u16, bool) { - // Extract UDP tracker ports (always exposed - no TLS termination via Caddy) - let udp_ports: Vec = tracker_config - .udp_trackers() - .iter() - .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() - .http_trackers_with_tls() - .iter() - .map(|(_, port)| *port) - .collect(); - - // Extract HTTP tracker ports WITHOUT TLS (these need to be exposed) - let http_ports_without_tls: Vec = tracker_config - .http_trackers() - .iter() - .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(); - - // Check if HTTP API has TLS enabled - let http_api_has_tls = user_inputs.tracker().http_api_tls_domain().is_some(); - - ( - udp_ports, - http_ports_without_tls, - api_port, - http_api_has_tls, - ) - } } #[cfg(test)] diff --git a/src/domain/caddy/config.rs b/src/domain/caddy/config.rs new file mode 100644 index 00000000..782178da --- /dev/null +++ b/src/domain/caddy/config.rs @@ -0,0 +1,206 @@ +//! Caddy TLS reverse proxy configuration domain type +//! +//! This module defines the Caddy configuration domain type which implements +//! the `PortDerivation` and `NetworkDerivation` traits following the same +//! pattern as other services. +//! +//! ## Port Rules Reference +//! +//! | Rule | Description | +//! |---------|-------------------------------------------| +//! | PORT-09 | Caddy always exposes 80, 443, 443/udp | +//! +//! ## Network Rules Reference +//! +//! | Rule | Description | +//! |--------|-------------------------------------------| +//! | NET-09 | Caddy always connects to Proxy network | + +use serde::{Deserialize, Serialize}; + +use crate::domain::topology::{ + EnabledServices, Network, NetworkDerivation, PortBinding, PortDerivation, +}; + +/// Caddy TLS reverse proxy configuration +/// +/// Caddy is a special service with fixed behavior: +/// - Always exposes ports 80 (ACME), 443 (HTTPS), 443/udp (QUIC) +/// - Always connects to the Proxy network +/// +/// Unlike other services, Caddy doesn't have user-configurable port behavior, +/// but it still implements `PortDerivation` for consistency. +/// +/// # Example +/// +/// ```rust +/// use torrust_tracker_deployer_lib::domain::caddy::CaddyConfig; +/// use torrust_tracker_deployer_lib::domain::topology::PortDerivation; +/// +/// let config = CaddyConfig::new(); +/// let ports = config.derive_ports(); +/// assert_eq!(ports.len(), 3); +/// ``` +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)] +pub struct CaddyConfig { + // Caddy has no configurable fields for port/network derivation. + // This is intentionally empty - the behavior is fixed. + // Future: Could add ACME email, custom certificates, etc. +} + +impl CaddyConfig { + /// Creates a new `CaddyConfig` + /// + /// # Examples + /// + /// ```rust + /// use torrust_tracker_deployer_lib::domain::caddy::CaddyConfig; + /// + /// let config = CaddyConfig::new(); + /// ``` + #[must_use] + pub const fn new() -> Self { + Self {} + } +} + +impl PortDerivation for CaddyConfig { + /// Derives port bindings for the Caddy TLS proxy service + /// + /// Implements PORT-09: Caddy always exposes 80, 443, 443/udp + /// + /// These ports are required for: + /// - **Port 80/tcp**: ACME HTTP-01 challenge for Let's Encrypt certificate renewal + /// - **Port 443/tcp**: HTTPS traffic for all proxied services + /// - **Port 443/udp**: HTTP/3 (QUIC) support for modern browsers + fn derive_ports(&self) -> Vec { + vec![ + PortBinding::tcp(80, "HTTP (ACME HTTP-01 challenge)"), + PortBinding::tcp(443, "HTTPS"), + PortBinding::udp(443, "HTTP/3 (QUIC)"), + ] + } +} + +impl NetworkDerivation for CaddyConfig { + /// Derives network assignments for the Caddy service + /// + /// Implements NET-09: Caddy always connects to Proxy network only + fn derive_networks(&self, _enabled_services: &EnabledServices) -> Vec { + vec![Network::Proxy] + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::domain::topology::Service; + use crate::domain::tracker::Protocol; + + // ========================================================================= + // Constructor tests + // ========================================================================= + + mod constructor { + use super::*; + + #[test] + fn it_should_create_caddy_config() { + let config = CaddyConfig::new(); + assert_eq!(config, CaddyConfig::default()); + } + + #[test] + fn it_should_implement_default() { + let config = CaddyConfig::default(); + assert_eq!(config, CaddyConfig::new()); + } + } + + // ========================================================================= + // PortDerivation tests (PORT-09) + // ========================================================================= + + mod port_derivation { + use super::*; + + #[test] + fn it_should_expose_port_80_for_acme_challenge() { + let config = CaddyConfig::new(); + let ports = config.derive_ports(); + + let port_80 = ports + .iter() + .find(|p| p.host_port() == 80 && p.protocol() == Protocol::Tcp); + + assert!(port_80.is_some()); + assert!(port_80.unwrap().description().contains("ACME")); + } + + #[test] + fn it_should_expose_port_443_tcp_for_https() { + let config = CaddyConfig::new(); + let ports = config.derive_ports(); + + let port_443_tcp = ports + .iter() + .find(|p| p.host_port() == 443 && p.protocol() == Protocol::Tcp); + + assert!(port_443_tcp.is_some()); + assert!(port_443_tcp.unwrap().description().contains("HTTPS")); + } + + #[test] + fn it_should_expose_port_443_udp_for_quic() { + let config = CaddyConfig::new(); + let ports = config.derive_ports(); + + let port_443_udp = ports + .iter() + .find(|p| p.host_port() == 443 && p.protocol() == Protocol::Udp); + + assert!(port_443_udp.is_some()); + assert!(port_443_udp.unwrap().description().contains("QUIC")); + } + + #[test] + fn it_should_expose_exactly_three_ports() { + let config = CaddyConfig::new(); + let ports = config.derive_ports(); + + assert_eq!(ports.len(), 3); + } + } + + // ========================================================================= + // NetworkDerivation tests (NET-09) + // ========================================================================= + + mod network_derivation { + use super::*; + + #[test] + fn it_should_connect_to_proxy_network() { + let config = CaddyConfig::new(); + let enabled = EnabledServices::from(&[]); + let networks = config.derive_networks(&enabled); + + assert_eq!(networks, vec![Network::Proxy]); + } + + #[test] + fn it_should_connect_only_to_proxy_network_regardless_of_enabled_services() { + let config = CaddyConfig::new(); + let enabled = EnabledServices::from(&[ + Service::Tracker, + Service::Prometheus, + Service::Grafana, + Service::MySQL, + ]); + let networks = config.derive_networks(&enabled); + + // NET-09: Caddy only connects to Proxy network + assert_eq!(networks, vec![Network::Proxy]); + } + } +} diff --git a/src/domain/caddy/mod.rs b/src/domain/caddy/mod.rs new file mode 100644 index 00000000..2102da93 --- /dev/null +++ b/src/domain/caddy/mod.rs @@ -0,0 +1,5 @@ +//! Caddy TLS reverse proxy service domain types + +pub mod config; + +pub use config::CaddyConfig; diff --git a/src/domain/grafana/config.rs b/src/domain/grafana/config.rs index cba5a215..0043a36f 100644 --- a/src/domain/grafana/config.rs +++ b/src/domain/grafana/config.rs @@ -2,6 +2,9 @@ use serde::{Deserialize, Serialize}; +use crate::domain::topology::{ + EnabledServices, Network, NetworkDerivation, PortBinding, PortDerivation, Service, +}; use crate::shared::domain_name::DomainName; use crate::shared::secrets::Password; @@ -116,6 +119,41 @@ impl Default for GrafanaConfig { } } +impl PortDerivation for GrafanaConfig { + /// Derives port bindings for Grafana + /// + /// Implements PORT-07 and PORT-08: + /// - Without TLS: expose port 3000 directly + /// - With TLS: don't expose (Caddy handles it) + fn derive_ports(&self) -> Vec { + // PORT-07: Grafana 3000 exposed only without TLS + // PORT-08: Grafana 3000 NOT exposed with TLS + if self.use_tls_proxy { + vec![] + } else { + vec![PortBinding::tcp(3000, "Grafana dashboard")] + } + } +} + +impl NetworkDerivation for GrafanaConfig { + /// Derives network assignments for the Grafana service + /// + /// Implements NET-06 and NET-07: + /// - NET-06: Visualization network always (to query Prometheus) + /// - NET-07: Proxy network if Caddy enabled + fn derive_networks(&self, enabled_services: &EnabledServices) -> Vec { + let mut networks = vec![Network::Visualization]; + + // NET-07: Proxy network if Caddy enabled + if enabled_services.has(Service::Caddy) { + networks.push(Network::Proxy); + } + + networks + } +} + #[cfg(test)] mod tests { use super::*; @@ -233,4 +271,53 @@ mod tests { config.admin_password.expose_secret() ); } + + // ========================================================================= + // Port derivation tests (PORT-07, PORT-08) + // ========================================================================= + + mod port_derivation { + use super::*; + use crate::domain::tracker::Protocol; + + #[test] + fn it_should_expose_port_3000_when_tls_disabled() { + // PORT-07: Grafana 3000 exposed only without TLS + let config = + GrafanaConfig::new("admin".to_string(), "password".to_string(), None, false); + + let ports = config.derive_ports(); + + assert_eq!(ports.len(), 1); + let port = &ports[0]; + assert_eq!(port.host_port(), 3000); + assert_eq!(port.container_port(), 3000); + assert_eq!(port.protocol(), Protocol::Tcp); + } + + #[test] + fn it_should_not_expose_port_when_tls_enabled() { + // PORT-08: Grafana 3000 NOT exposed with TLS + let domain = DomainName::new("grafana.example.com").unwrap(); + let config = GrafanaConfig::new( + "admin".to_string(), + "password".to_string(), + Some(domain), + true, + ); + + let ports = config.derive_ports(); + + assert!(ports.is_empty()); + } + + #[test] + fn it_should_include_description_for_grafana_port() { + let config = GrafanaConfig::default(); + + let ports = config.derive_ports(); + + assert_eq!(ports[0].description(), "Grafana dashboard"); + } + } } diff --git a/src/domain/mod.rs b/src/domain/mod.rs index f6c62a02..62d8fbc3 100644 --- a/src/domain/mod.rs +++ b/src/domain/mod.rs @@ -5,19 +5,23 @@ //! //! ## Components //! +//! - `caddy` - Caddy TLS reverse proxy service domain types //! - `environment` - Environment module with entity, name validation, and state management //! - `environment::name` - Environment name validation and management //! - `environment::state` - State marker types and type erasure for environment state machine //! - `instance_name` - LXD instance name validation and management +//! - `mysql` - `MySQL` database service domain types (distinct from tracker database config) //! - `profile_name` - LXD profile name validation and management //! - `provider` - Infrastructure provider types (LXD, Hetzner) and configuration //! - `template` - Core template domain models and business logic //! - `topology` - Docker Compose topology domain types (networks, services) +pub mod caddy; pub mod environment; pub mod grafana; pub mod https; pub mod instance_name; +pub mod mysql; pub mod profile_name; pub mod prometheus; pub mod provider; @@ -26,12 +30,14 @@ pub mod topology; pub mod tracker; // Re-export commonly used domain types for convenience +pub use caddy::CaddyConfig; pub use environment::{ name::{EnvironmentName, EnvironmentNameError}, state::{AnyEnvironmentState, StateTypeError}, Environment, }; pub use instance_name::{InstanceName, InstanceNameError}; +pub use mysql::MysqlServiceConfig; pub use profile_name::{ProfileName, ProfileNameError}; pub use provider::{HetznerConfig, LxdConfig, Provider, ProviderConfig}; pub use template::{TemplateEngine, TemplateEngineError, TemplateManager, TemplateManagerError}; diff --git a/src/domain/mysql/config.rs b/src/domain/mysql/config.rs new file mode 100644 index 00000000..a9e5ebec --- /dev/null +++ b/src/domain/mysql/config.rs @@ -0,0 +1,167 @@ +//! `MySQL` database service configuration domain type +//! +//! This module defines the `MySQL` service configuration domain type which implements +//! the `PortDerivation` and `NetworkDerivation` traits following the same +//! pattern as other services. +//! +//! ## Note on Type Names +//! +//! - `domain::mysql::MysqlServiceConfig` (this module): Docker service configuration +//! for port/network derivation in Docker Compose topology +//! - `domain::tracker::MysqlConfig`: Database connection settings for the tracker +//! (host, port, credentials) +//! +//! ## Port Rules Reference +//! +//! | Rule | Description | +//! |---------|-------------------------------------------| +//! | PORT-11 | `MySQL` never exposes ports externally | +//! +//! ## Network Rules Reference +//! +//! | Rule | Description | +//! |--------|---------------------------------------------| +//! | NET-08 | `MySQL` always connects to Database network | + +use serde::{Deserialize, Serialize}; + +use crate::domain::topology::{ + EnabledServices, Network, NetworkDerivation, PortBinding, PortDerivation, +}; + +/// `MySQL` database service configuration for Docker Compose topology +/// +/// `MySQL` is a special service with fixed behavior: +/// - Never exposes ports externally (security - internal only) +/// - Always connects to the Database network +/// +/// Unlike other services, `MySQL` doesn't have user-configurable port behavior, +/// but it still implements `PortDerivation` for consistency. +/// +/// # Example +/// +/// ```rust +/// use torrust_tracker_deployer_lib::domain::mysql::MysqlServiceConfig; +/// use torrust_tracker_deployer_lib::domain::topology::PortDerivation; +/// +/// let config = MysqlServiceConfig::new(); +/// let ports = config.derive_ports(); +/// assert!(ports.is_empty()); // MySQL never exposes ports +/// ``` +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)] +pub struct MysqlServiceConfig { + // MySQL has no configurable fields for port/network derivation. + // This is intentionally empty - the behavior is fixed. + // Note: Database credentials are in domain::tracker::MysqlConfig. +} + +impl MysqlServiceConfig { + /// Creates a new `MysqlServiceConfig` + /// + /// # Examples + /// + /// ```rust + /// use torrust_tracker_deployer_lib::domain::mysql::MysqlServiceConfig; + /// + /// let config = MysqlServiceConfig::new(); + /// ``` + #[must_use] + pub const fn new() -> Self { + Self {} + } +} + +impl PortDerivation for MysqlServiceConfig { + /// Derives port bindings for the `MySQL` database service + /// + /// Implements PORT-11: `MySQL` has no exposed ports + /// + /// `MySQL` is accessed only via Docker network by the tracker service. + /// It should never be exposed to the host network for security reasons. + fn derive_ports(&self) -> Vec { + vec![] + } +} + +impl NetworkDerivation for MysqlServiceConfig { + /// Derives network assignments for the `MySQL` service + /// + /// Implements NET-08: `MySQL` always connects to Database network only + fn derive_networks(&self, _enabled_services: &EnabledServices) -> Vec { + vec![Network::Database] + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::domain::topology::Service; + + // ========================================================================= + // Constructor tests + // ========================================================================= + + mod constructor { + use super::*; + + #[test] + fn it_should_create_mysql_service_config() { + let config = MysqlServiceConfig::new(); + assert_eq!(config, MysqlServiceConfig::default()); + } + + #[test] + fn it_should_implement_default() { + let config = MysqlServiceConfig::default(); + assert_eq!(config, MysqlServiceConfig::new()); + } + } + + // ========================================================================= + // PortDerivation tests (PORT-11) + // ========================================================================= + + mod port_derivation { + use super::*; + + #[test] + fn it_should_expose_no_ports() { + let config = MysqlServiceConfig::new(); + let ports = config.derive_ports(); + + assert!(ports.is_empty()); + } + } + + // ========================================================================= + // NetworkDerivation tests (NET-08) + // ========================================================================= + + mod network_derivation { + use super::*; + + #[test] + fn it_should_connect_to_database_network() { + let config = MysqlServiceConfig::new(); + let enabled = EnabledServices::from(&[]); + let networks = config.derive_networks(&enabled); + + assert_eq!(networks, vec![Network::Database]); + } + + #[test] + fn it_should_connect_only_to_database_network_regardless_of_enabled_services() { + let config = MysqlServiceConfig::new(); + let enabled = EnabledServices::from(&[ + Service::Tracker, + Service::Prometheus, + Service::Grafana, + Service::Caddy, + ]); + let networks = config.derive_networks(&enabled); + + // NET-08: MySQL only connects to Database network + assert_eq!(networks, vec![Network::Database]); + } + } +} diff --git a/src/domain/mysql/mod.rs b/src/domain/mysql/mod.rs new file mode 100644 index 00000000..a5a9a79d --- /dev/null +++ b/src/domain/mysql/mod.rs @@ -0,0 +1,9 @@ +//! `MySQL` database service domain types +//! +//! This module defines the `MySQL` service configuration for Docker Compose topology. +//! This is distinct from `domain::tracker::MysqlConfig` which configures the +//! tracker's database connection settings. + +pub mod config; + +pub use config::MysqlServiceConfig; diff --git a/src/domain/prometheus/config.rs b/src/domain/prometheus/config.rs index 33aedb9a..ff6ce35c 100644 --- a/src/domain/prometheus/config.rs +++ b/src/domain/prometheus/config.rs @@ -6,6 +6,10 @@ use std::num::NonZeroU32; use serde::{Deserialize, Serialize}; +use crate::domain::topology::{ + EnabledServices, Network, NetworkDerivation, PortBinding, PortDerivation, Service, +}; + /// Default scrape interval in seconds /// /// This is the recommended interval for most use cases, balancing @@ -85,6 +89,39 @@ impl Default for PrometheusConfig { } } +impl PortDerivation for PrometheusConfig { + /// Derives port bindings for Prometheus + /// + /// Implements PORT-10: Prometheus 9090 on localhost only + /// + /// Prometheus is bound to localhost to prevent external access. + /// Grafana accesses it via Docker network (`http://prometheus:9090`). + fn derive_ports(&self) -> Vec { + vec![PortBinding::localhost_tcp( + 9090, + "Prometheus metrics (localhost only)", + )] + } +} + +impl NetworkDerivation for PrometheusConfig { + /// Derives network assignments for the Prometheus service + /// + /// Implements NET-04 and NET-05: + /// - NET-04: Metrics network always (to scrape tracker) + /// - NET-05: Visualization network if Grafana enabled + fn derive_networks(&self, enabled_services: &EnabledServices) -> Vec { + let mut networks = vec![Network::Metrics]; + + // NET-05: Visualization network if Grafana enabled + if enabled_services.has(Service::Grafana) { + networks.push(Network::Visualization); + } + + networks + } +} + #[cfg(test)] mod tests { use std::num::NonZeroU32; @@ -143,4 +180,42 @@ mod tests { let result = NonZeroU32::new(0); assert!(result.is_none()); } + + // ========================================================================= + // Port derivation tests (PORT-10) + // ========================================================================= + + mod port_derivation { + use std::net::{IpAddr, Ipv4Addr}; + + use super::*; + use crate::domain::tracker::Protocol; + + #[test] + fn it_should_derive_prometheus_port_on_localhost_only() { + // PORT-10: Prometheus 9090 on localhost only + let config = PrometheusConfig::default(); + + let ports = config.derive_ports(); + + assert_eq!(ports.len(), 1); + let port = &ports[0]; + assert_eq!(port.host_port(), 9090); + assert_eq!(port.container_port(), 9090); + assert_eq!(port.protocol(), Protocol::Tcp); + assert_eq!(port.host_ip(), Some(IpAddr::V4(Ipv4Addr::LOCALHOST))); + } + + #[test] + fn it_should_include_description_for_prometheus_port() { + let config = PrometheusConfig::default(); + + let ports = config.derive_ports(); + + assert_eq!( + ports[0].description(), + "Prometheus metrics (localhost only)" + ); + } + } } diff --git a/src/domain/topology/aggregate.rs b/src/domain/topology/aggregate.rs index 63082a91..5ca5d58f 100644 --- a/src/domain/topology/aggregate.rs +++ b/src/domain/topology/aggregate.rs @@ -311,26 +311,23 @@ mod tests { mod required_networks { use super::*; - // TODO: Re-enable after Phase 4 DDD refactoring (Issue #301) - // Disabled due to clippy::large_stack_arrays false positive with vec![] macro - // when creating 3+ ServiceTopology items. See: https://github.com/rust-lang/rust-clippy/issues/12586 - // #[test] - // fn it_should_derive_required_networks_from_all_services() { - // let topology = DockerComposeTopology::new(vec![ - // ServiceTopology::with_networks( - // Service::Tracker, - // vec![Network::Database, Network::Metrics], - // ), - // ServiceTopology::with_networks(Service::MySQL, vec![Network::Database]), - // ServiceTopology::with_networks(Service::Prometheus, vec![Network::Metrics]), - // ]) - // .unwrap(); - // - // let required = topology.required_networks(); - // - // assert!(required.contains(&Network::Database)); - // assert!(required.contains(&Network::Metrics)); - // } + #[test] + fn it_should_derive_required_networks_from_all_services() { + let topology = DockerComposeTopology::new(vec![ + ServiceTopology::with_networks( + Service::Tracker, + vec![Network::Database, Network::Metrics], + ), + ServiceTopology::with_networks(Service::MySQL, vec![Network::Database]), + ServiceTopology::with_networks(Service::Prometheus, vec![Network::Metrics]), + ]) + .unwrap(); + + let required = topology.required_networks(); + + assert!(required.contains(&Network::Database)); + assert!(required.contains(&Network::Metrics)); + } #[test] fn it_should_not_have_orphan_networks() { @@ -350,52 +347,46 @@ mod tests { assert!(!required.contains(&Network::Proxy)); } - // TODO: Re-enable after Phase 4 DDD refactoring (Issue #301) - // Disabled due to clippy::large_stack_arrays false positive with vec![] macro - // when creating 3+ ServiceTopology items. See: https://github.com/rust-lang/rust-clippy/issues/12586 - // #[test] - // fn it_should_return_networks_in_deterministic_order() { - // // Create topology with networks added in non-alphabetical order - // let topology = DockerComposeTopology::new(vec![ - // ServiceTopology::with_networks(Service::Caddy, vec![Network::Proxy]), - // ServiceTopology::with_networks(Service::Tracker, vec![Network::Database]), - // ServiceTopology::with_networks(Service::Prometheus, vec![Network::Metrics]), - // ServiceTopology::with_networks(Service::Grafana, vec![Network::Visualization]), - // ]) - // .unwrap(); - // - // let required = topology.required_networks(); - // - // // Should be sorted alphabetically by name - // let names: Vec<&str> = required.iter().map(Network::name).collect(); - // assert_eq!( - // names, - // vec![ - // "database_network", - // "metrics_network", - // "proxy_network", - // "visualization_network" - // ] - // ); - // } - - // TODO: Re-enable after Phase 4 DDD refactoring (Issue #301) - // Disabled due to clippy::large_stack_arrays false positive with vec![] macro - // See: https://github.com/rust-lang/rust-clippy/issues/12586 - // #[test] - // fn it_should_deduplicate_networks_used_by_multiple_services() { - // let topology = DockerComposeTopology::new(vec![ - // ServiceTopology::with_networks(Service::Tracker, vec![Network::Metrics]), - // ServiceTopology::with_networks(Service::Prometheus, vec![Network::Metrics]), - // ]) - // .unwrap(); - // - // let required = topology.required_networks(); - // - // // Metrics appears twice but should only be in result once - // assert_eq!(required.len(), 1); - // assert!(required.contains(&Network::Metrics)); - // } + #[test] + fn it_should_return_networks_in_deterministic_order() { + // Create topology with networks added in non-alphabetical order + let topology = DockerComposeTopology::new(vec![ + ServiceTopology::with_networks(Service::Caddy, vec![Network::Proxy]), + ServiceTopology::with_networks(Service::Tracker, vec![Network::Database]), + ServiceTopology::with_networks(Service::Prometheus, vec![Network::Metrics]), + ServiceTopology::with_networks(Service::Grafana, vec![Network::Visualization]), + ]) + .unwrap(); + + let required = topology.required_networks(); + + // Should be sorted alphabetically by name + let names: Vec<&str> = required.iter().map(Network::name).collect(); + assert_eq!( + names, + vec![ + "database_network", + "metrics_network", + "proxy_network", + "visualization_network" + ] + ); + } + + #[test] + fn it_should_deduplicate_networks_used_by_multiple_services() { + let topology = DockerComposeTopology::new(vec![ + ServiceTopology::with_networks(Service::Tracker, vec![Network::Metrics]), + ServiceTopology::with_networks(Service::Prometheus, vec![Network::Metrics]), + ]) + .unwrap(); + + let required = topology.required_networks(); + + // Metrics appears twice but should only be in result once + assert_eq!(required.len(), 1); + assert!(required.contains(&Network::Metrics)); + } #[test] fn it_should_return_empty_when_no_services() { @@ -437,173 +428,152 @@ mod tests { assert!(required.is_empty()); } - // TODO: Re-enable after Phase 4 DDD refactoring (Issue #301) - // Disabled due to clippy::large_stack_arrays false positive with vec![] macro - // See: https://github.com/rust-lang/rust-clippy/issues/12586 - // #[test] - // fn it_should_configure_deployment_with_mysql() { - // let topology = DockerComposeTopology::new(vec![ - // ServiceTopology::with_networks(Service::Tracker, vec![Network::Database]), - // ServiceTopology::with_networks(Service::MySQL, vec![Network::Database]), - // ]) - // .unwrap(); - // - // let required = topology.required_networks(); - // - // assert_eq!(required.len(), 1); - // assert!(required.contains(&Network::Database)); - // } - - // TODO: Re-enable after Phase 4 DDD refactoring (Issue #301) - // Disabled due to clippy::large_stack_arrays false positive with vec![] macro - // when creating 3+ ServiceTopology items. See: https://github.com/rust-lang/rust-clippy/issues/12586 - // #[test] - // fn it_should_configure_deployment_with_monitoring() { - // let topology = DockerComposeTopology::new(vec![ - // ServiceTopology::with_networks(Service::Tracker, vec![Network::Metrics]), - // ServiceTopology::with_networks( - // Service::Prometheus, - // vec![Network::Metrics, Network::Visualization], - // ), - // ServiceTopology::with_networks(Service::Grafana, vec![Network::Visualization]), - // ]) - // .unwrap(); - // - // let required = topology.required_networks(); - // - // assert_eq!(required.len(), 2); - // assert!(required.contains(&Network::Metrics)); - // assert!(required.contains(&Network::Visualization)); - // } - - // TODO: Re-enable after Phase 4 DDD refactoring (Issue #301) - // Disabled due to clippy::large_stack_arrays false positive with vec![] macro - // when creating 3+ ServiceTopology items. See: https://github.com/rust-lang/rust-clippy/issues/12586 - // #[test] - // fn it_should_configure_full_http_deployment() { - // let topology = DockerComposeTopology::new(vec![ - // ServiceTopology::with_networks( - // Service::Tracker, - // vec![Network::Database, Network::Metrics], - // ), - // ServiceTopology::with_networks(Service::MySQL, vec![Network::Database]), - // ServiceTopology::with_networks( - // Service::Prometheus, - // vec![Network::Metrics, Network::Visualization], - // ), - // ServiceTopology::with_networks(Service::Grafana, vec![Network::Visualization]), - // ]) - // .unwrap(); - // - // let required = topology.required_networks(); - // - // assert_eq!(required.len(), 3); - // assert!(required.contains(&Network::Database)); - // assert!(required.contains(&Network::Metrics)); - // assert!(required.contains(&Network::Visualization)); - // assert!(!required.contains(&Network::Proxy)); // No Caddy - // } - - // TODO: Re-enable after Phase 4 DDD refactoring (Issue #301) - // Disabled due to clippy::large_stack_arrays false positive with vec![] macro - // when creating 3+ ServiceTopology items. See: https://github.com/rust-lang/rust-clippy/issues/12586 - // #[test] - // fn it_should_configure_full_https_deployment() { - // let topology = DockerComposeTopology::new(vec![ - // ServiceTopology::with_networks( - // Service::Tracker, - // vec![Network::Database, Network::Metrics, Network::Proxy], - // ), - // ServiceTopology::with_networks(Service::MySQL, vec![Network::Database]), - // ServiceTopology::with_networks( - // Service::Prometheus, - // vec![Network::Metrics, Network::Visualization], - // ), - // ServiceTopology::with_networks( - // Service::Grafana, - // vec![Network::Visualization, Network::Proxy], - // ), - // ServiceTopology::with_networks(Service::Caddy, vec![Network::Proxy]), - // ]) - // .unwrap(); - // - // let required = topology.required_networks(); - // - // assert_eq!(required.len(), 4); - // assert!(required.contains(&Network::Database)); - // assert!(required.contains(&Network::Metrics)); - // assert!(required.contains(&Network::Visualization)); - // assert!(required.contains(&Network::Proxy)); - // } - - // TODO: Re-enable after Phase 4 DDD refactoring (Issue #301) - // Disabled due to clippy::large_stack_arrays false positive with vec![] macro - // See: https://github.com/rust-lang/rust-clippy/issues/12586 - // #[test] - // fn it_should_configure_https_minimal_deployment() { - // // Tracker + Caddy only (HTTPS with no monitoring or database) - // let topology = DockerComposeTopology::new(vec![ - // ServiceTopology::with_networks(Service::Tracker, vec![Network::Proxy]), - // ServiceTopology::with_networks(Service::Caddy, vec![Network::Proxy]), - // ]) - // .unwrap(); - // - // let required = topology.required_networks(); - // - // assert_eq!(required.len(), 1); - // assert!(required.contains(&Network::Proxy)); - // } + #[test] + fn it_should_configure_deployment_with_mysql() { + let topology = DockerComposeTopology::new(vec![ + ServiceTopology::with_networks(Service::Tracker, vec![Network::Database]), + ServiceTopology::with_networks(Service::MySQL, vec![Network::Database]), + ]) + .unwrap(); + + let required = topology.required_networks(); + + assert_eq!(required.len(), 1); + assert!(required.contains(&Network::Database)); + } + + #[test] + fn it_should_configure_deployment_with_monitoring() { + let topology = DockerComposeTopology::new(vec![ + ServiceTopology::with_networks(Service::Tracker, vec![Network::Metrics]), + ServiceTopology::with_networks( + Service::Prometheus, + vec![Network::Metrics, Network::Visualization], + ), + ServiceTopology::with_networks(Service::Grafana, vec![Network::Visualization]), + ]) + .unwrap(); + + let required = topology.required_networks(); + + assert_eq!(required.len(), 2); + assert!(required.contains(&Network::Metrics)); + assert!(required.contains(&Network::Visualization)); + } + + #[test] + fn it_should_configure_full_http_deployment() { + let topology = DockerComposeTopology::new(vec![ + ServiceTopology::with_networks( + Service::Tracker, + vec![Network::Database, Network::Metrics], + ), + ServiceTopology::with_networks(Service::MySQL, vec![Network::Database]), + ServiceTopology::with_networks( + Service::Prometheus, + vec![Network::Metrics, Network::Visualization], + ), + ServiceTopology::with_networks(Service::Grafana, vec![Network::Visualization]), + ]) + .unwrap(); + + let required = topology.required_networks(); + + assert_eq!(required.len(), 3); + assert!(required.contains(&Network::Database)); + assert!(required.contains(&Network::Metrics)); + assert!(required.contains(&Network::Visualization)); + assert!(!required.contains(&Network::Proxy)); // No Caddy + } + + #[test] + fn it_should_configure_full_https_deployment() { + let topology = DockerComposeTopology::new(vec![ + ServiceTopology::with_networks( + Service::Tracker, + vec![Network::Database, Network::Metrics, Network::Proxy], + ), + ServiceTopology::with_networks(Service::MySQL, vec![Network::Database]), + ServiceTopology::with_networks( + Service::Prometheus, + vec![Network::Metrics, Network::Visualization], + ), + ServiceTopology::with_networks( + Service::Grafana, + vec![Network::Visualization, Network::Proxy], + ), + ServiceTopology::with_networks(Service::Caddy, vec![Network::Proxy]), + ]) + .unwrap(); + + let required = topology.required_networks(); + + assert_eq!(required.len(), 4); + assert!(required.contains(&Network::Database)); + assert!(required.contains(&Network::Metrics)); + assert!(required.contains(&Network::Visualization)); + assert!(required.contains(&Network::Proxy)); + } + + #[test] + fn it_should_configure_https_minimal_deployment() { + // Tracker + Caddy only (HTTPS with no monitoring or database) + let topology = DockerComposeTopology::new(vec![ + ServiceTopology::with_networks(Service::Tracker, vec![Network::Proxy]), + ServiceTopology::with_networks(Service::Caddy, vec![Network::Proxy]), + ]) + .unwrap(); + + let required = topology.required_networks(); + + assert_eq!(required.len(), 1); + assert!(required.contains(&Network::Proxy)); + } } mod port_validation { use super::*; - // TODO: Re-enable after Phase 4 DDD refactoring (Issue #301) - // Disabled due to clippy::large_stack_arrays false positive with vec![] macro - // See: https://github.com/rust-lang/rust-clippy/issues/12586 - // #[test] - // fn it_should_succeed_when_no_port_conflicts() { - // let result = DockerComposeTopology::new(vec![ - // ServiceTopology::new( - // Service::Tracker, - // vec![], - // vec![ - // PortBinding::udp(6969, "UDP announce"), - // PortBinding::tcp(7070, "HTTP announce"), - // ], - // ), - // ServiceTopology::new( - // Service::Prometheus, - // vec![], - // vec![PortBinding::tcp(9090, "Web UI")], - // ), - // ]); - // - // assert!(result.is_ok()); - // } - - // TODO: Re-enable after Phase 4 DDD refactoring (Issue #301) - // Disabled due to clippy::large_stack_arrays false positive with vec![] macro - // See: https://github.com/rust-lang/rust-clippy/issues/12586 - // #[test] - // fn it_should_fail_construction_when_same_host_port_bound_by_two_services() { - // let result = DockerComposeTopology::new(vec![ - // ServiceTopology::new( - // Service::Tracker, - // vec![], - // vec![PortBinding::tcp(9090, "Health check")], - // ), - // ServiceTopology::new( - // Service::Prometheus, - // vec![], - // vec![PortBinding::tcp(9090, "Web UI")], - // ), - // ]); - // - // assert!(result.is_err()); - // let conflict = result.unwrap_err(); - // assert_eq!(conflict.host_port, 9090); - // } + #[test] + fn it_should_succeed_when_no_port_conflicts() { + let result = DockerComposeTopology::new(vec![ + ServiceTopology::new( + Service::Tracker, + vec![], + vec![ + PortBinding::udp(6969, "UDP announce"), + PortBinding::tcp(7070, "HTTP announce"), + ], + ), + ServiceTopology::new( + Service::Prometheus, + vec![], + vec![PortBinding::tcp(9090, "Web UI")], + ), + ]); + + assert!(result.is_ok()); + } + + #[test] + fn it_should_fail_construction_when_same_host_port_bound_by_two_services() { + let result = DockerComposeTopology::new(vec![ + ServiceTopology::new( + Service::Tracker, + vec![], + vec![PortBinding::tcp(9090, "Health check")], + ), + ServiceTopology::new( + Service::Prometheus, + vec![], + vec![PortBinding::tcp(9090, "Web UI")], + ), + ]); + + assert!(result.is_err()); + let conflict = result.unwrap_err(); + assert_eq!(conflict.host_port, 9090); + } #[test] fn it_should_succeed_when_services_have_no_ports() { diff --git a/src/domain/topology/enabled_services.rs b/src/domain/topology/enabled_services.rs new file mode 100644 index 00000000..a272a223 --- /dev/null +++ b/src/domain/topology/enabled_services.rs @@ -0,0 +1,195 @@ +//! Enabled Services +//! +//! Provides a set of enabled services in a Docker Compose topology. +//! This is used by [`NetworkDerivation`](super::NetworkDerivation) implementations +//! to determine network assignments based on inter-service relationships. + +use std::collections::HashSet; + +use super::service::Service; + +/// Set of enabled services in a topology +/// +/// Provides information about which services are enabled in the topology. +/// This is needed because network assignments depend on inter-service +/// relationships (e.g., Tracker needs Metrics network only if Prometheus exists). +/// +/// # Design +/// +/// Uses a `HashSet` internally, following the Open/Closed principle: +/// adding new services doesn't require adding new fields or methods. +/// +/// # Examples +/// +/// ```rust +/// use torrust_tracker_deployer_lib::domain::topology::{EnabledServices, Service}; +/// +/// // Create with specific services enabled +/// let enabled = EnabledServices::from(&[Service::Prometheus, Service::Grafana]); +/// +/// assert!(enabled.has(Service::Prometheus)); +/// assert!(enabled.has(Service::Grafana)); +/// assert!(!enabled.has(Service::Caddy)); +/// assert!(!enabled.has(Service::MySQL)); +/// ``` +#[derive(Debug, Clone, Default)] +pub struct EnabledServices { + /// Set of enabled services in this topology + services: HashSet, +} + +impl EnabledServices { + /// Creates a new set of enabled services from a slice + /// + /// This is the primary constructor. The services slice represents + /// which optional services are present in the deployment. + /// + /// # Examples + /// + /// ```rust + /// use torrust_tracker_deployer_lib::domain::topology::{EnabledServices, Service}; + /// + /// let enabled = EnabledServices::from(&[Service::Prometheus, Service::MySQL]); + /// + /// assert!(enabled.has(Service::Prometheus)); + /// assert!(enabled.has(Service::MySQL)); + /// ``` + #[must_use] + pub fn from(services: &[Service]) -> Self { + Self { + services: services.iter().copied().collect(), + } + } + + /// Checks if a specific service is enabled + /// + /// # Examples + /// + /// ```rust + /// use torrust_tracker_deployer_lib::domain::topology::{EnabledServices, Service}; + /// + /// let enabled = EnabledServices::from(&[Service::Caddy]); + /// + /// assert!(enabled.has(Service::Caddy)); + /// assert!(!enabled.has(Service::MySQL)); + /// ``` + #[must_use] + pub fn has(&self, service: Service) -> bool { + self.services.contains(&service) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + mod from_constructor { + use super::*; + + #[test] + fn it_should_create_empty_set_when_given_empty_slice() { + let enabled = EnabledServices::from(&[]); + + assert!(!enabled.has(Service::Prometheus)); + assert!(!enabled.has(Service::Grafana)); + assert!(!enabled.has(Service::Caddy)); + assert!(!enabled.has(Service::MySQL)); + assert!(!enabled.has(Service::Tracker)); + } + + #[test] + fn it_should_create_set_with_single_service_when_given_one_service() { + let enabled = EnabledServices::from(&[Service::Prometheus]); + + assert!(enabled.has(Service::Prometheus)); + assert!(!enabled.has(Service::Grafana)); + } + + #[test] + fn it_should_create_set_with_multiple_services_when_given_multiple_services() { + let enabled = + EnabledServices::from(&[Service::Prometheus, Service::Grafana, Service::MySQL]); + + assert!(enabled.has(Service::Prometheus)); + assert!(enabled.has(Service::Grafana)); + assert!(enabled.has(Service::MySQL)); + assert!(!enabled.has(Service::Caddy)); + assert!(!enabled.has(Service::Tracker)); + } + + #[test] + fn it_should_deduplicate_when_given_duplicate_services() { + let enabled = EnabledServices::from(&[ + Service::Prometheus, + Service::Prometheus, + Service::Grafana, + ]); + + assert!(enabled.has(Service::Prometheus)); + assert!(enabled.has(Service::Grafana)); + } + + #[test] + fn it_should_include_all_service_types_when_given_all_services() { + let enabled = EnabledServices::from(Service::all()); + + for service in Service::all() { + assert!(enabled.has(*service), "Expected {service:?} to be present"); + } + } + } + + mod has_method { + use super::*; + + #[test] + fn it_should_return_true_when_service_is_present() { + let enabled = EnabledServices::from(&[Service::Caddy]); + + assert!(enabled.has(Service::Caddy)); + } + + #[test] + fn it_should_return_false_when_service_is_absent() { + let enabled = EnabledServices::from(&[Service::Prometheus]); + + assert!(!enabled.has(Service::Caddy)); + } + + #[test] + fn it_should_return_false_for_all_services_when_empty() { + let enabled = EnabledServices::from(&[]); + + for service in Service::all() { + assert!(!enabled.has(*service), "Expected {service:?} to be absent"); + } + } + } + + mod default_trait { + use super::*; + + #[test] + fn it_should_create_empty_set_when_using_default() { + let enabled = EnabledServices::default(); + + for service in Service::all() { + assert!(!enabled.has(*service), "Expected {service:?} to be absent"); + } + } + } + + mod clone_trait { + use super::*; + + #[test] + fn it_should_create_independent_copy_when_cloned() { + let original = EnabledServices::from(&[Service::Prometheus, Service::Grafana]); + let cloned = original.clone(); + + assert!(cloned.has(Service::Prometheus)); + assert!(cloned.has(Service::Grafana)); + assert!(!cloned.has(Service::MySQL)); + } + } +} diff --git a/src/domain/topology/mod.rs b/src/domain/topology/mod.rs index 4e41d4f3..758b48d3 100644 --- a/src/domain/topology/mod.rs +++ b/src/domain/topology/mod.rs @@ -20,16 +20,22 @@ //! - [`DockerComposeTopology`] - Aggregate that derives required networks from services //! - [`ServiceTopology`] - Topology information for a single service //! - [`TopologyError`] - Validation errors (e.g., port conflicts) +//! - [`PortDerivation`] - Trait for services that derive their port bindings +//! - [`NetworkDerivation`] - Trait for services that derive their network assignments pub mod aggregate; +pub mod enabled_services; pub mod error; pub mod network; pub mod port; pub mod service; +pub mod traits; // Re-export main types for convenience pub use aggregate::{DockerComposeTopology, ServiceTopology}; +pub use enabled_services::EnabledServices; pub use error::{PortConflict, TopologyError}; pub use network::Network; pub use port::PortBinding; pub use service::Service; +pub use traits::{NetworkDerivation, PortDerivation}; diff --git a/src/domain/topology/traits.rs b/src/domain/topology/traits.rs new file mode 100644 index 00000000..57d2e9b7 --- /dev/null +++ b/src/domain/topology/traits.rs @@ -0,0 +1,92 @@ +//! Traits for service topology participation +//! +//! This module defines traits that allow domain objects to participate +//! in Docker Compose topology construction. +//! +//! ## Design Principles +//! +//! - **Open/Closed Principle**: Port derivation is local to each service config. +//! Adding a new service doesn't require modifying existing services. +//! +//! - **DDD Layer Separation**: Business rules for port exposure live in the domain, +//! not in infrastructure template rendering code. +//! +//! - **Service-Local Logic**: Port derivation can be computed from a service's own +//! configuration without knowledge of other services. + +use super::enabled_services::EnabledServices; +use super::Network; +use super::PortBinding; + +/// Trait for services that can derive their port bindings +/// +/// This trait enables the Open/Closed principle: each service +/// encapsulates its own port derivation logic without requiring +/// changes to other services or the topology builder. +/// +/// # Implementors +/// +/// - [`TrackerConfig`](crate::domain::tracker::TrackerConfig) - derives ports based on TLS settings +/// - [`GrafanaConfig`](crate::domain::grafana::GrafanaConfig) - port 3000 only without TLS +/// - [`PrometheusConfig`](crate::domain::prometheus::PrometheusConfig) - port 9090 localhost-only +/// +/// # Port Rules Reference +/// +/// Each implementation applies the relevant PORT-* rules from the refactoring plan: +/// +/// | Rule | Service | Description | +/// |---------|------------|------------------------------------------------| +/// | PORT-02 | Tracker | UDP ports always exposed (no TLS for UDP) | +/// | PORT-03 | Tracker | HTTP ports WITHOUT TLS exposed directly | +/// | PORT-04 | Tracker | HTTP ports WITH TLS NOT exposed (Caddy) | +/// | PORT-05 | Tracker | API exposed only when no TLS | +/// | PORT-06 | Tracker | API NOT exposed when TLS | +/// | PORT-07 | Grafana | Port 3000 exposed only without TLS | +/// | PORT-08 | Grafana | Port 3000 NOT exposed with TLS | +/// | PORT-10 | Prometheus | Port 9090 on localhost only | +pub trait PortDerivation { + /// Derives port bindings based on service configuration + /// + /// The implementation should apply all PORT-* rules relevant + /// to this service (e.g., hiding ports when TLS is enabled). + /// + /// # Returns + /// + /// A vector of [`PortBinding`] that should be exposed in Docker Compose. + /// An empty vector means the service has no exposed ports. + fn derive_ports(&self) -> Vec; +} + +/// Trait for services that can derive their network assignments +/// +/// This trait enables domain-driven network computation: each service +/// determines which networks it needs based on its configuration and +/// the topology context (which other services are enabled). +/// +/// # Network Rules Reference +/// +/// Each implementation applies the relevant NET-* rules: +/// +/// | Rule | Service | Description | +/// |--------|------------|------------------------------------------------| +/// | NET-01 | Tracker | Metrics network if Prometheus enabled | +/// | NET-02 | Tracker | Database network if MySQL enabled | +/// | NET-03 | Tracker | Proxy network if Caddy enabled | +/// | NET-04 | Prometheus | Metrics network always | +/// | NET-05 | Prometheus | Visualization network if Grafana enabled | +/// | NET-06 | Grafana | Visualization network always | +/// | NET-07 | Grafana | Proxy network if Caddy enabled | +/// | NET-08 | MySQL | Database network always | +/// | NET-09 | Caddy | Proxy network always | +pub trait NetworkDerivation { + /// Derives network assignments based on service configuration and topology + /// + /// # Arguments + /// + /// * `enabled_services` - Information about which other services are enabled + /// + /// # Returns + /// + /// A vector of [`Network`] that this service should be connected to. + fn derive_networks(&self, enabled_services: &EnabledServices) -> Vec; +} diff --git a/src/domain/tracker/config/mod.rs b/src/domain/tracker/config/mod.rs index 4c194dab..be1efcd8 100644 --- a/src/domain/tracker/config/mod.rs +++ b/src/domain/tracker/config/mod.rs @@ -10,6 +10,9 @@ use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr}; use serde::{Deserialize, Serialize}; use super::{BindingAddress, Protocol}; +use crate::domain::topology::{ + EnabledServices, Network, NetworkDerivation, PortBinding, PortDerivation, Service, +}; use crate::shared::DomainName; mod core; @@ -509,6 +512,80 @@ impl TrackerConfig { } } +impl PortDerivation for TrackerConfig { + /// Derives port bindings for the Tracker service + /// + /// Implements PORT-01 through PORT-06: + /// - PORT-01: Tracker needs ports if UDP OR HTTP without TLS OR API without TLS + /// - PORT-02: UDP ports always exposed (UDP doesn't use TLS) + /// - PORT-03: HTTP ports WITHOUT TLS exposed directly + /// - PORT-04: HTTP ports WITH TLS NOT exposed (Caddy handles) + /// - PORT-05: API port exposed only when no TLS + /// - PORT-06: API port NOT exposed when TLS + fn derive_ports(&self) -> Vec { + let mut ports = Vec::new(); + + // PORT-02: UDP ports always exposed (UDP doesn't use TLS) + for udp_tracker in &self.udp_trackers { + ports.push(PortBinding::udp( + udp_tracker.bind_address().port(), + "BitTorrent UDP announce", + )); + } + + // PORT-03: HTTP ports WITHOUT TLS exposed directly + // PORT-04: HTTP ports WITH TLS NOT exposed (Caddy handles) + for http_tracker in &self.http_trackers { + if !http_tracker.use_tls_proxy() { + ports.push(PortBinding::tcp( + http_tracker.bind_address().port(), + "HTTP tracker announce", + )); + } + } + + // PORT-05: API exposed only when no TLS + // PORT-06: API NOT exposed when TLS + if !self.http_api.use_tls_proxy() { + ports.push(PortBinding::tcp( + self.http_api.bind_address().port(), + "HTTP API (stats/whitelist)", + )); + } + + ports + } +} + +impl NetworkDerivation for TrackerConfig { + /// Derives network assignments for the Tracker service + /// + /// Implements NET-01 through NET-03: + /// - NET-01: Metrics network if Prometheus enabled + /// - NET-02: Database network if `MySQL` enabled + /// - NET-03: Proxy network if Caddy enabled + fn derive_networks(&self, enabled_services: &EnabledServices) -> Vec { + let mut networks = Vec::new(); + + // NET-01: Metrics network if Prometheus enabled + if enabled_services.has(Service::Prometheus) { + networks.push(Network::Metrics); + } + + // NET-02: Database network if MySQL enabled + if enabled_services.has(Service::MySQL) { + networks.push(Network::Database); + } + + // NET-03: Proxy network if Caddy enabled + if enabled_services.has(Service::Caddy) { + networks.push(Network::Proxy); + } + + networks + } +} + /// Trait for types that have a bind address /// /// Used for generic tracker registration in validation logic. @@ -1135,4 +1212,168 @@ mod tests { assert!(config.http_api().use_tls_proxy()); } } + + // ========================================================================= + // Port derivation tests (PORT-01 through PORT-06) + // ========================================================================= + + mod port_derivation { + use super::*; + + fn default_core() -> TrackerCoreConfig { + TrackerCoreConfig::new( + DatabaseConfig::Sqlite(SqliteConfig::new("tracker.db").unwrap()), + false, + ) + } + + #[test] + fn it_should_expose_udp_ports_always() { + // PORT-02: UDP ports always exposed (UDP doesn't use TLS) + let config = TrackerConfig::new( + default_core(), + vec![ + test_udp_tracker_config("0.0.0.0:6969"), + test_udp_tracker_config("0.0.0.0:6868"), + ], + vec![], + test_http_api_config("0.0.0.0:1212", "token"), + test_health_check_api_config("127.0.0.1:1313"), + ) + .unwrap(); + + let ports = config.derive_ports(); + let udp_ports: Vec<_> = ports + .iter() + .filter(|p| p.protocol() == Protocol::Udp) + .collect(); + + assert_eq!(udp_ports.len(), 2); + assert_eq!(udp_ports[0].host_port(), 6969); + assert_eq!(udp_ports[1].host_port(), 6868); + } + + #[test] + fn it_should_expose_http_ports_without_tls() { + // PORT-03: HTTP ports WITHOUT TLS exposed directly + let config = TrackerConfig::new( + default_core(), + vec![], + vec![ + test_http_tracker_config("0.0.0.0:7070"), + test_http_tracker_config("0.0.0.0:8080"), + ], + test_http_api_config("0.0.0.0:1212", "token"), + test_health_check_api_config("127.0.0.1:1313"), + ) + .unwrap(); + + let ports = config.derive_ports(); + // Should have 2 HTTP ports + 1 API port + let tcp_ports: Vec<_> = ports + .iter() + .filter(|p| p.protocol() == Protocol::Tcp) + .collect(); + + assert_eq!(tcp_ports.len(), 3); + assert!(tcp_ports.iter().any(|p| p.host_port() == 7070)); + assert!(tcp_ports.iter().any(|p| p.host_port() == 8080)); + } + + #[test] + fn it_should_not_expose_http_ports_with_tls() { + // PORT-04: HTTP ports WITH TLS NOT exposed (Caddy handles) + let domain = crate::shared::DomainName::new("tracker.example.com").unwrap(); + let config = TrackerConfig::new( + default_core(), + vec![], + vec![test_http_tracker_config_with_tls( + "0.0.0.0:7070", + Some(domain), + true, + )], + test_http_api_config("0.0.0.0:1212", "token"), + test_health_check_api_config("127.0.0.1:1313"), + ) + .unwrap(); + + let ports = config.derive_ports(); + // Should only have API port (7070 is hidden behind TLS) + assert!(ports.iter().all(|p| p.host_port() != 7070)); + } + + #[test] + fn it_should_expose_api_port_without_tls() { + // PORT-05: API exposed only when no TLS + let config = TrackerConfig::new( + default_core(), + vec![], + vec![], + test_http_api_config("0.0.0.0:1212", "token"), + test_health_check_api_config("127.0.0.1:1313"), + ) + .unwrap(); + + let ports = config.derive_ports(); + let api_port = ports.iter().find(|p| p.host_port() == 1212); + + assert!(api_port.is_some()); + assert_eq!( + api_port.unwrap().description(), + "HTTP API (stats/whitelist)" + ); + } + + #[test] + fn it_should_not_expose_api_port_with_tls() { + // PORT-06: API NOT exposed when TLS + let domain = crate::shared::DomainName::new("api.example.com").unwrap(); + let config = TrackerConfig::new( + default_core(), + 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"), + ) + .unwrap(); + + let ports = config.derive_ports(); + // API port should not be exposed when TLS is enabled + assert!(ports.iter().all(|p| p.host_port() != 1212)); + } + + #[test] + fn it_should_return_empty_when_all_ports_hidden_by_tls() { + // All services behind TLS = no exposed ports from tracker + let api_domain = crate::shared::DomainName::new("api.example.com").unwrap(); + let tracker_domain = crate::shared::DomainName::new("tracker.example.com").unwrap(); + + let config = TrackerConfig::new( + default_core(), + vec![], // No UDP + vec![test_http_tracker_config_with_tls( + "0.0.0.0:7070", + Some(tracker_domain), + true, + )], + test_http_api_config_with_tls("0.0.0.0:1212", "token", Some(api_domain), true), + test_health_check_api_config("127.0.0.1:1313"), + ) + .unwrap(); + + let ports = config.derive_ports(); + assert!(ports.is_empty()); + } + + #[test] + fn it_should_include_descriptions_for_all_ports() { + let config = TrackerConfig::default(); + + let ports = config.derive_ports(); + + for port in &ports { + assert!(!port.description().is_empty()); + } + } + } } diff --git a/src/infrastructure/templating/docker_compose/template/renderer/docker_compose.rs b/src/infrastructure/templating/docker_compose/template/renderer/docker_compose.rs index 98ecb247..970ec758 100644 --- a/src/infrastructure/templating/docker_compose/template/renderer/docker_compose.rs +++ b/src/infrastructure/templating/docker_compose/template/renderer/docker_compose.rs @@ -188,21 +188,45 @@ mod tests { use super::*; use tempfile::TempDir; + use crate::domain::topology::EnabledServices; + use crate::domain::tracker::DatabaseConfig as TrackerDatabaseConfig; + use crate::domain::tracker::{ + HealthCheckApiConfig, HttpApiConfig, HttpTrackerConfig, SqliteConfig, TrackerConfig, + TrackerCoreConfig, UdpTrackerConfig, + }; use crate::infrastructure::templating::docker_compose::template::wrappers::docker_compose::{ - DockerComposeContext, MysqlSetupConfig, TrackerServiceConfig, + DockerComposeContext, MysqlSetupConfig, TrackerServiceContext, }; - /// Helper to create `TrackerServiceConfig` for tests (no TLS, no networks) - fn test_tracker_config() -> TrackerServiceConfig { - TrackerServiceConfig::new( - vec![6868, 6969], // UDP ports - vec![7070], // HTTP ports without TLS - 1212, // API port - false, // API has no TLS - false, // has_prometheus - false, // has_mysql - false, // has_caddy + /// Helper to create a domain `TrackerConfig` for tests + fn test_domain_tracker_config() -> TrackerConfig { + TrackerConfig::new( + TrackerCoreConfig::new( + TrackerDatabaseConfig::Sqlite(SqliteConfig::new("tracker.db").unwrap()), + false, + ), + vec![ + UdpTrackerConfig::new("0.0.0.0:6868".parse().unwrap(), None).unwrap(), + 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(), + "TestToken".to_string().into(), + None, + false, + ) + .unwrap(), + HealthCheckApiConfig::new("127.0.0.1:1313".parse().unwrap(), None, false).unwrap(), ) + .unwrap() + } + + /// Helper to create `TrackerServiceContext` for tests (no TLS, no networks) + fn test_tracker_config() -> TrackerServiceContext { + let domain_config = test_domain_tracker_config(); + let context = EnabledServices::from(&[]); + TrackerServiceContext::from_domain_config(&domain_config, &context) } #[test] @@ -349,30 +373,25 @@ mod tests { #[test] fn it_should_render_prometheus_service_when_config_is_present() { use crate::domain::prometheus::PrometheusConfig; + use crate::domain::topology::Service; let temp_dir = TempDir::new().unwrap(); let template_manager = Arc::new(TemplateManager::new(temp_dir.path())); // Create tracker config with prometheus enabled - let tracker = TrackerServiceConfig::new( - vec![6868, 6969], // UDP ports - vec![7070], // HTTP ports without TLS - 1212, // API port - false, // API has no TLS - true, // has_prometheus - false, // has_mysql - false, // has_caddy - ); + let domain_config = test_domain_tracker_config(); + let topology_context = EnabledServices::from(&[Service::Prometheus]); + let tracker = TrackerServiceContext::from_domain_config(&domain_config, &topology_context); let prometheus_config = PrometheusConfig::new(std::num::NonZeroU32::new(15).expect("15 is non-zero")); - let context = DockerComposeContext::builder(tracker) + let compose_context = DockerComposeContext::builder(tracker) .with_prometheus(prometheus_config) .build(); let renderer = DockerComposeRenderer::new(template_manager); let output_dir = TempDir::new().unwrap(); - let result = renderer.render(&context, output_dir.path()); + let result = renderer.render(&compose_context, output_dir.path()); assert!( result.is_ok(), "Rendering with Prometheus context should succeed" diff --git a/src/infrastructure/templating/docker_compose/template/renderer/project_generator.rs b/src/infrastructure/templating/docker_compose/template/renderer/project_generator.rs index 495e53f6..1462d135 100644 --- a/src/infrastructure/templating/docker_compose/template/renderer/project_generator.rs +++ b/src/infrastructure/templating/docker_compose/template/renderer/project_generator.rs @@ -185,7 +185,9 @@ mod tests { use tempfile::TempDir; use super::*; - use crate::infrastructure::templating::docker_compose::template::wrappers::docker_compose::TrackerServiceConfig; + use crate::domain::topology::EnabledServices; + use crate::domain::tracker::TrackerConfig; + use crate::infrastructure::templating::docker_compose::template::wrappers::docker_compose::TrackerServiceContext; use crate::infrastructure::templating::docker_compose::DOCKER_COMPOSE_SUBFOLDER; /// Creates a `TemplateManager` that uses the embedded templates @@ -206,16 +208,10 @@ mod tests { /// Helper function to create a test docker-compose context with `SQLite` fn create_test_docker_compose_context_sqlite() -> DockerComposeContext { - // Use default test ports (matching TrackerConfig::default()) - let tracker = TrackerServiceConfig::new( - vec![6969], // UDP ports - vec![7070], // HTTP ports without TLS - 1212, // API port - false, // API has no TLS - false, // has_prometheus - false, // has_mysql - false, // has_caddy - ); + // Use default TrackerConfig from domain layer + let tracker_config = TrackerConfig::default(); + let context = EnabledServices::from(&[]); + let tracker = TrackerServiceContext::from_domain_config(&tracker_config, &context); DockerComposeContext::builder(tracker).build() } diff --git a/src/infrastructure/templating/docker_compose/template/wrappers/docker_compose/context/builder.rs b/src/infrastructure/templating/docker_compose/template/wrappers/docker_compose/context/builder.rs index 0cedd591..57f0944a 100644 --- a/src/infrastructure/templating/docker_compose/template/wrappers/docker_compose/context/builder.rs +++ b/src/infrastructure/templating/docker_compose/template/wrappers/docker_compose/context/builder.rs @@ -5,16 +5,16 @@ use std::collections::{HashMap, HashSet}; // Internal crate use crate::domain::grafana::GrafanaConfig; use crate::domain::prometheus::PrometheusConfig; -use crate::domain::topology::Network; +use crate::domain::topology::{EnabledServices, Network, Service}; -use super::caddy::CaddyServiceConfig; +use super::caddy::CaddyServiceContext; use super::database::{DatabaseConfig, MysqlSetupConfig, DRIVER_MYSQL, DRIVER_SQLITE}; -use super::grafana::GrafanaServiceConfig; -use super::mysql::MysqlServiceConfig; +use super::grafana::GrafanaServiceContext; +use super::mysql::MysqlServiceContext; use super::network_definition::NetworkDefinition; use super::port_definition::PortDefinition; -use super::prometheus::PrometheusServiceConfig; -use super::{DockerComposeContext, TrackerServiceConfig}; +use super::prometheus::PrometheusServiceContext; +use super::{DockerComposeContext, TrackerServiceContext}; /// Builder for `DockerComposeContext` /// @@ -24,7 +24,7 @@ use super::{DockerComposeContext, TrackerServiceConfig}; /// The builder collects domain configuration objects and transforms them into /// service configuration objects with pre-computed networks at build time. pub struct DockerComposeContextBuilder { - tracker: TrackerServiceConfig, + tracker: TrackerServiceContext, database: DatabaseConfig, prometheus_config: Option, grafana_config: Option, @@ -33,7 +33,7 @@ pub struct DockerComposeContextBuilder { impl DockerComposeContextBuilder { /// Creates a new builder with default `SQLite` configuration - pub(super) fn new(tracker: TrackerServiceConfig) -> Self { + pub(super) fn new(tracker: TrackerServiceContext) -> Self { Self { tracker, database: DatabaseConfig { @@ -138,33 +138,48 @@ impl DockerComposeContextBuilder { fn build_internal(self) -> DockerComposeContext { let has_grafana = self.grafana_config.is_some(); let has_caddy = self.has_caddy; + let has_prometheus = self.prometheus_config.is_some(); + let has_mysql = self.database.driver == DRIVER_MYSQL; + + // Build list of enabled services for topology context + let mut enabled_services = Vec::new(); + if has_prometheus { + enabled_services.push(Service::Prometheus); + } + if has_grafana { + enabled_services.push(Service::Grafana); + } + if has_mysql { + enabled_services.push(Service::MySQL); + } + if has_caddy { + enabled_services.push(Service::Caddy); + } + + let topology_context = EnabledServices::from(&enabled_services); // Build Prometheus service config if enabled - let prometheus = self.prometheus_config.map(|config| { - PrometheusServiceConfig::new(config.scrape_interval_in_secs(), has_grafana) - }); + let prometheus = self + .prometheus_config + .as_ref() + .map(|config| PrometheusServiceContext::from_domain_config(config, &topology_context)); // Build Grafana service config if enabled - let grafana = self.grafana_config.map(|config| { - let has_tls = config.use_tls_proxy(); - GrafanaServiceConfig::new( - config.admin_user().to_string(), - config.admin_password().clone(), - has_tls, - has_caddy, - ) - }); + let grafana = self + .grafana_config + .as_ref() + .map(|config| GrafanaServiceContext::from_domain_config(config, &topology_context)); // Build Caddy service config if enabled let caddy = if has_caddy { - Some(CaddyServiceConfig::new()) + Some(CaddyServiceContext::new()) } else { None }; // Build MySQL service config if enabled let mysql = if self.database.driver == DRIVER_MYSQL { - Some(MysqlServiceConfig::new()) + Some(MysqlServiceContext::new()) } else { None }; @@ -194,29 +209,29 @@ impl DockerComposeContextBuilder { /// Collects networks from all enabled services, deduplicates them, /// and returns in deterministic alphabetical order. fn derive_required_networks( - tracker: &TrackerServiceConfig, - prometheus: Option<&PrometheusServiceConfig>, - grafana: Option<&GrafanaServiceConfig>, - caddy: Option<&CaddyServiceConfig>, - mysql: Option<&MysqlServiceConfig>, + tracker: &TrackerServiceContext, + prometheus: Option<&PrometheusServiceContext>, + grafana: Option<&GrafanaServiceContext>, + caddy: Option<&CaddyServiceContext>, + mysql: Option<&MysqlServiceContext>, ) -> Vec { let mut networks: HashSet = HashSet::new(); // Collect from tracker (always present) - networks.extend(tracker.networks.iter().copied()); + networks.extend(tracker.networks().iter().copied()); // Collect from optional services if let Some(prom) = prometheus { - networks.extend(prom.networks.iter().copied()); + networks.extend(prom.networks().iter().copied()); } if let Some(graf) = grafana { - networks.extend(graf.networks.iter().copied()); + networks.extend(graf.networks().iter().copied()); } if let Some(cad) = caddy { - networks.extend(cad.networks.iter().copied()); + networks.extend(cad.networks().iter().copied()); } if let Some(my) = mysql { - networks.extend(my.networks.iter().copied()); + networks.extend(my.networks().iter().copied()); } // Sort for deterministic output (alphabetically by name) @@ -248,22 +263,22 @@ impl DockerComposeContextBuilder { // Collect ports with service names let service_ports: Vec<(&'static str, &[PortDefinition])> = vec![ - ("tracker", &context.tracker.ports), + ("tracker", context.tracker.ports()), ( "prometheus", - context.prometheus.as_ref().map_or(&[][..], |p| &p.ports), + context.prometheus.as_ref().map_or(&[][..], |p| p.ports()), ), ( "grafana", - context.grafana.as_ref().map_or(&[][..], |g| &g.ports), + context.grafana.as_ref().map_or(&[][..], |g| g.ports()), ), ( "caddy", - context.caddy.as_ref().map_or(&[][..], |c| &c.ports), + context.caddy.as_ref().map_or(&[][..], |c| c.ports()), ), ( "mysql", - context.mysql.as_ref().map_or(&[][..], |m| &m.ports), + context.mysql.as_ref().map_or(&[][..], |m| m.ports()), ), ]; @@ -374,29 +389,85 @@ impl std::error::Error for PortConflictError {} mod tests { use super::*; use crate::domain::prometheus::PrometheusConfig; + use crate::domain::topology::EnabledServices; + use crate::domain::tracker::{ + DatabaseConfig as TrackerDatabaseConfig, HealthCheckApiConfig, HttpApiConfig, + HttpTrackerConfig, SqliteConfig, TrackerConfig, TrackerCoreConfig, UdpTrackerConfig, + }; + + /// Helper to create a minimal domain tracker config with TLS (API not exposed) + fn minimal_domain_tracker_config() -> TrackerConfig { + use crate::shared::DomainName; + TrackerConfig::new( + TrackerCoreConfig::new( + TrackerDatabaseConfig::Sqlite(SqliteConfig::new("tracker.db").unwrap()), + false, + ), + vec![], // No UDP trackers + vec![], // No HTTP trackers + HttpApiConfig::new( + "0.0.0.0:1212".parse().unwrap(), + "TestToken".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() + } - /// Helper to create a minimal tracker config - fn minimal_tracker_config() -> TrackerServiceConfig { - TrackerServiceConfig::new( - vec![], // No UDP ports - vec![], // No HTTP ports - 1212, // API port - true, // TLS enabled (so API port not exposed) - false, // No Prometheus - false, // No MySQL - false, // No Caddy + /// Helper to create a domain tracker config with specific UDP and HTTP ports + fn domain_tracker_config_with_ports( + udp_ports: Vec, + http_ports: Vec, + ) -> TrackerConfig { + use crate::shared::DomainName; + TrackerConfig::new( + TrackerCoreConfig::new( + TrackerDatabaseConfig::Sqlite(SqliteConfig::new("tracker.db").unwrap()), + false, + ), + udp_ports + .into_iter() + .map(|p| { + UdpTrackerConfig::new(format!("0.0.0.0:{p}").parse().unwrap(), None).unwrap() + }) + .collect(), + http_ports + .into_iter() + .map(|p| { + HttpTrackerConfig::new(format!("0.0.0.0:{p}").parse().unwrap(), None, false) + .unwrap() + }) + .collect(), + HttpApiConfig::new( + "0.0.0.0:1212".parse().unwrap(), + "TestToken".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() + } + + /// Helper to create a minimal tracker config + fn minimal_tracker_config() -> TrackerServiceContext { + let domain_config = minimal_domain_tracker_config(); + let context = EnabledServices::from(&[]); + TrackerServiceContext::from_domain_config(&domain_config, &context) } /// Helper to create a tracker config that exposes specific ports fn tracker_config_with_ports( udp_ports: Vec, http_ports: Vec, - ) -> TrackerServiceConfig { - TrackerServiceConfig::new( - udp_ports, http_ports, 1212, true, // TLS enabled - false, false, false, - ) + ) -> TrackerServiceContext { + let domain_config = domain_tracker_config_with_ports(udp_ports, http_ports); + let context = EnabledServices::from(&[]); + TrackerServiceContext::from_domain_config(&domain_config, &context) } // ========================================================================== diff --git a/src/infrastructure/templating/docker_compose/template/wrappers/docker_compose/context/caddy.rs b/src/infrastructure/templating/docker_compose/template/wrappers/docker_compose/context/caddy.rs index ab9cacad..6b4070da 100644 --- a/src/infrastructure/templating/docker_compose/template/wrappers/docker_compose/context/caddy.rs +++ b/src/infrastructure/templating/docker_compose/template/wrappers/docker_compose/context/caddy.rs @@ -5,10 +5,10 @@ //! //! ## Note on Context Separation //! -//! This type (`CaddyServiceConfig`) is separate from the `CaddyContext` used +//! This type (`CaddyServiceContext`) is separate from the `CaddyContext` used //! for rendering the Caddyfile.tera template. Each template has its own context: //! -//! - `CaddyServiceConfig` (this module): For docker-compose.yml service definition +//! - `CaddyServiceContext` (this module): For docker-compose.yml service definition //! - `CaddyContext` (in caddy/template/wrapper): For Caddyfile content with domains/ports //! //! The docker-compose template only needs to know that Caddy is enabled @@ -16,10 +16,11 @@ use serde::Serialize; -use crate::domain::topology::Network; +use crate::domain::caddy::CaddyConfig; +use crate::domain::topology::{EnabledServices, Network, NetworkDerivation, PortDerivation}; use super::port_definition::PortDefinition; -use super::port_derivation::derive_caddy_ports; +use super::service_topology::ServiceTopology; /// Caddy reverse proxy service configuration for Docker Compose /// @@ -27,52 +28,71 @@ use super::port_derivation::derive_caddy_ports; /// This is intentionally minimal - the actual Caddy configuration (domains, ports) /// is in the Caddyfile, rendered separately. /// +/// Uses `ServiceTopology` to share the common topology structure with other services. +/// /// # Example /// /// ```rust -/// use torrust_tracker_deployer_lib::infrastructure::templating::docker_compose::template::wrappers::docker_compose::context::CaddyServiceConfig; -/// use torrust_tracker_deployer_lib::domain::topology::Network; +/// use torrust_tracker_deployer_lib::infrastructure::templating::docker_compose::template::wrappers::docker_compose::context::CaddyServiceContext; +/// use torrust_tracker_deployer_lib::domain::caddy::CaddyConfig; +/// use torrust_tracker_deployer_lib::domain::topology::{EnabledServices, Network}; /// -/// let caddy = CaddyServiceConfig::new(); -/// assert_eq!(caddy.networks, vec![Network::Proxy]); -/// assert_eq!(caddy.ports.len(), 3); // 80, 443, 443/udp +/// let caddy = CaddyServiceContext::from_domain_config(&CaddyConfig::new(), &EnabledServices::default()); +/// assert_eq!(caddy.networks(), &[Network::Proxy]); +/// assert_eq!(caddy.ports().len(), 3); // 80, 443, 443/udp /// ``` #[derive(Debug, Clone, Serialize, PartialEq)] -pub struct CaddyServiceConfig { - /// Port bindings for Docker Compose - /// - /// Caddy exposes ports 80 (HTTP for ACME), 443 (HTTPS), and 443/udp (QUIC). - pub ports: Vec, - /// Networks this service connects to +pub struct CaddyServiceContext { + /// Service topology (ports and networks) /// - /// Caddy always connects to `proxy_network` for reverse proxying - /// to backend services (tracker API, HTTP trackers, Grafana). - pub networks: Vec, + /// Flattened for template compatibility - serializes ports/networks at top level. + #[serde(flatten)] + pub topology: ServiceTopology, } -impl CaddyServiceConfig { - /// Creates a new `CaddyServiceConfig` with derived ports and default networks +impl CaddyServiceContext { + /// Creates a new `CaddyServiceContext` from domain configuration + /// + /// Uses the domain `PortDerivation` and `NetworkDerivation` traits, + /// ensuring business rules live in the domain layer. /// - /// Caddy connects to: - /// - `proxy_network`: For reverse proxying to backend services + /// # Arguments /// - /// Caddy exposes: - /// - Port 80: HTTP for ACME challenge - /// - Port 443: HTTPS - /// - Port 443/udp: HTTP/3 QUIC + /// * `config` - The domain Caddy configuration + /// * `enabled_services` - Topology context with information about enabled services #[must_use] - pub fn new() -> Self { - let port_bindings = derive_caddy_ports(); + pub fn from_domain_config(config: &CaddyConfig, enabled_services: &EnabledServices) -> Self { + let port_bindings = config.derive_ports(); let ports = port_bindings.iter().map(PortDefinition::from).collect(); + let networks = config.derive_networks(enabled_services); Self { - ports, - networks: vec![Network::Proxy], + topology: ServiceTopology::new(ports, networks), } } + + /// Creates a new `CaddyServiceContext` with default configuration + /// + /// Convenience method that creates a default `CaddyConfig` and empty enabled services. + #[must_use] + pub fn new() -> Self { + Self::from_domain_config(&CaddyConfig::new(), &EnabledServices::default()) + } + + /// Returns a reference to the port bindings + #[must_use] + pub fn ports(&self) -> &[PortDefinition] { + &self.topology.ports + } + + /// Returns a reference to the networks + #[must_use] + pub fn networks(&self) -> &[Network] { + &self.topology.networks + } } -impl Default for CaddyServiceConfig { +impl Default for CaddyServiceContext { fn default() -> Self { Self::new() } @@ -84,21 +104,21 @@ mod tests { #[test] fn it_should_connect_caddy_to_proxy_network() { - let caddy = CaddyServiceConfig::new(); + let caddy = CaddyServiceContext::new(); - assert_eq!(caddy.networks, vec![Network::Proxy]); + assert_eq!(caddy.networks(), &[Network::Proxy]); } #[test] fn it_should_implement_default() { - let caddy = CaddyServiceConfig::default(); + let caddy = CaddyServiceContext::default(); - assert_eq!(caddy.networks, vec![Network::Proxy]); + assert_eq!(caddy.networks(), &[Network::Proxy]); } #[test] fn it_should_serialize_network_to_name_string() { - let caddy = CaddyServiceConfig::new(); + let caddy = CaddyServiceContext::new(); let json = serde_json::to_value(&caddy).expect("serialization should succeed"); @@ -108,14 +128,14 @@ mod tests { #[test] fn it_should_expose_three_ports() { - let caddy = CaddyServiceConfig::new(); + let caddy = CaddyServiceContext::new(); - assert_eq!(caddy.ports.len(), 3); + assert_eq!(caddy.ports().len(), 3); } #[test] fn it_should_serialize_ports_with_binding_and_description() { - let caddy = CaddyServiceConfig::new(); + let caddy = CaddyServiceContext::new(); let json = serde_json::to_value(&caddy).expect("serialization should succeed"); @@ -123,4 +143,24 @@ mod tests { assert!(json["ports"][0]["binding"].is_string()); assert!(json["ports"][0]["description"].is_string()); } + + #[test] + fn it_should_use_domain_traits_for_port_derivation() { + let config = CaddyConfig::new(); + let enabled_services = EnabledServices::default(); + let caddy = CaddyServiceContext::from_domain_config(&config, &enabled_services); + + // Verify ports come from domain trait + assert_eq!(caddy.ports().len(), 3); + } + + #[test] + fn it_should_use_domain_traits_for_network_derivation() { + let config = CaddyConfig::new(); + let enabled_services = EnabledServices::default(); + let caddy = CaddyServiceContext::from_domain_config(&config, &enabled_services); + + // Verify networks come from domain trait + assert_eq!(caddy.networks(), &[Network::Proxy]); + } } diff --git a/src/infrastructure/templating/docker_compose/template/wrappers/docker_compose/context/grafana.rs b/src/infrastructure/templating/docker_compose/template/wrappers/docker_compose/context/grafana.rs index f73de372..101467f6 100644 --- a/src/infrastructure/templating/docker_compose/template/wrappers/docker_compose/context/grafana.rs +++ b/src/infrastructure/templating/docker_compose/template/wrappers/docker_compose/context/grafana.rs @@ -3,116 +3,119 @@ // External crates use serde::Serialize; -use crate::domain::topology::Network; -use crate::shared::secrets::Password; +use crate::domain::grafana::GrafanaConfig; +use crate::domain::topology::{EnabledServices, Network, NetworkDerivation, PortDerivation}; use super::port_definition::PortDefinition; -use super::port_derivation::derive_grafana_ports; +use super::service_topology::ServiceTopology; /// Grafana service configuration for Docker Compose /// -/// Contains all configuration needed for the Grafana service in Docker Compose, -/// including admin credentials, TLS settings, port mappings, and network connections. -/// All logic is pre-computed in Rust to keep the Tera template simple. +/// Contains configuration needed for the Grafana service definition in docker-compose.yml. +/// Only includes fields actually used by the template (ports and networks). +/// Credentials are handled separately by the env context for .env template. +/// +/// Uses `ServiceTopology` to share the common topology structure with other services. #[derive(Serialize, Debug, Clone)] -pub struct GrafanaServiceConfig { - /// Grafana admin username - pub admin_user: String, - /// Grafana admin password - pub admin_password: Password, - /// Whether Grafana has TLS enabled (port should not be exposed if true) - #[serde(default)] - pub has_tls: bool, - /// Port bindings for Docker Compose - /// - /// When TLS is disabled, Grafana exposes port 3000 directly. - /// When TLS is enabled, Caddy handles the port and this is empty. - pub ports: Vec, - /// Networks the Grafana service should connect to +pub struct GrafanaServiceContext { + /// Service topology (ports and networks) /// - /// Pre-computed list based on enabled features: - /// - Always includes `visualization_network` (queries Prometheus) - /// - Includes `proxy_network` if Caddy TLS proxy is enabled - pub networks: Vec, + /// Flattened for template compatibility - serializes ports/networks at top level. + #[serde(flatten)] + pub topology: ServiceTopology, } -impl GrafanaServiceConfig { - /// Creates a new `GrafanaServiceConfig` with pre-computed networks and ports +impl GrafanaServiceContext { + /// Creates a new `GrafanaServiceContext` from domain configuration + /// + /// Uses the domain `PortDerivation` and `NetworkDerivation` traits, + /// ensuring business rules live in the domain layer. /// /// # Arguments /// - /// * `admin_user` - Grafana admin username - /// * `admin_password` - Grafana admin password - /// * `has_tls` - Whether Grafana has TLS enabled (via Caddy) - /// * `has_caddy` - Whether Caddy TLS proxy is enabled (adds `proxy_network`) + /// * `config` - The domain Grafana configuration + /// * `context` - Topology context with information about enabled services #[must_use] - pub fn new( - admin_user: String, - admin_password: Password, - has_tls: bool, - has_caddy: bool, - ) -> Self { - let networks = Self::compute_networks(has_caddy); - let port_bindings = derive_grafana_ports(has_tls); - let ports = port_bindings.iter().map(PortDefinition::from).collect(); - + pub fn from_domain_config(config: &GrafanaConfig, enabled_services: &EnabledServices) -> Self { + let networks = config.derive_networks(enabled_services); + let ports = config + .derive_ports() + .iter() + .map(PortDefinition::from) + .collect(); Self { - admin_user, - admin_password, - has_tls, - ports, - networks, + topology: ServiceTopology::new(ports, networks), } } - /// Computes the list of networks for the Grafana service - fn compute_networks(has_caddy: bool) -> Vec { - let mut networks = vec![Network::Visualization]; - - if has_caddy { - networks.push(Network::Proxy); - } + /// Returns a reference to the port bindings + #[must_use] + pub fn ports(&self) -> &[PortDefinition] { + &self.topology.ports + } - networks + /// Returns a reference to the networks + #[must_use] + pub fn networks(&self) -> &[Network] { + &self.topology.networks } } #[cfg(test)] mod tests { use super::*; + use crate::shared::DomainName; + + fn make_config(use_tls_proxy: bool) -> GrafanaConfig { + if use_tls_proxy { + GrafanaConfig::new( + "admin".to_string(), + "password".to_string(), + Some(DomainName::new("grafana.example.com").unwrap()), + true, + ) + } else { + GrafanaConfig::new("admin".to_string(), "password".to_string(), None, false) + } + } + + fn make_context(has_caddy: bool) -> EnabledServices { + if has_caddy { + EnabledServices::from(&[crate::domain::topology::Service::Caddy]) + } else { + EnabledServices::from(&[]) + } + } #[test] fn it_should_connect_grafana_to_visualization_network() { - let config = - GrafanaServiceConfig::new("admin".to_string(), Password::new("password"), false, false); + let context = make_context(false); + let config = GrafanaServiceContext::from_domain_config(&make_config(false), &context); - assert!(config.networks.contains(&Network::Visualization)); + assert!(config.networks().contains(&Network::Visualization)); } #[test] fn it_should_not_connect_grafana_to_proxy_network_when_caddy_disabled() { - let config = - GrafanaServiceConfig::new("admin".to_string(), Password::new("password"), false, false); + let context = make_context(false); + let config = GrafanaServiceContext::from_domain_config(&make_config(false), &context); - assert_eq!(config.networks, vec![Network::Visualization]); - assert!(!config.networks.contains(&Network::Proxy)); + assert_eq!(config.networks(), &[Network::Visualization]); + assert!(!config.networks().contains(&Network::Proxy)); } #[test] fn it_should_connect_grafana_to_proxy_network_when_caddy_enabled() { - let config = - GrafanaServiceConfig::new("admin".to_string(), Password::new("password"), true, true); + let context = make_context(true); + let config = GrafanaServiceContext::from_domain_config(&make_config(true), &context); - assert_eq!( - config.networks, - vec![Network::Visualization, Network::Proxy] - ); + assert_eq!(config.networks(), &[Network::Visualization, Network::Proxy]); } #[test] fn it_should_serialize_networks_to_name_strings() { - let config = - GrafanaServiceConfig::new("admin".to_string(), Password::new("password"), true, true); + let context = make_context(true); + let config = GrafanaServiceContext::from_domain_config(&make_config(true), &context); let json = serde_json::to_value(&config).expect("serialization should succeed"); @@ -123,18 +126,18 @@ mod tests { #[test] fn it_should_expose_port_3000_when_tls_disabled() { - let config = - GrafanaServiceConfig::new("admin".to_string(), Password::new("password"), false, false); + let context = make_context(false); + let config = GrafanaServiceContext::from_domain_config(&make_config(false), &context); - assert_eq!(config.ports.len(), 1); - assert_eq!(config.ports[0].binding(), "3000:3000"); + assert_eq!(config.ports().len(), 1); + assert_eq!(config.ports()[0].binding(), "3000:3000"); } #[test] fn it_should_not_expose_ports_when_tls_enabled() { - let config = - GrafanaServiceConfig::new("admin".to_string(), Password::new("password"), true, true); + let context = make_context(true); + let config = GrafanaServiceContext::from_domain_config(&make_config(true), &context); - assert!(config.ports.is_empty()); + assert!(config.ports().is_empty()); } } diff --git a/src/infrastructure/templating/docker_compose/template/wrappers/docker_compose/context/mod.rs b/src/infrastructure/templating/docker_compose/template/wrappers/docker_compose/context/mod.rs index 3326c0ae..653de3c1 100644 --- a/src/infrastructure/templating/docker_compose/template/wrappers/docker_compose/context/mod.rs +++ b/src/infrastructure/templating/docker_compose/template/wrappers/docker_compose/context/mod.rs @@ -14,24 +14,23 @@ mod grafana; mod mysql; mod network_definition; mod port_definition; -mod port_derivation; mod prometheus; +mod service_topology; mod tracker; -// Re-exports +// Re-exports - service contexts +pub use caddy::CaddyServiceContext; +pub use grafana::GrafanaServiceContext; +pub use mysql::MysqlServiceContext; +pub use prometheus::PrometheusServiceContext; +pub use tracker::TrackerServiceContext; + +// Re-exports - other types pub use builder::{DockerComposeContextBuilder, PortConflictError}; -pub use caddy::CaddyServiceConfig; pub use database::{DatabaseConfig, MysqlSetupConfig}; -pub use grafana::GrafanaServiceConfig; -pub use mysql::MysqlServiceConfig; pub use network_definition::NetworkDefinition; pub use port_definition::PortDefinition; -pub use port_derivation::{ - derive_caddy_ports, derive_grafana_ports, derive_mysql_ports, derive_prometheus_ports, - derive_tracker_ports, -}; -pub use prometheus::PrometheusServiceConfig; -pub use tracker::{TrackerPorts, TrackerServiceConfig}; +pub use service_topology::ServiceTopology; /// Context for rendering the docker-compose.yml template /// @@ -41,13 +40,13 @@ pub struct DockerComposeContext { /// Database configuration pub database: DatabaseConfig, /// Tracker service configuration (ports, networks) - pub tracker: TrackerServiceConfig, + pub tracker: TrackerServiceContext, /// Prometheus service configuration (optional) #[serde(skip_serializing_if = "Option::is_none")] - pub prometheus: Option, + pub prometheus: Option, /// Grafana service configuration (optional) #[serde(skip_serializing_if = "Option::is_none")] - pub grafana: Option, + pub grafana: Option, /// Caddy TLS proxy service configuration (optional) /// /// When present, Caddy reverse proxy is deployed for TLS termination. @@ -56,13 +55,13 @@ pub struct DockerComposeContext { /// Note: This is separate from `CaddyContext` (used for Caddyfile.tera). /// This type only contains the docker-compose service definition data. #[serde(skip_serializing_if = "Option::is_none")] - pub caddy: Option, + pub caddy: Option, /// `MySQL` service configuration (optional) /// /// Contains network configuration for the `MySQL` service. /// This is separate from `MysqlSetupConfig` which contains credentials. #[serde(skip_serializing_if = "Option::is_none")] - pub mysql: Option, + pub mysql: Option, /// All networks required by enabled services (derived) /// /// This list is computed from the networks used by all services. @@ -84,23 +83,29 @@ impl DockerComposeContext { /// # Examples /// /// ```rust - /// use torrust_tracker_deployer_lib::infrastructure::templating::docker_compose::template::wrappers::docker_compose::{DockerComposeContext, TrackerServiceConfig, MysqlSetupConfig}; + /// use torrust_tracker_deployer_lib::infrastructure::templating::docker_compose::template::wrappers::docker_compose::{DockerComposeContext, TrackerServiceContext, MysqlSetupConfig}; + /// use torrust_tracker_deployer_lib::domain::tracker::TrackerConfig; + /// use torrust_tracker_deployer_lib::domain::topology::{EnabledServices, Service}; /// - /// let tracker_config = TrackerServiceConfig::new( - /// vec![6868, 6969], // UDP ports (always exposed) - /// vec![7070], // HTTP ports without TLS - /// 1212, // API port - /// false, // API has no TLS - /// false, // has_prometheus - /// false, // has_mysql - /// false, // has_caddy + /// // Create tracker config from domain configuration + /// let domain_config = TrackerConfig::default(); + /// let context = EnabledServices::from(&[]); + /// let tracker_config = TrackerServiceContext::from_domain_config( + /// &domain_config, + /// &context, /// ); /// /// // SQLite (default) - /// let context = DockerComposeContext::builder(tracker_config.clone()).build(); - /// assert_eq!(context.database().driver(), "sqlite3"); + /// let compose_context = DockerComposeContext::builder(tracker_config.clone()).build(); + /// assert_eq!(compose_context.database().driver(), "sqlite3"); /// /// // MySQL + /// let domain_config_for_mysql = TrackerConfig::default(); + /// let mysql_context = EnabledServices::from(&[Service::MySQL]); + /// let tracker_config_with_mysql = TrackerServiceContext::from_domain_config( + /// &domain_config_for_mysql, + /// &mysql_context, + /// ); /// let mysql_config = MysqlSetupConfig { /// root_password: "root_pass".to_string(), /// database: "db".to_string(), @@ -108,14 +113,14 @@ impl DockerComposeContext { /// password: "pass".to_string(), /// port: 3306, /// }; - /// let context = DockerComposeContext::builder(tracker_config) + /// let compose_context = DockerComposeContext::builder(tracker_config_with_mysql) /// .with_mysql(mysql_config) /// .build(); - /// assert_eq!(context.database().driver(), "mysql"); + /// assert_eq!(compose_context.database().driver(), "mysql"); /// ``` #[must_use] - pub fn builder(ports: TrackerPorts) -> DockerComposeContextBuilder { - DockerComposeContextBuilder::new(ports) + pub fn builder(tracker: TrackerServiceContext) -> DockerComposeContextBuilder { + DockerComposeContextBuilder::new(tracker) } /// Get the database configuration @@ -126,31 +131,31 @@ impl DockerComposeContext { /// Get the tracker service configuration #[must_use] - pub fn tracker(&self) -> &TrackerServiceConfig { + pub fn tracker(&self) -> &TrackerServiceContext { &self.tracker } /// Get the Prometheus service configuration if present #[must_use] - pub fn prometheus(&self) -> Option<&PrometheusServiceConfig> { + pub fn prometheus(&self) -> Option<&PrometheusServiceContext> { self.prometheus.as_ref() } /// Get the Grafana service configuration if present #[must_use] - pub fn grafana(&self) -> Option<&GrafanaServiceConfig> { + pub fn grafana(&self) -> Option<&GrafanaServiceContext> { self.grafana.as_ref() } /// Get the Caddy TLS proxy service configuration if present #[must_use] - pub fn caddy(&self) -> Option<&CaddyServiceConfig> { + pub fn caddy(&self) -> Option<&CaddyServiceContext> { self.caddy.as_ref() } /// Get the `MySQL` service configuration if present #[must_use] - pub fn mysql(&self) -> Option<&MysqlServiceConfig> { + pub fn mysql(&self) -> Option<&MysqlServiceContext> { self.mysql.as_ref() } @@ -167,20 +172,114 @@ impl DockerComposeContext { #[cfg(test)] mod tests { use crate::domain::prometheus::PrometheusConfig; + use crate::domain::topology::EnabledServices; + use crate::domain::tracker::{ + DatabaseConfig as TrackerDatabaseConfig, HealthCheckApiConfig, HttpApiConfig, + HttpTrackerConfig, SqliteConfig, TrackerConfig, TrackerCoreConfig, UdpTrackerConfig, + }; use super::*; - /// Helper to create `TrackerServiceConfig` for tests (no TLS, no networks) - fn test_tracker_config() -> TrackerServiceConfig { - TrackerServiceConfig::new( - vec![6868, 6969], // UDP ports - vec![7070], // HTTP ports without TLS - 1212, // API port - false, // API has no TLS - false, // has_prometheus - false, // has_mysql - false, // has_caddy + /// Helper to create a domain `TrackerConfig` for tests (standard config) + fn test_domain_tracker_config() -> TrackerConfig { + TrackerConfig::new( + TrackerCoreConfig::new( + TrackerDatabaseConfig::Sqlite(SqliteConfig::new("tracker.db").unwrap()), + false, + ), + vec![ + UdpTrackerConfig::new("0.0.0.0:6868".parse().unwrap(), None).unwrap(), + 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(), + "TestToken".to_string().into(), + None, + false, + ) + .unwrap(), + HealthCheckApiConfig::new("127.0.0.1:1313".parse().unwrap(), None, false).unwrap(), + ) + .unwrap() + } + + /// Helper to create a domain `TrackerConfig` with specific UDP ports + fn domain_tracker_config_with_udp_port(port: u16) -> TrackerConfig { + TrackerConfig::new( + TrackerCoreConfig::new( + TrackerDatabaseConfig::Sqlite(SqliteConfig::new("tracker.db").unwrap()), + false, + ), + vec![UdpTrackerConfig::new(format!("0.0.0.0:{port}").parse().unwrap(), None).unwrap()], + vec![], + HttpApiConfig::new( + "0.0.0.0:1212".parse().unwrap(), + "TestToken".to_string().into(), + None, + false, + ) + .unwrap(), + HealthCheckApiConfig::new("127.0.0.1:1313".parse().unwrap(), None, false).unwrap(), + ) + .unwrap() + } + + /// Helper to create a domain `TrackerConfig` with API TLS enabled + fn domain_tracker_config_with_api_tls(port: u16) -> TrackerConfig { + use crate::shared::DomainName; + TrackerConfig::new( + TrackerCoreConfig::new( + TrackerDatabaseConfig::Sqlite(SqliteConfig::new("tracker.db").unwrap()), + false, + ), + vec![UdpTrackerConfig::new(format!("0.0.0.0:{port}").parse().unwrap(), None).unwrap()], + vec![], + HttpApiConfig::new( + "0.0.0.0:1212".parse().unwrap(), + "TestToken".to_string().into(), + Some(DomainName::new("api.example.com").unwrap()), + true, + ) + .unwrap(), + HealthCheckApiConfig::new("127.0.0.1:1313".parse().unwrap(), None, false).unwrap(), ) + .unwrap() + } + + /// Helper to create `TrackerServiceContext` for tests (no TLS, no networks) + fn test_tracker_config() -> TrackerServiceContext { + let domain_config = test_domain_tracker_config(); + let context = EnabledServices::from(&[]); + TrackerServiceContext::from_domain_config(&domain_config, &context) + } + + /// Helper to create `TrackerServiceContext` with specific network configuration + #[allow(clippy::fn_params_excessive_bools)] + fn tracker_config_for_networks( + has_prometheus: bool, + has_mysql: bool, + has_caddy: bool, + use_api_tls: bool, + ) -> TrackerServiceContext { + use crate::domain::topology::Service; + let domain_config = if use_api_tls { + domain_tracker_config_with_api_tls(6868) + } else { + domain_tracker_config_with_udp_port(6868) + }; + let mut services = Vec::new(); + if has_prometheus { + services.push(Service::Prometheus); + } + if has_mysql { + services.push(Service::MySQL); + } + if has_caddy { + services.push(Service::Caddy); + } + let context = EnabledServices::from(&services); + TrackerServiceContext::from_domain_config(&domain_config, &context) } #[test] @@ -190,9 +289,12 @@ mod tests { assert_eq!(context.database().driver(), "sqlite3"); assert!(context.database().mysql().is_none()); - assert_eq!(context.tracker().udp_tracker_ports, vec![6868, 6969]); - assert_eq!(context.tracker().http_tracker_ports_without_tls, vec![7070]); - assert_eq!(context.tracker().http_api_port, 1212); + // Verify ports are derived correctly (2 UDP + 1 HTTP + 1 API = 4 ports) + assert_eq!(context.tracker().ports().len(), 4); + assert_eq!(context.tracker().ports()[0].binding(), "6868:6868/udp"); + assert_eq!(context.tracker().ports()[1].binding(), "6969:6969/udp"); + assert_eq!(context.tracker().ports()[2].binding(), "7070:7070"); + assert_eq!(context.tracker().ports()[3].binding(), "1212:1212"); } #[test] @@ -219,9 +321,10 @@ mod tests { assert_eq!(mysql.password, "pass456"); assert_eq!(mysql.port, 3306); - assert_eq!(context.tracker().udp_tracker_ports, vec![6868, 6969]); - assert_eq!(context.tracker().http_tracker_ports_without_tls, vec![7070]); - assert_eq!(context.tracker().http_api_port, 1212); + // Verify ports are derived correctly (2 UDP + 1 HTTP + 1 API = 4 ports) + assert_eq!(context.tracker().ports().len(), 4); + assert_eq!(context.tracker().ports()[0].binding(), "6868:6868/udp"); + assert_eq!(context.tracker().ports()[1].binding(), "6969:6969/udp"); } #[test] @@ -293,7 +396,7 @@ mod tests { .build(); assert!(context.prometheus().is_some()); - assert_eq!(context.prometheus().unwrap().scrape_interval_in_secs, 30); + assert!(!context.prometheus().unwrap().ports().is_empty()); } #[test] @@ -316,7 +419,7 @@ mod tests { let serialized = serde_json::to_string(&context).unwrap(); assert!(serialized.contains("prometheus")); - assert!(serialized.contains("\"scrape_interval_in_secs\":20")); + assert!(serialized.contains("ports")); } #[test] @@ -331,7 +434,7 @@ mod tests { .build(); let prometheus = context.prometheus().unwrap(); - assert_eq!(prometheus.networks, vec![Network::Metrics]); + assert_eq!(prometheus.networks(), &[Network::Metrics]); } #[test] @@ -351,8 +454,8 @@ mod tests { let prometheus = context.prometheus().unwrap(); assert_eq!( - prometheus.networks, - vec![Network::Metrics, Network::Visualization] + prometheus.networks(), + &[Network::Metrics, Network::Visualization] ); } @@ -369,8 +472,7 @@ mod tests { .build(); let grafana = context.grafana().unwrap(); - assert_eq!(grafana.networks, vec![Network::Visualization]); - assert!(!grafana.has_tls); + assert_eq!(grafana.networks(), &[Network::Visualization]); } #[test] @@ -382,14 +484,14 @@ mod tests { let grafana_config = GrafanaConfig::new("admin".to_string(), "password".to_string(), None, false); let context = DockerComposeContext::builder(tracker) - .with_grafana(grafana_config) .with_caddy() + .with_grafana(grafana_config) .build(); let grafana = context.grafana().unwrap(); assert_eq!( - grafana.networks, - vec![Network::Visualization, Network::Proxy] + grafana.networks(), + &[Network::Visualization, Network::Proxy] ); } @@ -409,15 +511,7 @@ mod tests { #[test] fn it_should_include_database_network_when_mysql_enabled() { - let tracker = TrackerServiceConfig::new( - vec![6868], - vec![], - 1212, - false, - false, - true, // has_mysql - false, - ); + let tracker = tracker_config_for_networks(false, true, false, false); let mysql_config = MysqlSetupConfig { root_password: "root".to_string(), database: "db".to_string(), @@ -439,15 +533,7 @@ mod tests { #[test] fn it_should_include_metrics_network_when_prometheus_enabled() { - let tracker = TrackerServiceConfig::new( - vec![6868], - vec![], - 1212, - false, - true, // has_prometheus - false, - false, - ); + let tracker = tracker_config_for_networks(true, false, false, false); let prometheus_config = PrometheusConfig::new(std::num::NonZeroU32::new(30).expect("30 is non-zero")); let context = DockerComposeContext::builder(tracker) @@ -481,15 +567,7 @@ mod tests { #[test] fn it_should_include_proxy_network_when_caddy_enabled() { - let tracker = TrackerServiceConfig::new( - vec![6868], - vec![], - 1212, - true, // API has TLS - false, - false, - true, // has_caddy - ); + let tracker = tracker_config_for_networks(false, false, true, true); let context = DockerComposeContext::builder(tracker).with_caddy().build(); let network_names: Vec<&str> = context @@ -554,15 +632,7 @@ mod tests { #[test] fn it_should_include_all_networks_for_full_https_deployment() { - let tracker = TrackerServiceConfig::new( - vec![6868], - vec![], - 1212, - true, // API has TLS - true, // has_prometheus - true, // has_mysql - true, // has_caddy - ); + let tracker = tracker_config_for_networks(true, true, true, true); let mysql_config = MysqlSetupConfig { root_password: "root".to_string(), database: "db".to_string(), @@ -596,15 +666,7 @@ mod tests { #[test] fn it_should_return_networks_in_deterministic_alphabetical_order() { - let tracker = TrackerServiceConfig::new( - vec![6868], - vec![], - 1212, - true, // API has TLS - true, // has_prometheus - true, // has_mysql - true, // has_caddy - ); + let tracker = tracker_config_for_networks(true, true, true, true); let mysql_config = MysqlSetupConfig { root_password: "root".to_string(), database: "db".to_string(), @@ -644,15 +706,7 @@ mod tests { #[test] fn it_should_deduplicate_networks_from_multiple_services() { // Prometheus and Grafana both use visualization_network - let tracker = TrackerServiceConfig::new( - vec![6868], - vec![], - 1212, - false, - true, // has_prometheus - false, - false, - ); + let tracker = tracker_config_for_networks(true, false, false, false); let prometheus_config = PrometheusConfig::new(std::num::NonZeroU32::new(30).expect("30 is non-zero")); let grafana_config = diff --git a/src/infrastructure/templating/docker_compose/template/wrappers/docker_compose/context/mysql.rs b/src/infrastructure/templating/docker_compose/template/wrappers/docker_compose/context/mysql.rs index 25b4f11b..3dd6b80e 100644 --- a/src/infrastructure/templating/docker_compose/template/wrappers/docker_compose/context/mysql.rs +++ b/src/infrastructure/templating/docker_compose/template/wrappers/docker_compose/context/mysql.rs @@ -4,23 +4,26 @@ //! //! ## Note on Configuration Separation //! -//! There are two `MySQL`-related types in the docker-compose context: +//! There are multiple `MySQL`-related types: //! //! - `MysqlSetupConfig` (in database.rs): Contains credentials and initialization settings //! for Docker Compose environment variables (root password, database name, user, etc.) //! -//! - `MysqlServiceConfig` (this module): Contains service definition settings like networks, -//! following the same pattern as `CaddyServiceConfig`, `PrometheusServiceConfig`, etc. +//! - `MysqlServiceContext` (this module): Contains service definition settings like networks, +//! following the same pattern as `CaddyDockerServiceConfig`, `PrometheusServiceConfig`, etc. +//! +//! - `domain::mysql::MysqlServiceConfig`: Domain configuration for port/network derivation //! //! This separation keeps the pattern consistent across all services - each service //! has its own config type for networks and service-specific settings. use serde::Serialize; -use crate::domain::topology::Network; +use crate::domain::mysql::MysqlServiceConfig as DomainMysqlConfig; +use crate::domain::topology::{EnabledServices, Network, NetworkDerivation, PortDerivation}; use super::port_definition::PortDefinition; -use super::port_derivation::derive_mysql_ports; +use super::service_topology::ServiceTopology; /// `MySQL` service configuration for Docker Compose /// @@ -28,48 +31,74 @@ use super::port_derivation::derive_mysql_ports; /// This is intentionally minimal - the actual `MySQL` setup configuration (credentials) /// is in `MysqlSetupConfig`. /// +/// Uses `ServiceTopology` to share the common topology structure with other services. +/// /// # Example /// /// ```rust -/// use torrust_tracker_deployer_lib::infrastructure::templating::docker_compose::template::wrappers::docker_compose::context::MysqlServiceConfig; +/// use torrust_tracker_deployer_lib::infrastructure::templating::docker_compose::template::wrappers::docker_compose::context::MysqlServiceContext; +/// use torrust_tracker_deployer_lib::domain::mysql::MysqlServiceConfig; +/// use torrust_tracker_deployer_lib::domain::topology::EnabledServices; /// -/// let mysql = MysqlServiceConfig::new(); -/// assert_eq!(mysql.networks, vec![torrust_tracker_deployer_lib::domain::topology::Network::Database]); -/// assert!(mysql.ports.is_empty()); // MySQL never exposes ports externally +/// let mysql = MysqlServiceContext::from_domain_config(&MysqlServiceConfig::new(), &EnabledServices::default()); +/// assert_eq!(mysql.networks(), &[torrust_tracker_deployer_lib::domain::topology::Network::Database]); +/// assert!(mysql.ports().is_empty()); // MySQL never exposes ports externally /// ``` #[derive(Debug, Clone, Serialize, PartialEq)] -pub struct MysqlServiceConfig { - /// Port bindings for Docker Compose - /// - /// `MySQL` never exposes ports externally - only tracker can access it via internal network. - pub ports: Vec, - /// Networks this service connects to +pub struct MysqlServiceContext { + /// Service topology (ports and networks) /// - /// `MySQL` only connects to `database_network` for isolation. - /// Only the tracker can access `MySQL` through this network. - pub networks: Vec, + /// Flattened for template compatibility - serializes ports/networks at top level. + #[serde(flatten)] + pub topology: ServiceTopology, } -impl MysqlServiceConfig { - /// Creates a new `MysqlServiceConfig` with default networks and empty ports +impl MysqlServiceContext { + /// Creates a new `MysqlServiceContext` from domain configuration /// - /// `MySQL` connects to: - /// - `database_network`: For database access by the tracker + /// Uses the domain `PortDerivation` and `NetworkDerivation` traits, + /// ensuring business rules live in the domain layer. /// - /// `MySQL` never exposes ports externally for security. + /// # Arguments + /// + /// * `config` - The domain `MySQL` service configuration + /// * `enabled_services` - Topology context with information about enabled services #[must_use] - pub fn new() -> Self { - let port_bindings = derive_mysql_ports(); + pub fn from_domain_config( + config: &DomainMysqlConfig, + enabled_services: &EnabledServices, + ) -> Self { + let port_bindings = config.derive_ports(); let ports = port_bindings.iter().map(PortDefinition::from).collect(); + let networks = config.derive_networks(enabled_services); Self { - ports, - networks: vec![Network::Database], + topology: ServiceTopology::new(ports, networks), } } + + /// Creates a new `MysqlServiceContext` with default configuration + /// + /// Convenience method that creates a default domain config and empty enabled services. + #[must_use] + pub fn new() -> Self { + Self::from_domain_config(&DomainMysqlConfig::new(), &EnabledServices::default()) + } + + /// Returns a reference to the port bindings + #[must_use] + pub fn ports(&self) -> &[PortDefinition] { + &self.topology.ports + } + + /// Returns a reference to the networks + #[must_use] + pub fn networks(&self) -> &[Network] { + &self.topology.networks + } } -impl Default for MysqlServiceConfig { +impl Default for MysqlServiceContext { fn default() -> Self { Self::new() } @@ -81,21 +110,21 @@ mod tests { #[test] fn it_should_connect_mysql_to_database_network() { - let mysql = MysqlServiceConfig::new(); + let mysql = MysqlServiceContext::new(); - assert_eq!(mysql.networks, vec![Network::Database]); + assert_eq!(mysql.networks(), &[Network::Database]); } #[test] fn it_should_implement_default() { - let mysql = MysqlServiceConfig::default(); + let mysql = MysqlServiceContext::default(); - assert_eq!(mysql.networks, vec![Network::Database]); + assert_eq!(mysql.networks(), &[Network::Database]); } #[test] fn it_should_serialize_network_to_name_string() { - let mysql = MysqlServiceConfig::new(); + let mysql = MysqlServiceContext::new(); let json = serde_json::to_value(&mysql).expect("serialization should succeed"); @@ -105,8 +134,28 @@ mod tests { #[test] fn it_should_not_expose_any_ports() { - let mysql = MysqlServiceConfig::new(); + let mysql = MysqlServiceContext::new(); + + assert!(mysql.ports().is_empty()); + } + + #[test] + fn it_should_use_domain_traits_for_port_derivation() { + let config = DomainMysqlConfig::new(); + let enabled_services = EnabledServices::default(); + let mysql = MysqlServiceContext::from_domain_config(&config, &enabled_services); + + // Verify ports come from domain trait (empty for MySQL) + assert!(mysql.ports().is_empty()); + } + + #[test] + fn it_should_use_domain_traits_for_network_derivation() { + let config = DomainMysqlConfig::new(); + let enabled_services = EnabledServices::default(); + let mysql = MysqlServiceContext::from_domain_config(&config, &enabled_services); - assert!(mysql.ports.is_empty()); + // Verify networks come from domain trait + assert_eq!(mysql.networks(), &[Network::Database]); } } diff --git a/src/infrastructure/templating/docker_compose/template/wrappers/docker_compose/context/port_derivation.rs b/src/infrastructure/templating/docker_compose/template/wrappers/docker_compose/context/port_derivation.rs deleted file mode 100644 index 2e15b86f..00000000 --- a/src/infrastructure/templating/docker_compose/template/wrappers/docker_compose/context/port_derivation.rs +++ /dev/null @@ -1,341 +0,0 @@ -//! Port derivation functions for Docker Compose services -//! -//! This module implements PORT-01 through PORT-11 rules from the refactoring plan, -//! deriving port bindings for each service based on configuration. -//! -//! ## Design Principles -//! -//! - Single source of truth for port exposure logic -//! - TLS-aware: ports are hidden when Caddy handles TLS termination -//! - Type-safe: uses `PortBinding` domain type -//! - Self-documenting: each port has a description for YAML comments -//! -//! ## Port Rules Reference -//! -//! | Rule | Description | -//! |---------|-------------------------------------------------------| -//! | PORT-01 | Tracker needs ports if UDP OR HTTP/API without TLS | -//! | PORT-02 | UDP ports always exposed (no TLS for UDP) | -//! | PORT-03 | HTTP ports WITHOUT TLS exposed directly | -//! | PORT-04 | HTTP ports WITH TLS NOT exposed (Caddy handles) | -//! | PORT-05 | API exposed only when no TLS | -//! | PORT-06 | API NOT exposed when TLS | -//! | PORT-07 | Grafana 3000 exposed only without TLS | -//! | PORT-08 | Grafana 3000 NOT exposed with TLS | -//! | PORT-09 | Caddy always exposes 80, 443, 443/udp | -//! | PORT-10 | Prometheus 9090 on localhost only | -//! | PORT-11 | `MySQL` no exposed ports | - -use crate::domain::topology::PortBinding; - -/// Derives port bindings for the Tracker service -/// -/// Implements PORT-01 through PORT-06: -/// - UDP ports are always exposed (PORT-02) -/// - HTTP ports without TLS are exposed (PORT-03) -/// - HTTP ports with TLS are NOT exposed (PORT-04) -/// - API port exposed only when no TLS (PORT-05, PORT-06) -/// -/// # Arguments -/// -/// * `udp_ports` - UDP tracker port numbers -/// * `http_ports_without_tls` - HTTP tracker ports that don't use TLS -/// * `http_api_port` - HTTP API port number -/// * `http_api_has_tls` - Whether the HTTP API uses TLS (Caddy handles it) -#[must_use] -pub fn derive_tracker_ports( - udp_ports: &[u16], - http_ports_without_tls: &[u16], - http_api_port: u16, - http_api_has_tls: bool, -) -> Vec { - let mut ports = Vec::new(); - - // PORT-02: UDP ports always exposed (UDP doesn't use TLS) - for &port in udp_ports { - ports.push(PortBinding::udp(port, "BitTorrent UDP announce")); - } - - // PORT-03: HTTP ports WITHOUT TLS exposed directly - // PORT-04: HTTP ports WITH TLS NOT exposed (handled by Caddy) - for &port in http_ports_without_tls { - ports.push(PortBinding::tcp(port, "HTTP tracker announce")); - } - - // PORT-05: API exposed only when no TLS - // PORT-06: API NOT exposed when TLS (Caddy handles it) - if !http_api_has_tls { - ports.push(PortBinding::tcp( - http_api_port, - "HTTP API (stats/whitelist)", - )); - } - - ports -} - -/// Derives port bindings for the Caddy service -/// -/// Implements PORT-09: Caddy always exposes 80, 443, 443/udp -/// -/// These ports are required for: -/// - Port 80: ACME HTTP-01 challenge for certificate renewal -/// - Port 443: HTTPS traffic -/// - Port 443/udp: HTTP/3 (QUIC) support -#[must_use] -pub fn derive_caddy_ports() -> Vec { - vec![ - PortBinding::tcp(80, "HTTP (ACME HTTP-01 challenge)"), - PortBinding::tcp(443, "HTTPS"), - PortBinding::udp(443, "HTTP/3 (QUIC)"), - ] -} - -/// Derives port bindings for the Prometheus service -/// -/// Implements PORT-10: Prometheus 9090 on localhost only -/// -/// Prometheus is bound to localhost to prevent external access. -/// Grafana accesses it via Docker network (`http://prometheus:9090`). -#[must_use] -pub fn derive_prometheus_ports() -> Vec { - vec![PortBinding::localhost_tcp( - 9090, - "Prometheus metrics (localhost only)", - )] -} - -/// Derives port bindings for the Grafana service -/// -/// Implements PORT-07 and PORT-08: -/// - Without TLS: expose port 3000 directly -/// - With TLS: don't expose (Caddy handles it) -/// -/// # Arguments -/// -/// * `has_tls` - Whether TLS is enabled (Caddy handles Grafana access) -#[must_use] -pub fn derive_grafana_ports(has_tls: bool) -> Vec { - // PORT-07: Grafana 3000 exposed only without TLS - // PORT-08: Grafana 3000 NOT exposed with TLS - if has_tls { - vec![] - } else { - vec![PortBinding::tcp(3000, "Grafana dashboard")] - } -} - -/// Derives port bindings for the `MySQL` service -/// -/// Implements PORT-11: `MySQL` has no exposed ports -/// -/// `MySQL` is accessed only via Docker network by the tracker service. -/// It should never be exposed to the host network for security. -#[must_use] -pub fn derive_mysql_ports() -> Vec { - vec![] -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::domain::tracker::Protocol; - - // ========================================================================= - // Tracker port derivation tests - // ========================================================================= - - mod tracker_ports { - use super::*; - - #[test] - fn it_should_expose_udp_ports_always() { - // PORT-02: UDP ports always exposed - let ports = derive_tracker_ports(&[6969, 6868], &[], 1212, false); - - let udp_ports: Vec<_> = ports - .iter() - .filter(|p| p.protocol() == Protocol::Udp) - .collect(); - - assert_eq!(udp_ports.len(), 2); - assert_eq!(udp_ports[0].host_port(), 6969); - assert_eq!(udp_ports[1].host_port(), 6868); - } - - #[test] - fn it_should_expose_http_ports_without_tls() { - // PORT-03: HTTP ports WITHOUT TLS exposed directly - let ports = derive_tracker_ports(&[], &[7070, 8080], 1212, false); - - let http_ports: Vec<_> = ports - .iter() - .filter(|p| p.protocol() == Protocol::Tcp && p.host_port() != 1212) - .collect(); - - assert_eq!(http_ports.len(), 2); - assert_eq!(http_ports[0].host_port(), 7070); - assert_eq!(http_ports[1].host_port(), 8080); - } - - #[test] - fn it_should_expose_api_port_when_no_tls() { - // PORT-05: API exposed only when no TLS - let ports = derive_tracker_ports(&[], &[], 1212, false); - - let api_port = ports.iter().find(|p| p.host_port() == 1212); - - assert!(api_port.is_some()); - assert_eq!(api_port.unwrap().protocol(), Protocol::Tcp); - } - - #[test] - fn it_should_not_expose_api_port_when_tls_enabled() { - // PORT-06: API NOT exposed when TLS - let ports = derive_tracker_ports(&[], &[], 1212, true); - - let api_port = ports.iter().find(|p| p.host_port() == 1212); - - assert!(api_port.is_none()); - } - - #[test] - fn it_should_return_empty_when_all_ports_behind_tls() { - // All HTTP ports have TLS, no UDP ports - let ports = derive_tracker_ports(&[], &[], 1212, true); - - assert!(ports.is_empty()); - } - - #[test] - fn it_should_include_descriptions_for_all_ports() { - let ports = derive_tracker_ports(&[6969], &[7070], 1212, false); - - for port in &ports { - assert!(!port.description().is_empty()); - } - } - } - - // ========================================================================= - // Caddy port derivation tests - // ========================================================================= - - mod caddy_ports { - use super::*; - - #[test] - fn it_should_expose_http_for_acme_challenge() { - // PORT-09: Caddy always exposes 80 - let ports = derive_caddy_ports(); - - let http_port = ports - .iter() - .find(|p| p.host_port() == 80 && p.protocol() == Protocol::Tcp); - - assert!(http_port.is_some()); - assert!(http_port.unwrap().description().contains("ACME")); - } - - #[test] - fn it_should_expose_https_on_443() { - // PORT-09: Caddy always exposes 443 - let ports = derive_caddy_ports(); - - let https_port = ports - .iter() - .find(|p| p.host_port() == 443 && p.protocol() == Protocol::Tcp); - - assert!(https_port.is_some()); - assert!(https_port.unwrap().description().contains("HTTPS")); - } - - #[test] - fn it_should_expose_quic_on_443_udp() { - // PORT-09: Caddy always exposes 443/udp for HTTP/3 - let ports = derive_caddy_ports(); - - let quic_port = ports - .iter() - .find(|p| p.host_port() == 443 && p.protocol() == Protocol::Udp); - - assert!(quic_port.is_some()); - assert!(quic_port.unwrap().description().contains("QUIC")); - } - - #[test] - fn it_should_return_exactly_three_ports() { - let ports = derive_caddy_ports(); - - assert_eq!(ports.len(), 3); - } - } - - // ========================================================================= - // Prometheus port derivation tests - // ========================================================================= - - mod prometheus_ports { - use std::net::{IpAddr, Ipv4Addr}; - - use super::*; - - #[test] - fn it_should_expose_9090_on_localhost_only() { - // PORT-10: Prometheus 9090 on localhost only - let ports = derive_prometheus_ports(); - - assert_eq!(ports.len(), 1); - assert_eq!(ports[0].host_port(), 9090); - assert_eq!(ports[0].host_ip(), Some(IpAddr::V4(Ipv4Addr::LOCALHOST))); - } - - #[test] - fn it_should_use_tcp_protocol() { - let ports = derive_prometheus_ports(); - - assert_eq!(ports[0].protocol(), Protocol::Tcp); - } - } - - // ========================================================================= - // Grafana port derivation tests - // ========================================================================= - - mod grafana_ports { - use super::*; - - #[test] - fn it_should_expose_3000_without_tls() { - // PORT-07: Grafana 3000 exposed only without TLS - let ports = derive_grafana_ports(false); - - assert_eq!(ports.len(), 1); - assert_eq!(ports[0].host_port(), 3000); - } - - #[test] - fn it_should_not_expose_port_with_tls() { - // PORT-08: Grafana 3000 NOT exposed with TLS - let ports = derive_grafana_ports(true); - - assert!(ports.is_empty()); - } - } - - // ========================================================================= - // MySQL port derivation tests - // ========================================================================= - - mod mysql_ports { - use super::*; - - #[test] - fn it_should_not_expose_any_ports() { - // PORT-11: MySQL no exposed ports - let ports = derive_mysql_ports(); - - assert!(ports.is_empty()); - } - } -} diff --git a/src/infrastructure/templating/docker_compose/template/wrappers/docker_compose/context/prometheus.rs b/src/infrastructure/templating/docker_compose/template/wrappers/docker_compose/context/prometheus.rs index c7704672..84a8d537 100644 --- a/src/infrastructure/templating/docker_compose/template/wrappers/docker_compose/context/prometheus.rs +++ b/src/infrastructure/templating/docker_compose/template/wrappers/docker_compose/context/prometheus.rs @@ -3,96 +3,119 @@ // External crates use serde::Serialize; -use crate::domain::topology::Network; +use crate::domain::prometheus::PrometheusConfig; +use crate::domain::topology::{EnabledServices, Network, NetworkDerivation, PortDerivation}; use super::port_definition::PortDefinition; -use super::port_derivation::derive_prometheus_ports; +use super::service_topology::ServiceTopology; /// Prometheus service configuration for Docker Compose /// -/// Contains all configuration needed for the Prometheus service in Docker Compose, -/// including the scrape interval, port mappings, and network connections. All logic -/// is pre-computed in Rust to keep the Tera template simple. +/// Contains configuration needed for the Prometheus service definition in docker-compose.yml. +/// Only includes fields actually used by the template (ports and networks). +/// Scrape configuration is handled separately by the prometheus.yml template context. +/// +/// Uses `ServiceTopology` to share the common topology structure with other services. #[derive(Serialize, Debug, Clone)] -pub struct PrometheusServiceConfig { - /// Scrape interval in seconds - pub scrape_interval_in_secs: u32, - /// Port bindings for Docker Compose - /// - /// Prometheus exposes port 9090 on localhost only for security. - pub ports: Vec, - /// Networks the Prometheus service should connect to +pub struct PrometheusServiceContext { + /// Service topology (ports and networks) /// - /// Pre-computed list based on enabled features: - /// - Always includes `metrics_network` (scrapes metrics from tracker) - /// - Includes `visualization_network` if Grafana is enabled - pub networks: Vec, + /// Flattened for template compatibility - serializes ports/networks at top level. + #[serde(flatten)] + pub topology: ServiceTopology, } -impl PrometheusServiceConfig { - /// Creates a new `PrometheusServiceConfig` with pre-computed networks and ports +impl PrometheusServiceContext { + /// Creates a new `PrometheusServiceContext` from domain configuration + /// + /// Uses the domain `PortDerivation` and `NetworkDerivation` traits, + /// ensuring business rules live in the domain layer. /// /// # Arguments /// - /// * `scrape_interval_in_secs` - The scrape interval in seconds - /// * `has_grafana` - Whether Grafana is enabled (adds `visualization_network`) + /// * `config` - The domain Prometheus configuration + /// * `context` - Topology context with information about enabled services #[must_use] - pub fn new(scrape_interval_in_secs: u32, has_grafana: bool) -> Self { - let networks = Self::compute_networks(has_grafana); - let port_bindings = derive_prometheus_ports(); - let ports = port_bindings.iter().map(PortDefinition::from).collect(); - + pub fn from_domain_config( + config: &PrometheusConfig, + enabled_services: &EnabledServices, + ) -> Self { + let networks = config.derive_networks(enabled_services); + let ports = config + .derive_ports() + .iter() + .map(PortDefinition::from) + .collect(); Self { - scrape_interval_in_secs, - ports, - networks, + topology: ServiceTopology::new(ports, networks), } } - /// Computes the list of networks for the Prometheus service - fn compute_networks(has_grafana: bool) -> Vec { - let mut networks = vec![Network::Metrics]; - - if has_grafana { - networks.push(Network::Visualization); - } + /// Returns a reference to the port bindings + #[must_use] + pub fn ports(&self) -> &[PortDefinition] { + &self.topology.ports + } - networks + /// Returns a reference to the networks + #[must_use] + pub fn networks(&self) -> &[Network] { + &self.topology.networks } } #[cfg(test)] mod tests { + use std::num::NonZeroU32; + use super::*; + fn make_config(scrape_interval_secs: u32) -> PrometheusConfig { + PrometheusConfig::new( + NonZeroU32::new(scrape_interval_secs).expect("non-zero scrape interval"), + ) + } + + fn make_context(has_grafana: bool) -> EnabledServices { + if has_grafana { + EnabledServices::from(&[crate::domain::topology::Service::Grafana]) + } else { + EnabledServices::from(&[]) + } + } + #[test] fn it_should_connect_prometheus_to_metrics_network() { - let config = PrometheusServiceConfig::new(15, false); + let context = make_context(false); + let config = PrometheusServiceContext::from_domain_config(&make_config(15), &context); - assert!(config.networks.contains(&Network::Metrics)); + assert!(config.networks().contains(&Network::Metrics)); } #[test] fn it_should_not_connect_prometheus_to_visualization_network_when_grafana_disabled() { - let config = PrometheusServiceConfig::new(15, false); + let context = make_context(false); + let config = PrometheusServiceContext::from_domain_config(&make_config(15), &context); - assert_eq!(config.networks, vec![Network::Metrics]); - assert!(!config.networks.contains(&Network::Visualization)); + assert_eq!(config.networks(), &[Network::Metrics]); + assert!(!config.networks().contains(&Network::Visualization)); } #[test] fn it_should_connect_prometheus_to_visualization_network_when_grafana_enabled() { - let config = PrometheusServiceConfig::new(30, true); + let context = make_context(true); + let config = PrometheusServiceContext::from_domain_config(&make_config(30), &context); assert_eq!( - config.networks, - vec![Network::Metrics, Network::Visualization] + config.networks(), + &[Network::Metrics, Network::Visualization] ); } #[test] fn it_should_serialize_networks_to_name_strings() { - let config = PrometheusServiceConfig::new(15, true); + let context = make_context(true); + let config = PrometheusServiceContext::from_domain_config(&make_config(15), &context); let json = serde_json::to_value(&config).expect("serialization should succeed"); @@ -103,15 +126,17 @@ mod tests { #[test] fn it_should_expose_localhost_port_9090() { - let config = PrometheusServiceConfig::new(15, false); + let context = make_context(false); + let config = PrometheusServiceContext::from_domain_config(&make_config(15), &context); - assert_eq!(config.ports.len(), 1); - assert_eq!(config.ports[0].binding(), "127.0.0.1:9090:9090"); + assert_eq!(config.ports().len(), 1); + assert_eq!(config.ports()[0].binding(), "127.0.0.1:9090:9090"); } #[test] fn it_should_serialize_ports_with_binding_and_description() { - let config = PrometheusServiceConfig::new(15, false); + let context = make_context(false); + let config = PrometheusServiceContext::from_domain_config(&make_config(15), &context); let json = serde_json::to_value(&config).expect("serialization should succeed"); diff --git a/src/infrastructure/templating/docker_compose/template/wrappers/docker_compose/context/service_topology.rs b/src/infrastructure/templating/docker_compose/template/wrappers/docker_compose/context/service_topology.rs new file mode 100644 index 00000000..249160dc --- /dev/null +++ b/src/infrastructure/templating/docker_compose/template/wrappers/docker_compose/context/service_topology.rs @@ -0,0 +1,125 @@ +//! Service topology for Docker Compose +//! +//! This module defines the common topology structure shared by all Docker Compose +//! service contexts. The topology contains only inter-service relationship data +//! (ports and networks), not internal service configuration. +//! +//! ## Design Rationale +//! +//! All Docker Compose service contexts (Tracker, Grafana, Prometheus, Caddy, `MySQL`) +//! share the same topology structure because: +//! +//! 1. **Topology vs Configuration separation**: The docker-compose.yml template only +//! needs topology data (how services connect). Internal configuration (credentials, +//! intervals) is injected via `.env` files or service-specific templates. +//! +//! 2. **Template simplicity**: Tera templates stay simple by only dealing with +//! ports and networks. All business logic is computed in Rust. +//! +//! 3. **Explicit pattern**: This shared type makes the architectural pattern explicit +//! and ensures consistency across all services. + +// External crates +use serde::Serialize; + +use crate::domain::topology::Network; + +use super::port_definition::PortDefinition; + +/// Service topology for Docker Compose templates +/// +/// Contains the inter-service relationship data that all Docker Compose services share: +/// - **Ports**: Which ports to expose (derived from service config + TLS settings) +/// - **Networks**: Which internal networks to connect to (derived from enabled services) +/// +/// This type is embedded in all `*ServiceContext` types using `#[serde(flatten)]` +/// to provide a flat JSON structure for template compatibility. +/// +/// # Example +/// +/// ```rust +/// use torrust_tracker_deployer_lib::infrastructure::templating::docker_compose::template::wrappers::docker_compose::context::ServiceTopology; +/// use torrust_tracker_deployer_lib::infrastructure::templating::docker_compose::template::wrappers::docker_compose::context::PortDefinition; +/// use torrust_tracker_deployer_lib::domain::topology::Network; +/// +/// let topology = ServiceTopology { +/// ports: vec![PortDefinition::new("6969:6969/udp".to_string(), "UDP Tracker".to_string())], +/// networks: vec![Network::Metrics, Network::Database], +/// }; +/// +/// // Serializes to flat structure for template compatibility +/// let json = serde_json::to_value(&topology).unwrap(); +/// assert!(json["ports"].is_array()); +/// assert!(json["networks"].is_array()); +/// ``` +#[derive(Serialize, Debug, Clone, PartialEq, Default)] +pub struct ServiceTopology { + /// Port bindings for Docker Compose + /// + /// Pre-computed list of ports the service should expose. + /// Empty when TLS is enabled (Caddy handles the ports instead). + pub ports: Vec, + /// Networks the service should connect to + /// + /// Pre-computed list of internal Docker networks based on enabled services. + /// Examples: `metrics_network`, `database_network`, `proxy_network`. + pub networks: Vec, +} + +impl ServiceTopology { + /// Creates a new `ServiceTopology` with the given ports and networks + #[must_use] + pub fn new(ports: Vec, networks: Vec) -> Self { + Self { ports, networks } + } + + /// Creates an empty topology with no ports or networks + #[must_use] + pub fn empty() -> Self { + Self::default() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn it_should_create_empty_topology() { + let topology = ServiceTopology::empty(); + + assert!(topology.ports.is_empty()); + assert!(topology.networks.is_empty()); + } + + #[test] + fn it_should_create_topology_with_ports_and_networks() { + let ports = vec![PortDefinition::new( + "6969:6969/udp".to_string(), + "UDP Tracker".to_string(), + )]; + let networks = vec![Network::Metrics, Network::Database]; + + let topology = ServiceTopology::new(ports.clone(), networks.clone()); + + assert_eq!(topology.ports.len(), 1); + assert_eq!(topology.networks, networks); + } + + #[test] + fn it_should_serialize_to_flat_json_structure() { + let topology = ServiceTopology { + ports: vec![PortDefinition::new( + "6969:6969".to_string(), + "Test port".to_string(), + )], + networks: vec![Network::Metrics], + }; + + let json = serde_json::to_value(&topology).expect("serialization should succeed"); + + assert!(json["ports"].is_array()); + assert!(json["networks"].is_array()); + assert_eq!(json["networks"][0], "metrics_network"); + } +} diff --git a/src/infrastructure/templating/docker_compose/template/wrappers/docker_compose/context/tracker.rs b/src/infrastructure/templating/docker_compose/template/wrappers/docker_compose/context/tracker.rs index c41095f0..c638c3e1 100644 --- a/src/infrastructure/templating/docker_compose/template/wrappers/docker_compose/context/tracker.rs +++ b/src/infrastructure/templating/docker_compose/template/wrappers/docker_compose/context/tracker.rs @@ -3,119 +3,208 @@ // External crates use serde::Serialize; -use crate::domain::topology::Network; +use crate::domain::topology::{EnabledServices, Network, NetworkDerivation, PortDerivation}; +use crate::domain::tracker::TrackerConfig; use super::port_definition::PortDefinition; -use super::port_derivation::derive_tracker_ports; +use super::service_topology::ServiceTopology; -/// Tracker service configuration for Docker Compose +/// Tracker service context for Docker Compose template /// /// Contains all configuration needed for the tracker service in Docker Compose, /// including port mappings and network connections. All logic is pre-computed /// in Rust to keep the Tera template simple. +/// +/// Uses `ServiceTopology` to share the common topology structure with other services. #[derive(Serialize, Debug, Clone)] -pub struct TrackerServiceConfig { - /// UDP tracker ports (always exposed - UDP doesn't use TLS termination via Caddy) - pub udp_tracker_ports: Vec, - /// HTTP tracker ports without TLS (only these are exposed in Docker Compose) - /// - /// Ports with TLS enabled are handled by Caddy and NOT included here. - pub http_tracker_ports_without_tls: Vec, - /// HTTP API port - pub http_api_port: u16, - /// Whether the HTTP API has TLS enabled (port should not be exposed if true) - #[serde(default)] - pub http_api_has_tls: bool, - /// Whether the tracker service needs a ports section at all - /// - /// Pre-computed flag: true if there are UDP ports, HTTP ports without TLS, - /// or the API port is exposed (no TLS). - #[serde(default)] - pub needs_ports_section: bool, - /// Port bindings for Docker Compose +pub struct TrackerServiceContext { + /// Service topology (ports and networks) /// - /// Pre-computed list of all ports the tracker should expose. - /// Derived from UDP ports, HTTP ports without TLS, and API port (if no TLS). - pub ports: Vec, - /// Networks the tracker service should connect to - /// - /// Pre-computed list based on enabled features (prometheus, mysql, caddy). - pub networks: Vec, + /// Flattened for template compatibility - serializes ports/networks at top level. + #[serde(flatten)] + pub topology: ServiceTopology, } -impl TrackerServiceConfig { - /// Creates a new `TrackerServiceConfig` with pre-computed flags +impl TrackerServiceContext { + /// Creates a new `TrackerServiceContext` from domain configuration + /// + /// Uses the domain `PortDerivation` and `NetworkDerivation` traits, + /// ensuring business rules live in the domain layer. /// /// # Arguments /// - /// * `udp_tracker_ports` - UDP tracker ports (always exposed) - /// * `http_tracker_ports_without_tls` - HTTP tracker ports that don't have TLS - /// * `http_api_port` - The HTTP API port number - /// * `http_api_has_tls` - Whether the API uses TLS (Caddy handles it) - /// * `has_prometheus` - Whether Prometheus is enabled (adds `metrics_network`) - /// * `has_mysql` - Whether `MySQL` is the database driver (adds `database_network`) - /// * `has_caddy` - Whether Caddy TLS proxy is enabled (adds `proxy_network`) + /// * `config` - The domain Tracker configuration + /// * `enabled_services` - Topology context with information about enabled services #[must_use] - #[allow(clippy::fn_params_excessive_bools)] - pub fn new( - udp_tracker_ports: Vec, - http_tracker_ports_without_tls: Vec, - http_api_port: u16, - http_api_has_tls: bool, - has_prometheus: bool, - has_mysql: bool, - has_caddy: bool, - ) -> Self { - let needs_ports_section = !udp_tracker_ports.is_empty() - || !http_tracker_ports_without_tls.is_empty() - || !http_api_has_tls; - - let networks = Self::compute_networks(has_prometheus, has_mysql, has_caddy); - - // Derive ports using the domain logic - let port_bindings = derive_tracker_ports( - &udp_tracker_ports, - &http_tracker_ports_without_tls, - http_api_port, - http_api_has_tls, - ); - let ports = port_bindings.iter().map(PortDefinition::from).collect(); + pub fn from_domain_config(config: &TrackerConfig, enabled_services: &EnabledServices) -> Self { + let networks = config.derive_networks(enabled_services); + let ports = config + .derive_ports() + .iter() + .map(PortDefinition::from) + .collect(); Self { - udp_tracker_ports, - http_tracker_ports_without_tls, - http_api_port, - http_api_has_tls, - needs_ports_section, - ports, - networks, + topology: ServiceTopology::new(ports, networks), } } - /// Computes the list of networks for the tracker service - fn compute_networks(has_prometheus: bool, has_mysql: bool, has_caddy: bool) -> Vec { - let mut networks = Vec::new(); + /// Returns a reference to the port bindings + #[must_use] + pub fn ports(&self) -> &[PortDefinition] { + &self.topology.ports + } + + /// Returns a reference to the networks + #[must_use] + pub fn networks(&self) -> &[Network] { + &self.topology.networks + } +} + +#[cfg(test)] +mod tests { + use super::*; + + use crate::domain::tracker::{ + DatabaseConfig as TrackerDatabaseConfig, HealthCheckApiConfig, HttpApiConfig, + HttpTrackerConfig, SqliteConfig, TrackerConfig, TrackerCoreConfig, UdpTrackerConfig, + }; + /// Helper to create `EnabledServices` from boolean flags + fn make_context(has_prometheus: bool, has_mysql: bool, has_caddy: bool) -> EnabledServices { + use crate::domain::topology::Service; + let mut services = Vec::new(); if has_prometheus { - networks.push(Network::Metrics); + services.push(Service::Prometheus); } if has_mysql { - networks.push(Network::Database); + services.push(Service::MySQL); } if has_caddy { - networks.push(Network::Proxy); + services.push(Service::Caddy); } + EnabledServices::from(&services) + } - networks + /// Helper to create a basic domain `TrackerConfig` for network tests + fn basic_domain_tracker_config() -> TrackerConfig { + TrackerConfig::new( + TrackerCoreConfig::new( + TrackerDatabaseConfig::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(), + "TestToken".to_string().into(), + None, + false, + ) + .unwrap(), + HealthCheckApiConfig::new("127.0.0.1:1313".parse().unwrap(), None, false).unwrap(), + ) + .unwrap() } -} -// Type alias for backward compatibility -pub type TrackerPorts = TrackerServiceConfig; + /// Helper to create a domain `TrackerConfig` with TLS on API + fn domain_tracker_config_with_api_tls() -> TrackerConfig { + use crate::shared::DomainName; + TrackerConfig::new( + TrackerCoreConfig::new( + TrackerDatabaseConfig::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(), + "TestToken".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() + } -#[cfg(test)] -mod tests { - use super::*; + /// Helper to create a domain `TrackerConfig` with multiple UDP ports + fn domain_tracker_config_with_udp_ports(ports: &[u16]) -> TrackerConfig { + TrackerConfig::new( + TrackerCoreConfig::new( + TrackerDatabaseConfig::Sqlite(SqliteConfig::new("tracker.db").unwrap()), + false, + ), + ports + .iter() + .map(|p| { + UdpTrackerConfig::new(format!("0.0.0.0:{p}").parse().unwrap(), None).unwrap() + }) + .collect(), + vec![], + HttpApiConfig::new( + "0.0.0.0:1212".parse().unwrap(), + "TestToken".to_string().into(), + None, + false, + ) + .unwrap(), + HealthCheckApiConfig::new("127.0.0.1:1313".parse().unwrap(), None, false).unwrap(), + ) + .unwrap() + } + + /// Helper to create a domain `TrackerConfig` with HTTP ports (no TLS) + fn domain_tracker_config_with_http_ports(ports: &[u16]) -> TrackerConfig { + use crate::shared::DomainName; + TrackerConfig::new( + TrackerCoreConfig::new( + TrackerDatabaseConfig::Sqlite(SqliteConfig::new("tracker.db").unwrap()), + false, + ), + vec![], + ports + .iter() + .map(|p| { + HttpTrackerConfig::new(format!("0.0.0.0:{p}").parse().unwrap(), None, false) + .unwrap() + }) + .collect(), + HttpApiConfig::new( + "0.0.0.0:1212".parse().unwrap(), + "TestToken".to_string().into(), + Some(DomainName::new("api.example.com").unwrap()), + true, // API has TLS so only HTTP tracker ports are tested + ) + .unwrap(), + HealthCheckApiConfig::new("127.0.0.1:1313".parse().unwrap(), None, false).unwrap(), + ) + .unwrap() + } + + /// Helper to create a minimal domain `TrackerConfig` (no UDP, no HTTP, API has TLS) + fn minimal_domain_tracker_config_with_api_tls() -> TrackerConfig { + use crate::shared::DomainName; + TrackerConfig::new( + TrackerCoreConfig::new( + TrackerDatabaseConfig::Sqlite(SqliteConfig::new("tracker.db").unwrap()), + false, + ), + vec![], + vec![], + HttpApiConfig::new( + "0.0.0.0:1212".parse().unwrap(), + "TestToken".to_string().into(), + Some(DomainName::new("api.example.com").unwrap()), + true, + ) + .unwrap(), + HealthCheckApiConfig::new("127.0.0.1:1313".parse().unwrap(), None, false).unwrap(), + ) + .unwrap() + } // ========================================================================== // Network assignment tests @@ -123,125 +212,77 @@ mod tests { #[test] fn it_should_connect_tracker_to_metrics_network_when_prometheus_enabled() { - let config = TrackerServiceConfig::new( - vec![6969], - vec![], - 1212, - false, - true, // has_prometheus - false, // has_mysql - false, // has_caddy - ); + let domain_config = basic_domain_tracker_config(); + let context = make_context(true, false, false); + let config = TrackerServiceContext::from_domain_config(&domain_config, &context); - assert!(config.networks.contains(&Network::Metrics)); + assert!(config.networks().contains(&Network::Metrics)); } #[test] fn it_should_not_connect_tracker_to_metrics_network_when_prometheus_disabled() { - let config = TrackerServiceConfig::new( - vec![6969], - vec![], - 1212, - false, - false, // has_prometheus - false, // has_mysql - false, // has_caddy - ); + let domain_config = basic_domain_tracker_config(); + let context = make_context(false, false, false); + let config = TrackerServiceContext::from_domain_config(&domain_config, &context); - assert!(!config.networks.contains(&Network::Metrics)); + assert!(!config.networks().contains(&Network::Metrics)); } #[test] fn it_should_connect_tracker_to_database_network_when_mysql_enabled() { - let config = TrackerServiceConfig::new( - vec![6969], - vec![], - 1212, - false, - false, // has_prometheus - true, // has_mysql - false, // has_caddy - ); + let domain_config = basic_domain_tracker_config(); + let context = make_context(false, true, false); + let config = TrackerServiceContext::from_domain_config(&domain_config, &context); - assert!(config.networks.contains(&Network::Database)); + assert!(config.networks().contains(&Network::Database)); } #[test] fn it_should_not_connect_tracker_to_database_network_when_mysql_disabled() { - let config = TrackerServiceConfig::new( - vec![6969], - vec![], - 1212, - false, - false, // has_prometheus - false, // has_mysql (SQLite) - false, // has_caddy - ); + let domain_config = basic_domain_tracker_config(); + let context = make_context(false, false, false); + let config = TrackerServiceContext::from_domain_config(&domain_config, &context); - assert!(!config.networks.contains(&Network::Database)); + assert!(!config.networks().contains(&Network::Database)); } #[test] fn it_should_connect_tracker_to_proxy_network_when_caddy_enabled() { - let config = TrackerServiceConfig::new( - vec![6969], - vec![], - 1212, - true, // http_api_has_tls - false, // has_prometheus - false, // has_mysql - true, // has_caddy - ); + let domain_config = domain_tracker_config_with_api_tls(); + let context = make_context(false, false, true); + let config = TrackerServiceContext::from_domain_config(&domain_config, &context); - assert!(config.networks.contains(&Network::Proxy)); + assert!(config.networks().contains(&Network::Proxy)); } #[test] fn it_should_not_connect_tracker_to_proxy_network_when_caddy_disabled() { - let config = TrackerServiceConfig::new( - vec![6969], - vec![], - 1212, - false, - false, // has_prometheus - false, // has_mysql - false, // has_caddy - ); + let domain_config = basic_domain_tracker_config(); + let context = make_context(false, false, false); + let config = TrackerServiceContext::from_domain_config(&domain_config, &context); - assert!(!config.networks.contains(&Network::Proxy)); + assert!(!config.networks().contains(&Network::Proxy)); } #[test] fn it_should_connect_tracker_to_all_networks_when_all_services_enabled() { - let config = TrackerServiceConfig::new( - vec![6969], - vec![], - 1212, - true, - true, // has_prometheus - true, // has_mysql - true, // has_caddy - ); + let domain_config = domain_tracker_config_with_api_tls(); + let context = make_context(true, true, true); + let config = TrackerServiceContext::from_domain_config(&domain_config, &context); assert_eq!( - config.networks, - vec![Network::Metrics, Network::Database, Network::Proxy] + config.networks(), + &[Network::Metrics, Network::Database, Network::Proxy] ); } #[test] fn it_should_have_no_networks_when_minimal_deployment() { - let config = TrackerServiceConfig::new( - vec![6969], - vec![], - 1212, - false, - false, // has_prometheus - false, // has_mysql (SQLite) - false, // has_caddy - ); + let domain_config = basic_domain_tracker_config(); + let context = make_context(false, false, false); + let config = TrackerServiceContext::from_domain_config(&domain_config, &context); - assert!(config.networks.is_empty()); + assert!(config.networks().is_empty()); } // ========================================================================== @@ -250,15 +291,9 @@ mod tests { #[test] fn it_should_serialize_networks_to_name_strings() { - let config = TrackerServiceConfig::new( - vec![6969], - vec![], - 1212, - true, - true, // has_prometheus - true, // has_mysql - false, // has_caddy - ); + let domain_config = domain_tracker_config_with_api_tls(); + let context = make_context(true, true, false); + let config = TrackerServiceContext::from_domain_config(&domain_config, &context); let json = serde_json::to_value(&config).expect("serialization should succeed"); @@ -268,78 +303,66 @@ mod tests { } // ========================================================================== - // Port derivation tests + // Port derivation tests (testing from_domain_config delegation to domain) // ========================================================================== #[test] fn it_should_derive_udp_ports() { - let config = TrackerServiceConfig::new( - vec![6969, 6970], - vec![], - 1212, - true, // TLS enabled, so API port not exposed - false, - false, - false, - ); - - assert_eq!(config.ports.len(), 2); - assert_eq!(config.ports[0].binding(), "6969:6969/udp"); - assert_eq!(config.ports[1].binding(), "6970:6970/udp"); + let domain_config = domain_tracker_config_with_udp_ports(&[6969, 6970]); + let context = make_context(false, false, false); + let config = TrackerServiceContext::from_domain_config(&domain_config, &context); + + // UDP ports + API port (no TLS) = 3 ports + // Filter just UDP ports + let udp_ports: Vec<_> = config + .ports() + .iter() + .filter(|p| p.binding().ends_with("/udp")) + .collect(); + assert_eq!(udp_ports.len(), 2); + assert_eq!(udp_ports[0].binding(), "6969:6969/udp"); + assert_eq!(udp_ports[1].binding(), "6970:6970/udp"); } #[test] fn it_should_derive_http_ports_without_tls() { - let config = TrackerServiceConfig::new( - vec![], - vec![7070, 7071], - 1212, - true, // TLS enabled, so API port not exposed - false, - false, - false, - ); - - assert_eq!(config.ports.len(), 2); - assert_eq!(config.ports[0].binding(), "7070:7070"); - assert_eq!(config.ports[1].binding(), "7071:7071"); + let domain_config = domain_tracker_config_with_http_ports(&[7070, 7071]); + let context = make_context(false, false, false); + let config = TrackerServiceContext::from_domain_config(&domain_config, &context); + + // HTTP tracker ports only (API has TLS so not exposed) + assert_eq!(config.ports().len(), 2); + assert_eq!(config.ports()[0].binding(), "7070:7070"); + assert_eq!(config.ports()[1].binding(), "7071:7071"); } #[test] fn it_should_derive_api_port_when_no_tls() { - let config = TrackerServiceConfig::new( - vec![], - vec![], - 1212, - false, // No TLS, so API port exposed - false, - false, - false, - ); - - assert_eq!(config.ports.len(), 1); - assert_eq!(config.ports[0].binding(), "1212:1212"); + let domain_config = basic_domain_tracker_config(); + let context = make_context(false, false, false); + let config = TrackerServiceContext::from_domain_config(&domain_config, &context); + + // UDP port + API port = 2 ports + // API port is the second one + let api_port = config.ports().iter().find(|p| p.binding() == "1212:1212"); + assert!(api_port.is_some()); } #[test] fn it_should_not_derive_api_port_when_tls_enabled() { - let config = TrackerServiceConfig::new( - vec![], - vec![], - 1212, - true, // TLS enabled, so API port not exposed - false, - false, - false, - ); + let domain_config = minimal_domain_tracker_config_with_api_tls(); + let context = make_context(false, false, false); + let config = TrackerServiceContext::from_domain_config(&domain_config, &context); - assert!(config.ports.is_empty()); + // No UDP, no HTTP without TLS, API has TLS = no ports + assert!(config.ports().is_empty()); } #[test] fn it_should_serialize_ports_with_binding_and_description() { - let config = - TrackerServiceConfig::new(vec![6969], vec![], 1212, false, false, false, false); + let domain_config = basic_domain_tracker_config(); + let context = make_context(false, false, false); + let config = TrackerServiceContext::from_domain_config(&domain_config, &context); let json = serde_json::to_value(&config).expect("serialization should succeed"); diff --git a/src/infrastructure/templating/docker_compose/template/wrappers/docker_compose/mod.rs b/src/infrastructure/templating/docker_compose/template/wrappers/docker_compose/mod.rs index ab90a246..282d0d8d 100644 --- a/src/infrastructure/templating/docker_compose/template/wrappers/docker_compose/mod.rs +++ b/src/infrastructure/templating/docker_compose/template/wrappers/docker_compose/mod.rs @@ -3,6 +3,6 @@ pub mod template; pub use context::{ DockerComposeContext, DockerComposeContextBuilder, MysqlSetupConfig, NetworkDefinition, - TrackerPorts, TrackerServiceConfig, + TrackerServiceContext, }; pub use template::DockerComposeTemplate; diff --git a/src/infrastructure/templating/docker_compose/template/wrappers/docker_compose/template.rs b/src/infrastructure/templating/docker_compose/template/wrappers/docker_compose/template.rs index 9263f831..dd5e55fe 100644 --- a/src/infrastructure/templating/docker_compose/template/wrappers/docker_compose/template.rs +++ b/src/infrastructure/templating/docker_compose/template/wrappers/docker_compose/template.rs @@ -81,20 +81,44 @@ impl DockerComposeTemplate { #[cfg(test)] mod tests { - use super::super::context::{MysqlSetupConfig, TrackerServiceConfig}; + use super::super::context::{MysqlSetupConfig, TrackerServiceContext}; use super::*; - /// Helper to create `TrackerServiceConfig` for tests (no TLS, no networks) - fn test_tracker_config() -> TrackerServiceConfig { - TrackerServiceConfig::new( - vec![6868, 6969], // UDP ports - vec![7070], // HTTP ports without TLS - 1212, // API port - false, // API has no TLS - false, // has_prometheus - false, // has_mysql - false, // has_caddy + use crate::domain::topology::EnabledServices; + use crate::domain::tracker::{ + DatabaseConfig as TrackerDatabaseConfig, HealthCheckApiConfig, HttpApiConfig, + HttpTrackerConfig, SqliteConfig, TrackerConfig, TrackerCoreConfig, UdpTrackerConfig, + }; + + /// Helper to create a domain `TrackerConfig` for tests + fn test_domain_tracker_config() -> TrackerConfig { + TrackerConfig::new( + TrackerCoreConfig::new( + TrackerDatabaseConfig::Sqlite(SqliteConfig::new("tracker.db").unwrap()), + false, + ), + vec![ + UdpTrackerConfig::new("0.0.0.0:6868".parse().unwrap(), None).unwrap(), + 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(), + "TestToken".to_string().into(), + None, + false, + ) + .unwrap(), + HealthCheckApiConfig::new("127.0.0.1:1313".parse().unwrap(), None, false).unwrap(), ) + .unwrap() + } + + /// Helper to create `TrackerServiceContext` for tests (no TLS, no networks) + fn test_tracker_config() -> TrackerServiceContext { + let domain_config = test_domain_tracker_config(); + let context = EnabledServices::from(&[]); + TrackerServiceContext::from_domain_config(&domain_config, &context) } #[test] diff --git a/src/lib.rs b/src/lib.rs index 9d260835..e4c114ba 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -25,6 +25,12 @@ //! - `shared` - Shared modules used across different layers //! - `testing` - Testing utilities (unit, integration, and end-to-end) +// False positive: clippy reports large_stack_arrays for vec![] macro with ServiceTopology +// This is a known upstream issue: https://github.com/rust-lang/rust-clippy/issues/12586 +// Tracking issue: https://github.com/torrust/torrust-tracker-deployer/issues/304 +// See: docs/issues/304-clippy-large-stack-arrays-false-positive.md +#![allow(clippy::large_stack_arrays)] + pub mod adapters; pub mod application; pub mod bootstrap;