From 894da3cfaa4541f772d4ac32f9f0e3937f4753b0 Mon Sep 17 00:00:00 2001 From: Jose Celano Date: Mon, 26 Jan 2026 08:33:12 +0000 Subject: [PATCH 01/19] refactor: [#301] add PortDerivation trait in domain topology --- ...-phase-4-service-topology-ddd-alignment.md | 77 +++++++++++++------ src/domain/topology/mod.rs | 3 + src/domain/topology/traits.rs | 56 ++++++++++++++ 3 files changed, 112 insertions(+), 24 deletions(-) create mode 100644 src/domain/topology/traits.rs 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..3fceb445 100644 --- a/docs/issues/301-phase-4-service-topology-ddd-alignment.md +++ b/docs/issues/301-phase-4-service-topology-ddd-alignment.md @@ -297,37 +297,66 @@ impl TrackerServiceContext { ## Implementation Plan -### P4.1: Add Trait and Implement in Domain +> **Approach**: Single PR with incremental commits. Each step is a logical commit point. +> Progress tracked with checkboxes below. -- [ ] 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 +### Step 1: Create PortDerivation Trait Foundation -### P4.2: Create Domain Topology Builder +- [x] 1.1 Create `src/domain/topology/traits.rs` with `PortDerivation` trait +- [x] 1.2 Export trait from `src/domain/topology/mod.rs` -- [ ] 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 +### Step 2: Implement PortDerivation for Prometheus (Simplest) -### P4.3: Refactor Infrastructure to Pure DTOs +- [ ] 2.1 Implement `PortDerivation` for `PrometheusConfig` in domain +- [ ] 2.2 Add unit tests for Prometheus port derivation +- [ ] 2.3 Update infrastructure `PrometheusServiceConfig` to use domain trait +- [ ] 2.4 Remove `derive_prometheus_ports()` calls from infrastructure -- [ ] 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 +### Step 3: Implement PortDerivation for Grafana -### P4.4: Cleanup +- [ ] 3.1 Implement `PortDerivation` for `GrafanaConfig` in domain +- [ ] 3.2 Add unit tests for Grafana port derivation +- [ ] 3.3 Update infrastructure `GrafanaServiceConfig` to use domain trait +- [ ] 3.4 Remove `derive_grafana_ports()` calls from infrastructure -- [ ] Delete `src/infrastructure/.../context/port_derivation.rs` -- [ ] Remove any remaining business logic from infrastructure -- [ ] Update documentation -- [ ] Run full E2E test suite +### Step 4: Implement PortDerivation for Tracker (Most Complex) + +- [ ] 4.1 Implement `PortDerivation` for `TrackerConfig` in domain +- [ ] 4.2 Add unit tests for Tracker port derivation +- [ ] 4.3 Update infrastructure `TrackerServiceConfig` to use domain trait +- [ ] 4.4 Remove `derive_tracker_ports()` calls from infrastructure + +### Step 5: Fixed Port Services (Caddy, MySQL) + +- [ ] 5.1 Create `src/domain/topology/fixed_ports.rs` with `caddy_ports()` and `mysql_ports()` +- [ ] 5.2 Add unit tests for fixed port functions +- [ ] 5.3 Update infrastructure to use domain fixed port functions +- [ ] 5.4 Remove `derive_caddy_ports()` and `derive_mysql_ports()` from infrastructure + +### Step 6: Network Computation - Domain Topology Builder + +- [ ] 6.1 Create `src/domain/topology/builder.rs` with `DockerComposeTopologyBuilder` +- [ ] 6.2 Move Tracker network computation to builder +- [ ] 6.3 Move Prometheus network computation to builder +- [ ] 6.4 Move Grafana network computation to builder +- [ ] 6.5 Move Caddy network computation to builder +- [ ] 6.6 Move MySQL network computation to builder +- [ ] 6.7 Add unit tests for builder network derivation + +### Step 7: Refactor Infrastructure to Pure DTOs + +- [ ] 7.1 Remove `compute_networks()` from `TrackerServiceConfig` +- [ ] 7.2 Remove `compute_networks()` from `PrometheusServiceConfig` +- [ ] 7.3 Remove `compute_networks()` from `GrafanaServiceConfig` +- [ ] 7.4 Update `DockerComposeContextBuilder` to receive topology from domain +- [ ] 7.5 Rename service config types to `*Context` (optional, for clarity) + +### Step 8: Cleanup and Verification + +- [ ] 8.1 Delete `src/infrastructure/.../context/port_derivation.rs` +- [ ] 8.2 Remove unused imports and dead code +- [ ] 8.3 Run full E2E test suite +- [ ] 8.4 Run pre-commit checks: `./scripts/pre-commit.sh` ## Files Changed diff --git a/src/domain/topology/mod.rs b/src/domain/topology/mod.rs index 4e41d4f3..4d6c4636 100644 --- a/src/domain/topology/mod.rs +++ b/src/domain/topology/mod.rs @@ -20,12 +20,14 @@ //! - [`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 pub mod aggregate; 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}; @@ -33,3 +35,4 @@ pub use error::{PortConflict, TopologyError}; pub use network::Network; pub use port::PortBinding; pub use service::Service; +pub use traits::PortDerivation; diff --git a/src/domain/topology/traits.rs b/src/domain/topology/traits.rs new file mode 100644 index 00000000..30cf5b18 --- /dev/null +++ b/src/domain/topology/traits.rs @@ -0,0 +1,56 @@ +//! 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::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; +} From b8db37fc91c6965e70397313ec90f3135376ae05 Mon Sep 17 00:00:00 2001 From: Jose Celano Date: Mon, 26 Jan 2026 08:46:53 +0000 Subject: [PATCH 02/19] refactor: [#301] implement PortDerivation for PrometheusConfig --- ...-phase-4-service-topology-ddd-alignment.md | 8 +-- src/domain/prometheus/config.rs | 55 +++++++++++++++++++ .../docker_compose/context/builder.rs | 7 ++- .../docker_compose/context/prometheus.rs | 33 +++++++---- 4 files changed, 84 insertions(+), 19 deletions(-) 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 3fceb445..decf9426 100644 --- a/docs/issues/301-phase-4-service-topology-ddd-alignment.md +++ b/docs/issues/301-phase-4-service-topology-ddd-alignment.md @@ -307,10 +307,10 @@ impl TrackerServiceContext { ### Step 2: Implement PortDerivation for Prometheus (Simplest) -- [ ] 2.1 Implement `PortDerivation` for `PrometheusConfig` in domain -- [ ] 2.2 Add unit tests for Prometheus port derivation -- [ ] 2.3 Update infrastructure `PrometheusServiceConfig` to use domain trait -- [ ] 2.4 Remove `derive_prometheus_ports()` calls from infrastructure +- [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 diff --git a/src/domain/prometheus/config.rs b/src/domain/prometheus/config.rs index 33aedb9a..d5868066 100644 --- a/src/domain/prometheus/config.rs +++ b/src/domain/prometheus/config.rs @@ -6,6 +6,8 @@ use std::num::NonZeroU32; use serde::{Deserialize, Serialize}; +use crate::domain::topology::{PortBinding, PortDerivation}; + /// Default scrape interval in seconds /// /// This is the recommended interval for most use cases, balancing @@ -85,6 +87,21 @@ 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)", + )] + } +} + #[cfg(test)] mod tests { use std::num::NonZeroU32; @@ -143,4 +160,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/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..dc23fa78 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 @@ -140,9 +140,10 @@ impl DockerComposeContextBuilder { let has_caddy = self.has_caddy; // 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| PrometheusServiceConfig::new(config, has_grafana)); // Build Grafana service config if enabled let grafana = self.grafana_config.map(|config| { 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..103e3a44 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,10 +3,10 @@ // External crates use serde::Serialize; -use crate::domain::topology::Network; +use crate::domain::prometheus::PrometheusConfig; +use crate::domain::topology::{Network, PortDerivation}; use super::port_definition::PortDefinition; -use super::port_derivation::derive_prometheus_ports; /// Prometheus service configuration for Docker Compose /// @@ -34,16 +34,17 @@ impl PrometheusServiceConfig { /// /// # Arguments /// - /// * `scrape_interval_in_secs` - The scrape interval in seconds + /// * `config` - The domain Prometheus configuration /// * `has_grafana` - Whether Grafana is enabled (adds `visualization_network`) #[must_use] - pub fn new(scrape_interval_in_secs: u32, has_grafana: bool) -> Self { + pub fn new(config: &PrometheusConfig, has_grafana: bool) -> Self { let networks = Self::compute_networks(has_grafana); - let port_bindings = derive_prometheus_ports(); + // Use domain PortDerivation trait for port logic + let port_bindings = config.derive_ports(); let ports = port_bindings.iter().map(PortDefinition::from).collect(); Self { - scrape_interval_in_secs, + scrape_interval_in_secs: config.scrape_interval_in_secs(), ports, networks, } @@ -63,18 +64,26 @@ impl PrometheusServiceConfig { #[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"), + ) + } + #[test] fn it_should_connect_prometheus_to_metrics_network() { - let config = PrometheusServiceConfig::new(15, false); + let config = PrometheusServiceConfig::new(&make_config(15), false); 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 config = PrometheusServiceConfig::new(&make_config(15), false); assert_eq!(config.networks, vec![Network::Metrics]); assert!(!config.networks.contains(&Network::Visualization)); @@ -82,7 +91,7 @@ mod tests { #[test] fn it_should_connect_prometheus_to_visualization_network_when_grafana_enabled() { - let config = PrometheusServiceConfig::new(30, true); + let config = PrometheusServiceConfig::new(&make_config(30), true); assert_eq!( config.networks, @@ -92,7 +101,7 @@ mod tests { #[test] fn it_should_serialize_networks_to_name_strings() { - let config = PrometheusServiceConfig::new(15, true); + let config = PrometheusServiceConfig::new(&make_config(15), true); let json = serde_json::to_value(&config).expect("serialization should succeed"); @@ -103,7 +112,7 @@ mod tests { #[test] fn it_should_expose_localhost_port_9090() { - let config = PrometheusServiceConfig::new(15, false); + let config = PrometheusServiceConfig::new(&make_config(15), false); assert_eq!(config.ports.len(), 1); assert_eq!(config.ports[0].binding(), "127.0.0.1:9090:9090"); @@ -111,7 +120,7 @@ mod tests { #[test] fn it_should_serialize_ports_with_binding_and_description() { - let config = PrometheusServiceConfig::new(15, false); + let config = PrometheusServiceConfig::new(&make_config(15), false); let json = serde_json::to_value(&config).expect("serialization should succeed"); From cdbbda664cf1dd78cccb71e48a43bea5e5656d28 Mon Sep 17 00:00:00 2001 From: Jose Celano Date: Mon, 26 Jan 2026 09:12:32 +0000 Subject: [PATCH 03/19] refactor: [#301] implement PortDerivation for GrafanaConfig --- ...-phase-4-service-topology-ddd-alignment.md | 8 +-- src/domain/grafana/config.rs | 67 +++++++++++++++++++ .../docker_compose/context/builder.rs | 13 ++-- .../docker_compose/context/grafana.rs | 56 ++++++++-------- 4 files changed, 104 insertions(+), 40 deletions(-) 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 decf9426..a5c16a7c 100644 --- a/docs/issues/301-phase-4-service-topology-ddd-alignment.md +++ b/docs/issues/301-phase-4-service-topology-ddd-alignment.md @@ -314,10 +314,10 @@ impl TrackerServiceContext { ### Step 3: Implement PortDerivation for Grafana -- [ ] 3.1 Implement `PortDerivation` for `GrafanaConfig` in domain -- [ ] 3.2 Add unit tests for Grafana port derivation -- [ ] 3.3 Update infrastructure `GrafanaServiceConfig` to use domain trait -- [ ] 3.4 Remove `derive_grafana_ports()` calls from infrastructure +- [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) diff --git a/src/domain/grafana/config.rs b/src/domain/grafana/config.rs index cba5a215..c17713c0 100644 --- a/src/domain/grafana/config.rs +++ b/src/domain/grafana/config.rs @@ -2,6 +2,7 @@ use serde::{Deserialize, Serialize}; +use crate::domain::topology::{PortBinding, PortDerivation}; use crate::shared::domain_name::DomainName; use crate::shared::secrets::Password; @@ -116,6 +117,23 @@ 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")] + } + } +} + #[cfg(test)] mod tests { use super::*; @@ -233,4 +251,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/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 dc23fa78..c52d2c81 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 @@ -146,15 +146,10 @@ impl DockerComposeContextBuilder { .map(|config| PrometheusServiceConfig::new(config, has_grafana)); // 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| GrafanaServiceConfig::new(config, has_caddy)); // Build Caddy service config if enabled let caddy = if has_caddy { 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..ed12adc1 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,11 +3,11 @@ // External crates use serde::Serialize; -use crate::domain::topology::Network; +use crate::domain::grafana::GrafanaConfig; +use crate::domain::topology::{Network, PortDerivation}; use crate::shared::secrets::Password; use super::port_definition::PortDefinition; -use super::port_derivation::derive_grafana_ports; /// Grafana service configuration for Docker Compose /// @@ -41,25 +41,19 @@ impl GrafanaServiceConfig { /// /// # Arguments /// - /// * `admin_user` - Grafana admin username - /// * `admin_password` - Grafana admin password - /// * `has_tls` - Whether Grafana has TLS enabled (via Caddy) + /// * `config` - The domain Grafana configuration /// * `has_caddy` - Whether Caddy TLS proxy is enabled (adds `proxy_network`) #[must_use] - pub fn new( - admin_user: String, - admin_password: Password, - has_tls: bool, - has_caddy: bool, - ) -> Self { + pub fn new(config: &GrafanaConfig, has_caddy: bool) -> Self { let networks = Self::compute_networks(has_caddy); - let port_bindings = derive_grafana_ports(has_tls); + // Use domain PortDerivation trait for port logic + let port_bindings = config.derive_ports(); let ports = port_bindings.iter().map(PortDefinition::from).collect(); Self { - admin_user, - admin_password, - has_tls, + admin_user: config.admin_user().to_string(), + admin_password: config.admin_password().clone(), + has_tls: config.use_tls_proxy(), ports, networks, } @@ -80,19 +74,31 @@ impl GrafanaServiceConfig { #[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) + } + } #[test] fn it_should_connect_grafana_to_visualization_network() { - let config = - GrafanaServiceConfig::new("admin".to_string(), Password::new("password"), false, false); + let config = GrafanaServiceConfig::new(&make_config(false), false); 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 config = GrafanaServiceConfig::new(&make_config(false), false); assert_eq!(config.networks, vec![Network::Visualization]); assert!(!config.networks.contains(&Network::Proxy)); @@ -100,8 +106,7 @@ mod tests { #[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 config = GrafanaServiceConfig::new(&make_config(true), true); assert_eq!( config.networks, @@ -111,8 +116,7 @@ mod tests { #[test] fn it_should_serialize_networks_to_name_strings() { - let config = - GrafanaServiceConfig::new("admin".to_string(), Password::new("password"), true, true); + let config = GrafanaServiceConfig::new(&make_config(true), true); let json = serde_json::to_value(&config).expect("serialization should succeed"); @@ -123,8 +127,7 @@ 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 config = GrafanaServiceConfig::new(&make_config(false), false); assert_eq!(config.ports.len(), 1); assert_eq!(config.ports[0].binding(), "3000:3000"); @@ -132,8 +135,7 @@ mod tests { #[test] fn it_should_not_expose_ports_when_tls_enabled() { - let config = - GrafanaServiceConfig::new("admin".to_string(), Password::new("password"), true, true); + let config = GrafanaServiceConfig::new(&make_config(true), true); assert!(config.ports.is_empty()); } From 62e02ff419a1ae943358aea8759bc473b1a73d4d Mon Sep 17 00:00:00 2001 From: Jose Celano Date: Mon, 26 Jan 2026 09:24:32 +0000 Subject: [PATCH 04/19] refactor: [#301] implement PortDerivation for TrackerConfig --- ...-phase-4-service-topology-ddd-alignment.md | 6 +- src/domain/tracker/config/mod.rs | 210 ++++++++++++++++++ .../docker_compose/context/tracker.rs | 59 ++++- 3 files changed, 271 insertions(+), 4 deletions(-) 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 a5c16a7c..a28b6bd6 100644 --- a/docs/issues/301-phase-4-service-topology-ddd-alignment.md +++ b/docs/issues/301-phase-4-service-topology-ddd-alignment.md @@ -321,9 +321,9 @@ impl TrackerServiceContext { ### Step 4: Implement PortDerivation for Tracker (Most Complex) -- [ ] 4.1 Implement `PortDerivation` for `TrackerConfig` in domain -- [ ] 4.2 Add unit tests for Tracker port derivation -- [ ] 4.3 Update infrastructure `TrackerServiceConfig` to use domain trait +- [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 - [ ] 4.4 Remove `derive_tracker_ports()` calls from infrastructure ### Step 5: Fixed Port Services (Caddy, MySQL) diff --git a/src/domain/tracker/config/mod.rs b/src/domain/tracker/config/mod.rs index 4c194dab..93d278fb 100644 --- a/src/domain/tracker/config/mod.rs +++ b/src/domain/tracker/config/mod.rs @@ -10,6 +10,7 @@ use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr}; use serde::{Deserialize, Serialize}; use super::{BindingAddress, Protocol}; +use crate::domain::topology::{PortBinding, PortDerivation}; use crate::shared::DomainName; mod core; @@ -509,6 +510,51 @@ 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 + } +} + /// Trait for types that have a bind address /// /// Used for generic tracker registration in validation logic. @@ -1135,4 +1181,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/wrappers/docker_compose/context/tracker.rs b/src/infrastructure/templating/docker_compose/template/wrappers/docker_compose/context/tracker.rs index c41095f0..30c454da 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,7 +3,8 @@ // External crates use serde::Serialize; -use crate::domain::topology::Network; +use crate::domain::topology::{Network, PortDerivation}; +use crate::domain::tracker::TrackerConfig; use super::port_definition::PortDefinition; use super::port_derivation::derive_tracker_ports; @@ -92,6 +93,62 @@ impl TrackerServiceConfig { } } + /// Creates a new `TrackerServiceConfig` from domain configuration + /// + /// Uses the domain `PortDerivation` trait for port derivation logic, + /// ensuring business rules live in the domain layer. + /// + /// # Arguments + /// + /// * `config` - The domain Tracker configuration + /// * `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`) + #[must_use] + pub fn from_domain_config( + config: &TrackerConfig, + has_prometheus: bool, + has_mysql: bool, + has_caddy: bool, + ) -> Self { + // Extract port info for legacy fields + let udp_tracker_ports: Vec = config + .udp_trackers() + .iter() + .map(|t| t.bind_address().port()) + .collect(); + + let http_tracker_ports_without_tls: Vec = config + .http_trackers() + .iter() + .filter(|t| !t.use_tls_proxy()) + .map(|t| t.bind_address().port()) + .collect(); + + let http_api_port = config.http_api().bind_address().port(); + let http_api_has_tls = config.http_api().use_tls_proxy(); + + 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); + + // Use domain PortDerivation trait for port logic + let port_bindings = config.derive_ports(); + let ports = port_bindings.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, + } + } + /// 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(); From 15ae4c987f55e10b07c4b64760452a986d5929fc Mon Sep 17 00:00:00 2001 From: Jose Celano Date: Mon, 26 Jan 2026 09:32:25 +0000 Subject: [PATCH 05/19] refactor: [#301] add fixed port functions for Caddy and MySQL in domain - Create src/domain/topology/fixed_ports.rs with caddy_ports() and mysql_ports() - Add comprehensive unit tests for fixed port functions - Update CaddyServiceConfig and MysqlServiceConfig to use domain functions - Remove derive_caddy_ports(), derive_mysql_ports(), derive_prometheus_ports(), and derive_grafana_ports() from infrastructure port_derivation.rs - Update mod.rs exports to only export derive_tracker_ports() This completes Step 5: Fixed Port Services (Caddy, MySQL) --- ...-phase-4-service-topology-ddd-alignment.md | 8 +- src/domain/topology/fixed_ports.rs | 140 ++++++++++++ src/domain/topology/mod.rs | 3 + .../wrappers/docker_compose/context/caddy.rs | 5 +- .../wrappers/docker_compose/context/mod.rs | 5 +- .../wrappers/docker_compose/context/mysql.rs | 5 +- .../docker_compose/context/port_derivation.rs | 206 ++---------------- 7 files changed, 167 insertions(+), 205 deletions(-) create mode 100644 src/domain/topology/fixed_ports.rs 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 a28b6bd6..b62c75d3 100644 --- a/docs/issues/301-phase-4-service-topology-ddd-alignment.md +++ b/docs/issues/301-phase-4-service-topology-ddd-alignment.md @@ -328,10 +328,10 @@ impl TrackerServiceContext { ### Step 5: Fixed Port Services (Caddy, MySQL) -- [ ] 5.1 Create `src/domain/topology/fixed_ports.rs` with `caddy_ports()` and `mysql_ports()` -- [ ] 5.2 Add unit tests for fixed port functions -- [ ] 5.3 Update infrastructure to use domain fixed port functions -- [ ] 5.4 Remove `derive_caddy_ports()` and `derive_mysql_ports()` from infrastructure +- [x] 5.1 Create `src/domain/topology/fixed_ports.rs` with `caddy_ports()` and `mysql_ports()` +- [x] 5.2 Add unit tests for fixed port functions +- [x] 5.3 Update infrastructure to use domain fixed port functions +- [x] 5.4 Remove `derive_caddy_ports()` and `derive_mysql_ports()` from infrastructure ### Step 6: Network Computation - Domain Topology Builder diff --git a/src/domain/topology/fixed_ports.rs b/src/domain/topology/fixed_ports.rs new file mode 100644 index 00000000..8573d9d3 --- /dev/null +++ b/src/domain/topology/fixed_ports.rs @@ -0,0 +1,140 @@ +//! Fixed port definitions for services without configuration +//! +//! This module provides port bindings for services that have static port +//! configurations (no user-configurable ports). These are services where +//! the port exposure is fixed by the service's nature, not by configuration. +//! +//! ## Services +//! +//! - **Caddy**: Always exposes 80, 443, and 443/udp for TLS termination +//! - **MySQL**: Never exposes ports (internal-only database access) +//! +//! ## Port Rules Reference +//! +//! | Rule | Service | Description | +//! |---------|---------|-------------------------------------------| +//! | PORT-09 | Caddy | Always exposes 80, 443, 443/udp | +//! | PORT-11 | MySQL | No exposed ports (internal only) | + +use super::PortBinding; + +/// 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 +/// +/// # Examples +/// +/// ```rust +/// use torrust_tracker_deployer_lib::domain::topology::caddy_ports; +/// +/// let ports = caddy_ports(); +/// assert_eq!(ports.len(), 3); +/// assert!(ports.iter().any(|p| p.host_port() == 80)); +/// assert!(ports.iter().any(|p| p.host_port() == 443)); +/// ``` +#[must_use] +pub fn 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 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. +/// +/// # Examples +/// +/// ```rust +/// use torrust_tracker_deployer_lib::domain::topology::mysql_ports; +/// +/// let ports = mysql_ports(); +/// assert!(ports.is_empty()); +/// ``` +#[must_use] +pub fn mysql_ports() -> Vec { + vec![] +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::domain::tracker::Protocol; + + // ========================================================================= + // Caddy port tests (PORT-09) + // ========================================================================= + + mod caddy { + use super::*; + + #[test] + fn it_should_expose_port_80_for_acme_challenge() { + let ports = caddy_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 ports = caddy_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_http3() { + let ports = caddy_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("HTTP/3")); + } + + #[test] + fn it_should_have_exactly_three_ports() { + let ports = caddy_ports(); + + assert_eq!(ports.len(), 3); + } + } + + // ========================================================================= + // MySQL port tests (PORT-11) + // ========================================================================= + + mod mysql { + use super::*; + + #[test] + fn it_should_have_no_exposed_ports() { + // PORT-11: MySQL no exposed ports + let ports = mysql_ports(); + + assert!(ports.is_empty()); + } + } +} diff --git a/src/domain/topology/mod.rs b/src/domain/topology/mod.rs index 4d6c4636..1ae6198b 100644 --- a/src/domain/topology/mod.rs +++ b/src/domain/topology/mod.rs @@ -21,9 +21,11 @@ //! - [`ServiceTopology`] - Topology information for a single service //! - [`TopologyError`] - Validation errors (e.g., port conflicts) //! - [`PortDerivation`] - Trait for services that derive their port bindings +//! - [`caddy_ports`], [`mysql_ports`] - Fixed port functions for static services pub mod aggregate; pub mod error; +pub mod fixed_ports; pub mod network; pub mod port; pub mod service; @@ -32,6 +34,7 @@ pub mod traits; // Re-export main types for convenience pub use aggregate::{DockerComposeTopology, ServiceTopology}; pub use error::{PortConflict, TopologyError}; +pub use fixed_ports::{caddy_ports, mysql_ports}; pub use network::Network; pub use port::PortBinding; pub use service::Service; 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..fdb5c32b 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 @@ -16,10 +16,9 @@ use serde::Serialize; -use crate::domain::topology::Network; +use crate::domain::topology::{caddy_ports, Network}; use super::port_definition::PortDefinition; -use super::port_derivation::derive_caddy_ports; /// Caddy reverse proxy service configuration for Docker Compose /// @@ -62,7 +61,7 @@ impl CaddyServiceConfig { /// - Port 443/udp: HTTP/3 QUIC #[must_use] pub fn new() -> Self { - let port_bindings = derive_caddy_ports(); + let port_bindings = caddy_ports(); let ports = port_bindings.iter().map(PortDefinition::from).collect(); Self { 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..7a4765dd 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 @@ -26,10 +26,7 @@ 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 port_derivation::derive_tracker_ports; pub use prometheus::PrometheusServiceConfig; pub use tracker::{TrackerPorts, TrackerServiceConfig}; 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..c3ef271a 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 @@ -17,10 +17,9 @@ use serde::Serialize; -use crate::domain::topology::Network; +use crate::domain::topology::{mysql_ports, Network}; use super::port_definition::PortDefinition; -use super::port_derivation::derive_mysql_ports; /// `MySQL` service configuration for Docker Compose /// @@ -59,7 +58,7 @@ impl MysqlServiceConfig { /// `MySQL` never exposes ports externally for security. #[must_use] pub fn new() -> Self { - let port_bindings = derive_mysql_ports(); + let port_bindings = mysql_ports(); let ports = port_bindings.iter().map(PortDefinition::from).collect(); Self { 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 index 2e15b86f..8dcbd4bd 100644 --- 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 @@ -1,7 +1,7 @@ //! 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. +//! This module implements PORT-01 through PORT-06 rules from the refactoring plan, +//! deriving port bindings for the Tracker service based on configuration. //! //! ## Design Principles //! @@ -10,6 +10,19 @@ //! - Type-safe: uses `PortBinding` domain type //! - Self-documenting: each port has a description for YAML comments //! +//! ## Note on Port Derivation Migration +//! +//! Port derivation for other services has been moved to the domain layer: +//! - Tracker: `TrackerConfig::derive_ports()` (domain/tracker/config) +//! - Prometheus: `PrometheusConfig::derive_ports()` (domain/prometheus) +//! - Grafana: `GrafanaConfig::derive_ports()` (domain/grafana) +//! - Caddy: `domain::topology::caddy_ports()` (domain/topology) +//! - MySQL: `domain::topology::mysql_ports()` (domain/topology) +//! +//! This file contains legacy tracker port derivation for backward compatibility +//! during the migration. See `TrackerServiceConfig::from_domain_config()` for +//! the new domain-based approach. +//! //! ## Port Rules Reference //! //! | Rule | Description | @@ -20,11 +33,6 @@ //! | 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; @@ -74,68 +82,6 @@ pub fn derive_tracker_ports( 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::*; @@ -216,126 +162,4 @@ mod tests { } } } - - // ========================================================================= - // 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()); - } - } } From 9da19b6e012f5f16eef5ca7c47c7f10d90a77854 Mon Sep 17 00:00:00 2001 From: Jose Celano Date: Mon, 26 Jan 2026 10:09:44 +0000 Subject: [PATCH 06/19] refactor: [#301] complete DDD migration - remove TrackerServiceConfig::new() - Remove TrackerServiceConfig::new() method entirely - Delete port_derivation.rs from infrastructure layer - Migrate all callers to use from_domain_config() - Update application layer to pass domain TrackerConfig directly - Add domain config helper functions for test code - Fix clippy doc_markdown warnings for MySQL - All 396 tests pass, all pre-commit checks pass Port derivation now uses domain PortDerivation trait exclusively. Infrastructure TrackerServiceConfig is now a pure DTO. --- ...-phase-4-service-topology-ddd-alignment.md | 18 +- .../rendering/docker_compose_templates.rs | 55 +-- src/domain/topology/fixed_ports.rs | 8 +- .../template/renderer/docker_compose.rs | 57 +++- .../template/renderer/project_generator.rs | 17 +- .../docker_compose/context/builder.rs | 79 ++++- .../wrappers/docker_compose/context/mod.rs | 188 +++++++---- .../docker_compose/context/port_derivation.rs | 165 --------- .../docker_compose/context/tracker.rs | 316 ++++++++++-------- .../wrappers/docker_compose/template.rs | 43 ++- 10 files changed, 461 insertions(+), 485 deletions(-) delete mode 100644 src/infrastructure/templating/docker_compose/template/wrappers/docker_compose/context/port_derivation.rs 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 b62c75d3..dd1501a6 100644 --- a/docs/issues/301-phase-4-service-topology-ddd-alignment.md +++ b/docs/issues/301-phase-4-service-topology-ddd-alignment.md @@ -28,10 +28,10 @@ These are business rules that should be in the domain layer: ## Goals -- [ ] Move port derivation logic to domain layer using `PortDerivation` trait +- [x] 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] Maintain all existing functionality and E2E tests passing ## 🏗️ Architecture Requirements @@ -324,7 +324,11 @@ impl TrackerServiceContext { - [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 -- [ ] 4.4 Remove `derive_tracker_ports()` calls from infrastructure +- [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) @@ -353,10 +357,10 @@ impl TrackerServiceContext { ### Step 8: Cleanup and Verification -- [ ] 8.1 Delete `src/infrastructure/.../context/port_derivation.rs` -- [ ] 8.2 Remove unused imports and dead code -- [ ] 8.3 Run full E2E test suite -- [ ] 8.4 Run pre-commit checks: `./scripts/pre-commit.sh` +- [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 diff --git a/src/application/steps/rendering/docker_compose_templates.rs b/src/application/steps/rendering/docker_compose_templates.rs index a986cf61..7c9c57a0 100644 --- a/src/application/steps/rendering/docker_compose_templates.rs +++ b/src/application/steps/rendering/docker_compose_templates.rs @@ -29,10 +29,9 @@ 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::tracker::DatabaseConfig; use crate::infrastructure::templating::docker_compose::template::wrappers::docker_compose::{ DockerComposeContext, DockerComposeContextBuilder, MysqlSetupConfig, TrackerServiceConfig, }; @@ -157,10 +156,6 @@ impl RenderDockerComposeTemplatesStep { fn build_tracker_config(&self) -> TrackerServiceConfig { 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(); @@ -170,11 +165,8 @@ impl RenderDockerComposeTemplatesStep { ); let has_caddy = self.has_caddy_enabled(); - TrackerServiceConfig::new( - udp_tracker_ports, - http_tracker_ports_without_tls, - http_api_port, - http_api_has_tls, + TrackerServiceConfig::from_domain_config( + tracker_config, has_prometheus, has_mysql, has_caddy, @@ -312,47 +304,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/topology/fixed_ports.rs b/src/domain/topology/fixed_ports.rs index 8573d9d3..8e05d464 100644 --- a/src/domain/topology/fixed_ports.rs +++ b/src/domain/topology/fixed_ports.rs @@ -7,7 +7,7 @@ //! ## Services //! //! - **Caddy**: Always exposes 80, 443, and 443/udp for TLS termination -//! - **MySQL**: Never exposes ports (internal-only database access) +//! - **`MySQL`**: Never exposes ports (internal-only database access) //! //! ## Port Rules Reference //! @@ -46,11 +46,11 @@ pub fn caddy_ports() -> Vec { ] } -/// Derives port bindings for the MySQL database service +/// Derives port bindings for the `MySQL` database service /// -/// Implements PORT-11: MySQL has no exposed ports +/// Implements PORT-11: `MySQL` has no exposed ports /// -/// MySQL is accessed only via Docker network by the tracker service. +/// `MySQL` is accessed only via Docker network by the tracker service. /// It should never be exposed to the host network for security reasons. /// /// # Examples 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..f8a594ab 100644 --- a/src/infrastructure/templating/docker_compose/template/renderer/docker_compose.rs +++ b/src/infrastructure/templating/docker_compose/template/renderer/docker_compose.rs @@ -188,20 +188,47 @@ mod tests { use super::*; use tempfile::TempDir; + 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, }; + /// 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 `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 + let domain_config = test_domain_tracker_config(); + TrackerServiceConfig::from_domain_config( + &domain_config, + false, // has_prometheus + false, // has_mysql + false, // has_caddy ) } @@ -354,14 +381,12 @@ mod tests { 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 tracker = TrackerServiceConfig::from_domain_config( + &domain_config, + true, // has_prometheus + false, // has_mysql + false, // has_caddy ); let prometheus_config = PrometheusConfig::new(std::num::NonZeroU32::new(15).expect("15 is non-zero")); 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..4695ff23 100644 --- a/src/infrastructure/templating/docker_compose/template/renderer/project_generator.rs +++ b/src/infrastructure/templating/docker_compose/template/renderer/project_generator.rs @@ -185,6 +185,7 @@ mod tests { use tempfile::TempDir; use super::*; + use crate::domain::tracker::TrackerConfig; use crate::infrastructure::templating::docker_compose::template::wrappers::docker_compose::TrackerServiceConfig; use crate::infrastructure::templating::docker_compose::DOCKER_COMPOSE_SUBFOLDER; @@ -206,15 +207,13 @@ 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 tracker = TrackerServiceConfig::from_domain_config( + &tracker_config, + false, // has_prometheus + false, // has_mysql + false, // has_caddy ); 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 c52d2c81..eefacca5 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 @@ -370,18 +370,73 @@ impl std::error::Error for PortConflictError {} mod tests { use super::*; use crate::domain::prometheus::PrometheusConfig; + 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 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() -> 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 - ) + let domain_config = minimal_domain_tracker_config(); + TrackerServiceConfig::from_domain_config(&domain_config, false, false, false) } /// Helper to create a tracker config that exposes specific ports @@ -389,10 +444,8 @@ mod tests { udp_ports: Vec, http_ports: Vec, ) -> TrackerServiceConfig { - TrackerServiceConfig::new( - udp_ports, http_ports, 1212, true, // TLS enabled - false, false, false, - ) + let domain_config = domain_tracker_config_with_ports(udp_ports, http_ports); + TrackerServiceConfig::from_domain_config(&domain_config, false, false, false) } // ========================================================================== 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 7a4765dd..f2480e67 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,7 +14,6 @@ mod grafana; mod mysql; mod network_definition; mod port_definition; -mod port_derivation; mod prometheus; mod tracker; @@ -26,7 +25,6 @@ pub use grafana::GrafanaServiceConfig; pub use mysql::MysqlServiceConfig; pub use network_definition::NetworkDefinition; pub use port_definition::PortDefinition; -pub use port_derivation::derive_tracker_ports; pub use prometheus::PrometheusServiceConfig; pub use tracker::{TrackerPorts, TrackerServiceConfig}; @@ -82,15 +80,15 @@ impl DockerComposeContext { /// /// ```rust /// use torrust_tracker_deployer_lib::infrastructure::templating::docker_compose::template::wrappers::docker_compose::{DockerComposeContext, TrackerServiceConfig, MysqlSetupConfig}; + /// use torrust_tracker_deployer_lib::domain::tracker::TrackerConfig; /// - /// 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 tracker_config = TrackerServiceConfig::from_domain_config( + /// &domain_config, + /// false, // has_prometheus + /// false, // has_mysql + /// false, // has_caddy /// ); /// /// // SQLite (default) @@ -98,6 +96,13 @@ impl DockerComposeContext { /// assert_eq!(context.database().driver(), "sqlite3"); /// /// // MySQL + /// let domain_config_for_mysql = TrackerConfig::default(); + /// let tracker_config_with_mysql = TrackerServiceConfig::from_domain_config( + /// &domain_config_for_mysql, + /// false, // has_prometheus + /// true, // has_mysql + /// false, // has_caddy + /// ); /// let mysql_config = MysqlSetupConfig { /// root_password: "root_pass".to_string(), /// database: "db".to_string(), @@ -105,7 +110,7 @@ impl DockerComposeContext { /// password: "pass".to_string(), /// port: 3306, /// }; - /// let context = DockerComposeContext::builder(tracker_config) + /// let context = DockerComposeContext::builder(tracker_config_with_mysql) /// .with_mysql(mysql_config) /// .build(); /// assert_eq!(context.database().driver(), "mysql"); @@ -164,19 +169,104 @@ impl DockerComposeContext { #[cfg(test)] mod tests { use crate::domain::prometheus::PrometheusConfig; + use crate::domain::tracker::{ + DatabaseConfig as TrackerDatabaseConfig, HealthCheckApiConfig, HttpApiConfig, + HttpTrackerConfig, SqliteConfig, TrackerConfig, TrackerCoreConfig, UdpTrackerConfig, + }; use super::*; + /// 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 `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 + let domain_config = test_domain_tracker_config(); + TrackerServiceConfig::from_domain_config(&domain_config, false, false, false) + } + + /// Helper to create `TrackerServiceConfig` 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, + ) -> TrackerServiceConfig { + let domain_config = if use_api_tls { + domain_tracker_config_with_api_tls(6868) + } else { + domain_tracker_config_with_udp_port(6868) + }; + TrackerServiceConfig::from_domain_config( + &domain_config, + has_prometheus, + has_mysql, + has_caddy, ) } @@ -406,15 +496,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(), @@ -436,15 +518,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) @@ -478,15 +552,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 @@ -551,15 +617,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(), @@ -593,15 +651,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(), @@ -641,15 +691,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/port_derivation.rs b/src/infrastructure/templating/docker_compose/template/wrappers/docker_compose/context/port_derivation.rs deleted file mode 100644 index 8dcbd4bd..00000000 --- a/src/infrastructure/templating/docker_compose/template/wrappers/docker_compose/context/port_derivation.rs +++ /dev/null @@ -1,165 +0,0 @@ -//! Port derivation functions for Docker Compose services -//! -//! This module implements PORT-01 through PORT-06 rules from the refactoring plan, -//! deriving port bindings for the Tracker 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 -//! -//! ## Note on Port Derivation Migration -//! -//! Port derivation for other services has been moved to the domain layer: -//! - Tracker: `TrackerConfig::derive_ports()` (domain/tracker/config) -//! - Prometheus: `PrometheusConfig::derive_ports()` (domain/prometheus) -//! - Grafana: `GrafanaConfig::derive_ports()` (domain/grafana) -//! - Caddy: `domain::topology::caddy_ports()` (domain/topology) -//! - MySQL: `domain::topology::mysql_ports()` (domain/topology) -//! -//! This file contains legacy tracker port derivation for backward compatibility -//! during the migration. See `TrackerServiceConfig::from_domain_config()` for -//! the new domain-based approach. -//! -//! ## 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 | - -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 -} - -#[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()); - } - } - } -} 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 30c454da..1cc7b070 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 @@ -7,7 +7,6 @@ use crate::domain::topology::{Network, PortDerivation}; use crate::domain::tracker::TrackerConfig; use super::port_definition::PortDefinition; -use super::port_derivation::derive_tracker_ports; /// Tracker service configuration for Docker Compose /// @@ -45,54 +44,6 @@ pub struct TrackerServiceConfig { } impl TrackerServiceConfig { - /// Creates a new `TrackerServiceConfig` with pre-computed flags - /// - /// # 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`) - #[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(); - - Self { - udp_tracker_ports, - http_tracker_ports_without_tls, - http_api_port, - http_api_has_tls, - needs_ports_section, - ports, - networks, - } - } - /// Creates a new `TrackerServiceConfig` from domain configuration /// /// Uses the domain `PortDerivation` trait for port derivation logic, @@ -174,17 +125,139 @@ pub type TrackerPorts = TrackerServiceConfig; mod tests { use super::*; + use crate::domain::tracker::{ + DatabaseConfig as TrackerDatabaseConfig, HealthCheckApiConfig, HttpApiConfig, + HttpTrackerConfig, SqliteConfig, TrackerConfig, TrackerCoreConfig, UdpTrackerConfig, + }; + + /// 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() + } + + /// 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() + } + + /// 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 // ========================================================================== #[test] fn it_should_connect_tracker_to_metrics_network_when_prometheus_enabled() { - let config = TrackerServiceConfig::new( - vec![6969], - vec![], - 1212, - false, + let domain_config = basic_domain_tracker_config(); + let config = TrackerServiceConfig::from_domain_config( + &domain_config, true, // has_prometheus false, // has_mysql false, // has_caddy @@ -195,11 +268,9 @@ mod tests { #[test] fn it_should_not_connect_tracker_to_metrics_network_when_prometheus_disabled() { - let config = TrackerServiceConfig::new( - vec![6969], - vec![], - 1212, - false, + let domain_config = basic_domain_tracker_config(); + let config = TrackerServiceConfig::from_domain_config( + &domain_config, false, // has_prometheus false, // has_mysql false, // has_caddy @@ -210,11 +281,9 @@ mod tests { #[test] fn it_should_connect_tracker_to_database_network_when_mysql_enabled() { - let config = TrackerServiceConfig::new( - vec![6969], - vec![], - 1212, - false, + let domain_config = basic_domain_tracker_config(); + let config = TrackerServiceConfig::from_domain_config( + &domain_config, false, // has_prometheus true, // has_mysql false, // has_caddy @@ -225,11 +294,9 @@ mod tests { #[test] fn it_should_not_connect_tracker_to_database_network_when_mysql_disabled() { - let config = TrackerServiceConfig::new( - vec![6969], - vec![], - 1212, - false, + let domain_config = basic_domain_tracker_config(); + let config = TrackerServiceConfig::from_domain_config( + &domain_config, false, // has_prometheus false, // has_mysql (SQLite) false, // has_caddy @@ -240,11 +307,9 @@ mod tests { #[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 + let domain_config = domain_tracker_config_with_api_tls(); + let config = TrackerServiceConfig::from_domain_config( + &domain_config, false, // has_prometheus false, // has_mysql true, // has_caddy @@ -255,11 +320,9 @@ mod tests { #[test] fn it_should_not_connect_tracker_to_proxy_network_when_caddy_disabled() { - let config = TrackerServiceConfig::new( - vec![6969], - vec![], - 1212, - false, + let domain_config = basic_domain_tracker_config(); + let config = TrackerServiceConfig::from_domain_config( + &domain_config, false, // has_prometheus false, // has_mysql false, // has_caddy @@ -270,11 +333,9 @@ mod tests { #[test] fn it_should_connect_tracker_to_all_networks_when_all_services_enabled() { - let config = TrackerServiceConfig::new( - vec![6969], - vec![], - 1212, - true, + let domain_config = domain_tracker_config_with_api_tls(); + let config = TrackerServiceConfig::from_domain_config( + &domain_config, true, // has_prometheus true, // has_mysql true, // has_caddy @@ -288,11 +349,9 @@ mod tests { #[test] fn it_should_have_no_networks_when_minimal_deployment() { - let config = TrackerServiceConfig::new( - vec![6969], - vec![], - 1212, - false, + let domain_config = basic_domain_tracker_config(); + let config = TrackerServiceConfig::from_domain_config( + &domain_config, false, // has_prometheus false, // has_mysql (SQLite) false, // has_caddy @@ -307,11 +366,9 @@ mod tests { #[test] fn it_should_serialize_networks_to_name_strings() { - let config = TrackerServiceConfig::new( - vec![6969], - vec![], - 1212, - true, + let domain_config = domain_tracker_config_with_api_tls(); + let config = TrackerServiceConfig::from_domain_config( + &domain_config, true, // has_prometheus true, // has_mysql false, // has_caddy @@ -325,38 +382,32 @@ 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, - ); + let domain_config = domain_tracker_config_with_udp_ports(&[6969, 6970]); + let config = TrackerServiceConfig::from_domain_config(&domain_config, 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"); + // 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, - ); + let domain_config = domain_tracker_config_with_http_ports(&[7070, 7071]); + let config = TrackerServiceConfig::from_domain_config(&domain_config, false, false, false); + // 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"); @@ -364,39 +415,28 @@ mod tests { #[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, - ); + let domain_config = basic_domain_tracker_config(); + let config = TrackerServiceConfig::from_domain_config(&domain_config, false, false, false); - assert_eq!(config.ports.len(), 1); - assert_eq!(config.ports[0].binding(), "1212:1212"); + // 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 config = TrackerServiceConfig::from_domain_config(&domain_config, false, false, false); + // 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 config = TrackerServiceConfig::from_domain_config(&domain_config, false, false, false); let json = serde_json::to_value(&config).expect("serialization should succeed"); 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..b03815cc 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 @@ -84,16 +84,43 @@ mod tests { use super::super::context::{MysqlSetupConfig, TrackerServiceConfig}; use super::*; + 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 `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 + let domain_config = test_domain_tracker_config(); + TrackerServiceConfig::from_domain_config( + &domain_config, + false, // has_prometheus + false, // has_mysql + false, // has_caddy ) } From 17a526ecb580aed83be1d5a27f573166a3179077 Mon Sep 17 00:00:00 2001 From: Jose Celano Date: Mon, 26 Jan 2026 10:24:23 +0000 Subject: [PATCH 07/19] refactor: [#301] rename PrometheusServiceConfig and GrafanaServiceConfig constructors - Rename new() to from_domain_config() for consistency with TrackerServiceConfig - Update all test calls and callers in builder.rs - Part of Phase 4 DDD alignment for service configs --- .../docker_compose/context/builder.rs | 4 ++-- .../docker_compose/context/grafana.rs | 19 +++++++++++-------- .../docker_compose/context/prometheus.rs | 19 +++++++++++-------- 3 files changed, 24 insertions(+), 18 deletions(-) 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 eefacca5..0cb78aff 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 @@ -143,13 +143,13 @@ impl DockerComposeContextBuilder { let prometheus = self .prometheus_config .as_ref() - .map(|config| PrometheusServiceConfig::new(config, has_grafana)); + .map(|config| PrometheusServiceConfig::from_domain_config(config, has_grafana)); // Build Grafana service config if enabled let grafana = self .grafana_config .as_ref() - .map(|config| GrafanaServiceConfig::new(config, has_caddy)); + .map(|config| GrafanaServiceConfig::from_domain_config(config, has_caddy)); // Build Caddy service config if enabled let caddy = if has_caddy { 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 ed12adc1..f0f397f2 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 @@ -37,14 +37,17 @@ pub struct GrafanaServiceConfig { } impl GrafanaServiceConfig { - /// Creates a new `GrafanaServiceConfig` with pre-computed networks and ports + /// Creates a new `GrafanaServiceConfig` from domain configuration + /// + /// Uses the domain `PortDerivation` trait for port derivation logic, + /// ensuring business rules live in the domain layer. /// /// # Arguments /// /// * `config` - The domain Grafana configuration /// * `has_caddy` - Whether Caddy TLS proxy is enabled (adds `proxy_network`) #[must_use] - pub fn new(config: &GrafanaConfig, has_caddy: bool) -> Self { + pub fn from_domain_config(config: &GrafanaConfig, has_caddy: bool) -> Self { let networks = Self::compute_networks(has_caddy); // Use domain PortDerivation trait for port logic let port_bindings = config.derive_ports(); @@ -91,14 +94,14 @@ mod tests { #[test] fn it_should_connect_grafana_to_visualization_network() { - let config = GrafanaServiceConfig::new(&make_config(false), false); + let config = GrafanaServiceConfig::from_domain_config(&make_config(false), false); assert!(config.networks.contains(&Network::Visualization)); } #[test] fn it_should_not_connect_grafana_to_proxy_network_when_caddy_disabled() { - let config = GrafanaServiceConfig::new(&make_config(false), false); + let config = GrafanaServiceConfig::from_domain_config(&make_config(false), false); assert_eq!(config.networks, vec![Network::Visualization]); assert!(!config.networks.contains(&Network::Proxy)); @@ -106,7 +109,7 @@ mod tests { #[test] fn it_should_connect_grafana_to_proxy_network_when_caddy_enabled() { - let config = GrafanaServiceConfig::new(&make_config(true), true); + let config = GrafanaServiceConfig::from_domain_config(&make_config(true), true); assert_eq!( config.networks, @@ -116,7 +119,7 @@ mod tests { #[test] fn it_should_serialize_networks_to_name_strings() { - let config = GrafanaServiceConfig::new(&make_config(true), true); + let config = GrafanaServiceConfig::from_domain_config(&make_config(true), true); let json = serde_json::to_value(&config).expect("serialization should succeed"); @@ -127,7 +130,7 @@ mod tests { #[test] fn it_should_expose_port_3000_when_tls_disabled() { - let config = GrafanaServiceConfig::new(&make_config(false), false); + let config = GrafanaServiceConfig::from_domain_config(&make_config(false), false); assert_eq!(config.ports.len(), 1); assert_eq!(config.ports[0].binding(), "3000:3000"); @@ -135,7 +138,7 @@ mod tests { #[test] fn it_should_not_expose_ports_when_tls_enabled() { - let config = GrafanaServiceConfig::new(&make_config(true), true); + let config = GrafanaServiceConfig::from_domain_config(&make_config(true), true); assert!(config.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 103e3a44..d6c411d4 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 @@ -30,14 +30,17 @@ pub struct PrometheusServiceConfig { } impl PrometheusServiceConfig { - /// Creates a new `PrometheusServiceConfig` with pre-computed networks and ports + /// Creates a new `PrometheusServiceConfig` from domain configuration + /// + /// Uses the domain `PortDerivation` trait for port derivation logic, + /// ensuring business rules live in the domain layer. /// /// # Arguments /// /// * `config` - The domain Prometheus configuration /// * `has_grafana` - Whether Grafana is enabled (adds `visualization_network`) #[must_use] - pub fn new(config: &PrometheusConfig, has_grafana: bool) -> Self { + pub fn from_domain_config(config: &PrometheusConfig, has_grafana: bool) -> Self { let networks = Self::compute_networks(has_grafana); // Use domain PortDerivation trait for port logic let port_bindings = config.derive_ports(); @@ -76,14 +79,14 @@ mod tests { #[test] fn it_should_connect_prometheus_to_metrics_network() { - let config = PrometheusServiceConfig::new(&make_config(15), false); + let config = PrometheusServiceConfig::from_domain_config(&make_config(15), false); assert!(config.networks.contains(&Network::Metrics)); } #[test] fn it_should_not_connect_prometheus_to_visualization_network_when_grafana_disabled() { - let config = PrometheusServiceConfig::new(&make_config(15), false); + let config = PrometheusServiceConfig::from_domain_config(&make_config(15), false); assert_eq!(config.networks, vec![Network::Metrics]); assert!(!config.networks.contains(&Network::Visualization)); @@ -91,7 +94,7 @@ mod tests { #[test] fn it_should_connect_prometheus_to_visualization_network_when_grafana_enabled() { - let config = PrometheusServiceConfig::new(&make_config(30), true); + let config = PrometheusServiceConfig::from_domain_config(&make_config(30), true); assert_eq!( config.networks, @@ -101,7 +104,7 @@ mod tests { #[test] fn it_should_serialize_networks_to_name_strings() { - let config = PrometheusServiceConfig::new(&make_config(15), true); + let config = PrometheusServiceConfig::from_domain_config(&make_config(15), true); let json = serde_json::to_value(&config).expect("serialization should succeed"); @@ -112,7 +115,7 @@ mod tests { #[test] fn it_should_expose_localhost_port_9090() { - let config = PrometheusServiceConfig::new(&make_config(15), false); + let config = PrometheusServiceConfig::from_domain_config(&make_config(15), false); assert_eq!(config.ports.len(), 1); assert_eq!(config.ports[0].binding(), "127.0.0.1:9090:9090"); @@ -120,7 +123,7 @@ mod tests { #[test] fn it_should_serialize_ports_with_binding_and_description() { - let config = PrometheusServiceConfig::new(&make_config(15), false); + let config = PrometheusServiceConfig::from_domain_config(&make_config(15), false); let json = serde_json::to_value(&config).expect("serialization should succeed"); From 30eb4a126478bde465e122657fec20c44b021bd1 Mon Sep 17 00:00:00 2001 From: Jose Celano Date: Mon, 26 Jan 2026 11:28:56 +0000 Subject: [PATCH 08/19] refactor(domain): [#301] add NetworkDerivation trait and EnabledServices abstraction - Create NetworkDerivation trait for domain-driven network assignment - Implement NetworkDerivation for TrackerConfig, PrometheusConfig, GrafanaConfig - Create EnabledServices abstraction (renamed from TopologyContext): - Uses HashSet for Open/Closed principle compliance - Provides only generic has(Service) method - no convenience wrappers - Add comprehensive unit tests for EnabledServices (10 tests) - Update all infrastructure service configs to use domain traits - Remove compute_networks() methods from infrastructure layer Phase 4 Step 6: Network computation now uses domain layer traits. --- ...-phase-4-service-topology-ddd-alignment.md | 30 +-- .../rendering/docker_compose_templates.rs | 26 ++- src/domain/grafana/config.rs | 22 +- src/domain/prometheus/config.rs | 22 +- src/domain/topology/enabled_services.rs | 195 ++++++++++++++++++ src/domain/topology/mod.rs | 4 +- src/domain/topology/traits.rs | 36 ++++ src/domain/tracker/config/mod.rs | 33 ++- .../template/renderer/docker_compose.rs | 22 +- .../template/renderer/project_generator.rs | 9 +- .../docker_compose/context/builder.rs | 32 ++- .../docker_compose/context/grafana.rs | 48 +++-- .../wrappers/docker_compose/context/mod.rs | 42 ++-- .../docker_compose/context/prometheus.rs | 51 +++-- .../docker_compose/context/tracker.rs | 136 +++++------- .../wrappers/docker_compose/template.rs | 9 +- 16 files changed, 516 insertions(+), 201 deletions(-) create mode 100644 src/domain/topology/enabled_services.rs 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 dd1501a6..2eecaf19 100644 --- a/docs/issues/301-phase-4-service-topology-ddd-alignment.md +++ b/docs/issues/301-phase-4-service-topology-ddd-alignment.md @@ -339,13 +339,18 @@ impl TrackerServiceContext { ### Step 6: Network Computation - Domain Topology Builder -- [ ] 6.1 Create `src/domain/topology/builder.rs` with `DockerComposeTopologyBuilder` -- [ ] 6.2 Move Tracker network computation to builder -- [ ] 6.3 Move Prometheus network computation to builder -- [ ] 6.4 Move Grafana network computation to builder -- [ ] 6.5 Move Caddy network computation to builder -- [ ] 6.6 Move MySQL network computation to builder -- [ ] 6.7 Add unit tests for builder network derivation +- [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) +- [ ] 6.7 Create `src/domain/topology/builder.rs` with `DockerComposeTopologyBuilder` +- [ ] 6.8 Move Caddy network computation to builder +- [ ] 6.9 Move MySQL network computation to builder +- [ ] 6.10 Add unit tests for builder network derivation ### Step 7: Refactor Infrastructure to Pure DTOs @@ -366,11 +371,12 @@ impl TrackerServiceContext { ### 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/topology/builder.rs` | `DockerComposeTopologyBuilder` | +| `src/domain/topology/fixed_ports.rs` | Caddy/MySQL port functions | ### Modified Files diff --git a/src/application/steps/rendering/docker_compose_templates.rs b/src/application/steps/rendering/docker_compose_templates.rs index 7c9c57a0..4ee7777f 100644 --- a/src/application/steps/rendering/docker_compose_templates.rs +++ b/src/application/steps/rendering/docker_compose_templates.rs @@ -31,6 +31,7 @@ use tracing::{info, instrument}; use crate::domain::environment::Environment; use crate::domain::template::TemplateManager; +use crate::domain::topology::EnabledServices; use crate::domain::tracker::DatabaseConfig; use crate::infrastructure::templating::docker_compose::template::wrappers::docker_compose::{ DockerComposeContext, DockerComposeContextBuilder, MysqlSetupConfig, TrackerServiceConfig, @@ -164,13 +165,26 @@ impl RenderDockerComposeTemplatesStep { DatabaseConfig::Mysql(..) ); let has_caddy = self.has_caddy_enabled(); + let has_grafana = self.environment.grafana_config().is_some(); - TrackerServiceConfig::from_domain_config( - tracker_config, - 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); + + TrackerServiceConfig::from_domain_config(tracker_config, &topology_context) } /// Check if Caddy is enabled (HTTPS with at least one TLS-configured service) diff --git a/src/domain/grafana/config.rs b/src/domain/grafana/config.rs index c17713c0..0043a36f 100644 --- a/src/domain/grafana/config.rs +++ b/src/domain/grafana/config.rs @@ -2,7 +2,9 @@ use serde::{Deserialize, Serialize}; -use crate::domain::topology::{PortBinding, PortDerivation}; +use crate::domain::topology::{ + EnabledServices, Network, NetworkDerivation, PortBinding, PortDerivation, Service, +}; use crate::shared::domain_name::DomainName; use crate::shared::secrets::Password; @@ -134,6 +136,24 @@ impl PortDerivation for GrafanaConfig { } } +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::*; diff --git a/src/domain/prometheus/config.rs b/src/domain/prometheus/config.rs index d5868066..ff6ce35c 100644 --- a/src/domain/prometheus/config.rs +++ b/src/domain/prometheus/config.rs @@ -6,7 +6,9 @@ use std::num::NonZeroU32; use serde::{Deserialize, Serialize}; -use crate::domain::topology::{PortBinding, PortDerivation}; +use crate::domain::topology::{ + EnabledServices, Network, NetworkDerivation, PortBinding, PortDerivation, Service, +}; /// Default scrape interval in seconds /// @@ -102,6 +104,24 @@ impl PortDerivation for PrometheusConfig { } } +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; 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 1ae6198b..b74bd5a7 100644 --- a/src/domain/topology/mod.rs +++ b/src/domain/topology/mod.rs @@ -24,6 +24,7 @@ //! - [`caddy_ports`], [`mysql_ports`] - Fixed port functions for static services pub mod aggregate; +pub mod enabled_services; pub mod error; pub mod fixed_ports; pub mod network; @@ -33,9 +34,10 @@ 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 fixed_ports::{caddy_ports, mysql_ports}; pub use network::Network; pub use port::PortBinding; pub use service::Service; -pub use traits::PortDerivation; +pub use traits::{NetworkDerivation, PortDerivation}; diff --git a/src/domain/topology/traits.rs b/src/domain/topology/traits.rs index 30cf5b18..57d2e9b7 100644 --- a/src/domain/topology/traits.rs +++ b/src/domain/topology/traits.rs @@ -14,6 +14,8 @@ //! - **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 @@ -54,3 +56,37 @@ pub trait PortDerivation { /// 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 93d278fb..be1efcd8 100644 --- a/src/domain/tracker/config/mod.rs +++ b/src/domain/tracker/config/mod.rs @@ -10,7 +10,9 @@ use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr}; use serde::{Deserialize, Serialize}; use super::{BindingAddress, Protocol}; -use crate::domain::topology::{PortBinding, PortDerivation}; +use crate::domain::topology::{ + EnabledServices, Network, NetworkDerivation, PortBinding, PortDerivation, Service, +}; use crate::shared::DomainName; mod core; @@ -555,6 +557,35 @@ impl PortDerivation for TrackerConfig { } } +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. 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 f8a594ab..04fc0095 100644 --- a/src/infrastructure/templating/docker_compose/template/renderer/docker_compose.rs +++ b/src/infrastructure/templating/docker_compose/template/renderer/docker_compose.rs @@ -188,6 +188,7 @@ 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, @@ -224,12 +225,8 @@ mod tests { /// Helper to create `TrackerServiceConfig` for tests (no TLS, no networks) fn test_tracker_config() -> TrackerServiceConfig { let domain_config = test_domain_tracker_config(); - TrackerServiceConfig::from_domain_config( - &domain_config, - false, // has_prometheus - false, // has_mysql - false, // has_caddy - ) + let context = EnabledServices::from(&[]); + TrackerServiceConfig::from_domain_config(&domain_config, &context) } #[test] @@ -376,28 +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 domain_config = test_domain_tracker_config(); - let tracker = TrackerServiceConfig::from_domain_config( - &domain_config, - true, // has_prometheus - false, // has_mysql - false, // has_caddy - ); + let topology_context = EnabledServices::from(&[Service::Prometheus]); + let tracker = TrackerServiceConfig::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 4695ff23..4725f6bb 100644 --- a/src/infrastructure/templating/docker_compose/template/renderer/project_generator.rs +++ b/src/infrastructure/templating/docker_compose/template/renderer/project_generator.rs @@ -185,6 +185,7 @@ mod tests { use tempfile::TempDir; use super::*; + use crate::domain::topology::EnabledServices; use crate::domain::tracker::TrackerConfig; use crate::infrastructure::templating::docker_compose::template::wrappers::docker_compose::TrackerServiceConfig; use crate::infrastructure::templating::docker_compose::DOCKER_COMPOSE_SUBFOLDER; @@ -209,12 +210,8 @@ mod tests { fn create_test_docker_compose_context_sqlite() -> DockerComposeContext { // Use default TrackerConfig from domain layer let tracker_config = TrackerConfig::default(); - let tracker = TrackerServiceConfig::from_domain_config( - &tracker_config, - false, // has_prometheus - false, // has_mysql - false, // has_caddy - ); + let context = EnabledServices::from(&[]); + let tracker = TrackerServiceConfig::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 0cb78aff..31ea4188 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,7 +5,7 @@ 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::database::{DatabaseConfig, MysqlSetupConfig, DRIVER_MYSQL, DRIVER_SQLITE}; @@ -138,18 +138,37 @@ 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 .as_ref() - .map(|config| PrometheusServiceConfig::from_domain_config(config, has_grafana)); + .map(|config| PrometheusServiceConfig::from_domain_config(config, &topology_context)); // Build Grafana service config if enabled let grafana = self .grafana_config .as_ref() - .map(|config| GrafanaServiceConfig::from_domain_config(config, has_caddy)); + .map(|config| GrafanaServiceConfig::from_domain_config(config, &topology_context)); // Build Caddy service config if enabled let caddy = if has_caddy { @@ -370,6 +389,7 @@ 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, @@ -436,7 +456,8 @@ mod tests { /// Helper to create a minimal tracker config fn minimal_tracker_config() -> TrackerServiceConfig { let domain_config = minimal_domain_tracker_config(); - TrackerServiceConfig::from_domain_config(&domain_config, false, false, false) + let context = EnabledServices::from(&[]); + TrackerServiceConfig::from_domain_config(&domain_config, &context) } /// Helper to create a tracker config that exposes specific ports @@ -445,7 +466,8 @@ mod tests { http_ports: Vec, ) -> TrackerServiceConfig { let domain_config = domain_tracker_config_with_ports(udp_ports, http_ports); - TrackerServiceConfig::from_domain_config(&domain_config, false, false, false) + let context = EnabledServices::from(&[]); + TrackerServiceConfig::from_domain_config(&domain_config, &context) } // ========================================================================== 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 f0f397f2..c52603c1 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 @@ -4,7 +4,7 @@ use serde::Serialize; use crate::domain::grafana::GrafanaConfig; -use crate::domain::topology::{Network, PortDerivation}; +use crate::domain::topology::{EnabledServices, Network, NetworkDerivation, PortDerivation}; use crate::shared::secrets::Password; use super::port_definition::PortDefinition; @@ -39,16 +39,17 @@ pub struct GrafanaServiceConfig { impl GrafanaServiceConfig { /// Creates a new `GrafanaServiceConfig` from domain configuration /// - /// Uses the domain `PortDerivation` trait for port derivation logic, + /// Uses the domain `PortDerivation` and `NetworkDerivation` traits, /// ensuring business rules live in the domain layer. /// /// # Arguments /// /// * `config` - The domain Grafana configuration - /// * `has_caddy` - Whether Caddy TLS proxy is enabled (adds `proxy_network`) + /// * `context` - Topology context with information about enabled services #[must_use] - pub fn from_domain_config(config: &GrafanaConfig, has_caddy: bool) -> Self { - let networks = Self::compute_networks(has_caddy); + pub fn from_domain_config(config: &GrafanaConfig, enabled_services: &EnabledServices) -> Self { + // Use domain NetworkDerivation trait for network logic + let networks = config.derive_networks(enabled_services); // Use domain PortDerivation trait for port logic let port_bindings = config.derive_ports(); let ports = port_bindings.iter().map(PortDefinition::from).collect(); @@ -61,17 +62,6 @@ impl GrafanaServiceConfig { 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); - } - - networks - } } #[cfg(test)] @@ -92,16 +82,26 @@ mod tests { } } + 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::from_domain_config(&make_config(false), false); + let context = make_context(false); + let config = GrafanaServiceConfig::from_domain_config(&make_config(false), &context); assert!(config.networks.contains(&Network::Visualization)); } #[test] fn it_should_not_connect_grafana_to_proxy_network_when_caddy_disabled() { - let config = GrafanaServiceConfig::from_domain_config(&make_config(false), false); + let context = make_context(false); + let config = GrafanaServiceConfig::from_domain_config(&make_config(false), &context); assert_eq!(config.networks, vec![Network::Visualization]); assert!(!config.networks.contains(&Network::Proxy)); @@ -109,7 +109,8 @@ mod tests { #[test] fn it_should_connect_grafana_to_proxy_network_when_caddy_enabled() { - let config = GrafanaServiceConfig::from_domain_config(&make_config(true), true); + let context = make_context(true); + let config = GrafanaServiceConfig::from_domain_config(&make_config(true), &context); assert_eq!( config.networks, @@ -119,7 +120,8 @@ mod tests { #[test] fn it_should_serialize_networks_to_name_strings() { - let config = GrafanaServiceConfig::from_domain_config(&make_config(true), true); + let context = make_context(true); + let config = GrafanaServiceConfig::from_domain_config(&make_config(true), &context); let json = serde_json::to_value(&config).expect("serialization should succeed"); @@ -130,7 +132,8 @@ mod tests { #[test] fn it_should_expose_port_3000_when_tls_disabled() { - let config = GrafanaServiceConfig::from_domain_config(&make_config(false), false); + let context = make_context(false); + let config = GrafanaServiceConfig::from_domain_config(&make_config(false), &context); assert_eq!(config.ports.len(), 1); assert_eq!(config.ports[0].binding(), "3000:3000"); @@ -138,7 +141,8 @@ mod tests { #[test] fn it_should_not_expose_ports_when_tls_enabled() { - let config = GrafanaServiceConfig::from_domain_config(&make_config(true), true); + let context = make_context(true); + let config = GrafanaServiceConfig::from_domain_config(&make_config(true), &context); 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 f2480e67..4561146d 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 @@ -81,27 +81,26 @@ impl DockerComposeContext { /// ```rust /// use torrust_tracker_deployer_lib::infrastructure::templating::docker_compose::template::wrappers::docker_compose::{DockerComposeContext, TrackerServiceConfig, MysqlSetupConfig}; /// use torrust_tracker_deployer_lib::domain::tracker::TrackerConfig; + /// use torrust_tracker_deployer_lib::domain::topology::{EnabledServices, Service}; /// /// // Create tracker config from domain configuration /// let domain_config = TrackerConfig::default(); + /// let context = EnabledServices::from(&[]); /// let tracker_config = TrackerServiceConfig::from_domain_config( /// &domain_config, - /// false, // has_prometheus - /// false, // has_mysql - /// false, // has_caddy + /// &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 = TrackerServiceConfig::from_domain_config( /// &domain_config_for_mysql, - /// false, // has_prometheus - /// true, // has_mysql - /// false, // has_caddy + /// &mysql_context, /// ); /// let mysql_config = MysqlSetupConfig { /// root_password: "root_pass".to_string(), @@ -110,10 +109,10 @@ impl DockerComposeContext { /// password: "pass".to_string(), /// port: 3306, /// }; - /// let context = DockerComposeContext::builder(tracker_config_with_mysql) + /// 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 { @@ -169,6 +168,7 @@ 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, @@ -246,7 +246,8 @@ mod tests { /// Helper to create `TrackerServiceConfig` for tests (no TLS, no networks) fn test_tracker_config() -> TrackerServiceConfig { let domain_config = test_domain_tracker_config(); - TrackerServiceConfig::from_domain_config(&domain_config, false, false, false) + let context = EnabledServices::from(&[]); + TrackerServiceConfig::from_domain_config(&domain_config, &context) } /// Helper to create `TrackerServiceConfig` with specific network configuration @@ -257,17 +258,24 @@ mod tests { has_caddy: bool, use_api_tls: bool, ) -> TrackerServiceConfig { + 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) }; - TrackerServiceConfig::from_domain_config( - &domain_config, - has_prometheus, - has_mysql, - has_caddy, - ) + 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); + TrackerServiceConfig::from_domain_config(&domain_config, &context) } #[test] 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 d6c411d4..34d7da3f 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 @@ -4,7 +4,7 @@ use serde::Serialize; use crate::domain::prometheus::PrometheusConfig; -use crate::domain::topology::{Network, PortDerivation}; +use crate::domain::topology::{EnabledServices, Network, NetworkDerivation, PortDerivation}; use super::port_definition::PortDefinition; @@ -32,16 +32,20 @@ pub struct PrometheusServiceConfig { impl PrometheusServiceConfig { /// Creates a new `PrometheusServiceConfig` from domain configuration /// - /// Uses the domain `PortDerivation` trait for port derivation logic, + /// Uses the domain `PortDerivation` and `NetworkDerivation` traits, /// ensuring business rules live in the domain layer. /// /// # Arguments /// /// * `config` - The domain Prometheus configuration - /// * `has_grafana` - Whether Grafana is enabled (adds `visualization_network`) + /// * `context` - Topology context with information about enabled services #[must_use] - pub fn from_domain_config(config: &PrometheusConfig, has_grafana: bool) -> Self { - let networks = Self::compute_networks(has_grafana); + pub fn from_domain_config( + config: &PrometheusConfig, + enabled_services: &EnabledServices, + ) -> Self { + // Use domain NetworkDerivation trait for network logic + let networks = config.derive_networks(enabled_services); // Use domain PortDerivation trait for port logic let port_bindings = config.derive_ports(); let ports = port_bindings.iter().map(PortDefinition::from).collect(); @@ -52,17 +56,6 @@ impl PrometheusServiceConfig { 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); - } - - networks - } } #[cfg(test)] @@ -77,16 +70,26 @@ mod tests { ) } + 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::from_domain_config(&make_config(15), false); + let context = make_context(false); + let config = PrometheusServiceConfig::from_domain_config(&make_config(15), &context); assert!(config.networks.contains(&Network::Metrics)); } #[test] fn it_should_not_connect_prometheus_to_visualization_network_when_grafana_disabled() { - let config = PrometheusServiceConfig::from_domain_config(&make_config(15), false); + let context = make_context(false); + let config = PrometheusServiceConfig::from_domain_config(&make_config(15), &context); assert_eq!(config.networks, vec![Network::Metrics]); assert!(!config.networks.contains(&Network::Visualization)); @@ -94,7 +97,8 @@ mod tests { #[test] fn it_should_connect_prometheus_to_visualization_network_when_grafana_enabled() { - let config = PrometheusServiceConfig::from_domain_config(&make_config(30), true); + let context = make_context(true); + let config = PrometheusServiceConfig::from_domain_config(&make_config(30), &context); assert_eq!( config.networks, @@ -104,7 +108,8 @@ mod tests { #[test] fn it_should_serialize_networks_to_name_strings() { - let config = PrometheusServiceConfig::from_domain_config(&make_config(15), true); + let context = make_context(true); + let config = PrometheusServiceConfig::from_domain_config(&make_config(15), &context); let json = serde_json::to_value(&config).expect("serialization should succeed"); @@ -115,7 +120,8 @@ mod tests { #[test] fn it_should_expose_localhost_port_9090() { - let config = PrometheusServiceConfig::from_domain_config(&make_config(15), false); + let context = make_context(false); + let config = PrometheusServiceConfig::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"); @@ -123,7 +129,8 @@ mod tests { #[test] fn it_should_serialize_ports_with_binding_and_description() { - let config = PrometheusServiceConfig::from_domain_config(&make_config(15), false); + let context = make_context(false); + let config = PrometheusServiceConfig::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/tracker.rs b/src/infrastructure/templating/docker_compose/template/wrappers/docker_compose/context/tracker.rs index 1cc7b070..9de2293c 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,7 +3,7 @@ // External crates use serde::Serialize; -use crate::domain::topology::{Network, PortDerivation}; +use crate::domain::topology::{EnabledServices, Network, NetworkDerivation, PortDerivation}; use crate::domain::tracker::TrackerConfig; use super::port_definition::PortDefinition; @@ -52,16 +52,9 @@ impl TrackerServiceConfig { /// # Arguments /// /// * `config` - The domain Tracker configuration - /// * `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`) + /// * `context` - Topology context with information about enabled services #[must_use] - pub fn from_domain_config( - config: &TrackerConfig, - has_prometheus: bool, - has_mysql: bool, - has_caddy: bool, - ) -> Self { + pub fn from_domain_config(config: &TrackerConfig, enabled_services: &EnabledServices) -> Self { // Extract port info for legacy fields let udp_tracker_ports: Vec = config .udp_trackers() @@ -83,7 +76,8 @@ impl TrackerServiceConfig { || !http_tracker_ports_without_tls.is_empty() || !http_api_has_tls; - let networks = Self::compute_networks(has_prometheus, has_mysql, has_caddy); + // Use domain NetworkDerivation trait for network logic + let networks = config.derive_networks(enabled_services); // Use domain PortDerivation trait for port logic let port_bindings = config.derive_ports(); @@ -99,23 +93,6 @@ impl TrackerServiceConfig { 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(); - - if has_prometheus { - networks.push(Network::Metrics); - } - if has_mysql { - networks.push(Network::Database); - } - if has_caddy { - networks.push(Network::Proxy); - } - - networks - } } // Type alias for backward compatibility @@ -130,6 +107,22 @@ mod tests { 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 { + services.push(Service::Prometheus); + } + if has_mysql { + services.push(Service::MySQL); + } + if has_caddy { + services.push(Service::Caddy); + } + EnabledServices::from(&services) + } + /// Helper to create a basic domain `TrackerConfig` for network tests fn basic_domain_tracker_config() -> TrackerConfig { TrackerConfig::new( @@ -256,12 +249,8 @@ mod tests { #[test] fn it_should_connect_tracker_to_metrics_network_when_prometheus_enabled() { let domain_config = basic_domain_tracker_config(); - let config = TrackerServiceConfig::from_domain_config( - &domain_config, - true, // has_prometheus - false, // has_mysql - false, // has_caddy - ); + let context = make_context(true, false, false); + let config = TrackerServiceConfig::from_domain_config(&domain_config, &context); assert!(config.networks.contains(&Network::Metrics)); } @@ -269,12 +258,8 @@ mod tests { #[test] fn it_should_not_connect_tracker_to_metrics_network_when_prometheus_disabled() { let domain_config = basic_domain_tracker_config(); - let config = TrackerServiceConfig::from_domain_config( - &domain_config, - false, // has_prometheus - false, // has_mysql - false, // has_caddy - ); + let context = make_context(false, false, false); + let config = TrackerServiceConfig::from_domain_config(&domain_config, &context); assert!(!config.networks.contains(&Network::Metrics)); } @@ -282,12 +267,8 @@ mod tests { #[test] fn it_should_connect_tracker_to_database_network_when_mysql_enabled() { let domain_config = basic_domain_tracker_config(); - let config = TrackerServiceConfig::from_domain_config( - &domain_config, - false, // has_prometheus - true, // has_mysql - false, // has_caddy - ); + let context = make_context(false, true, false); + let config = TrackerServiceConfig::from_domain_config(&domain_config, &context); assert!(config.networks.contains(&Network::Database)); } @@ -295,12 +276,8 @@ mod tests { #[test] fn it_should_not_connect_tracker_to_database_network_when_mysql_disabled() { let domain_config = basic_domain_tracker_config(); - let config = TrackerServiceConfig::from_domain_config( - &domain_config, - false, // has_prometheus - false, // has_mysql (SQLite) - false, // has_caddy - ); + let context = make_context(false, false, false); + let config = TrackerServiceConfig::from_domain_config(&domain_config, &context); assert!(!config.networks.contains(&Network::Database)); } @@ -308,12 +285,8 @@ mod tests { #[test] fn it_should_connect_tracker_to_proxy_network_when_caddy_enabled() { let domain_config = domain_tracker_config_with_api_tls(); - let config = TrackerServiceConfig::from_domain_config( - &domain_config, - false, // has_prometheus - false, // has_mysql - true, // has_caddy - ); + let context = make_context(false, false, true); + let config = TrackerServiceConfig::from_domain_config(&domain_config, &context); assert!(config.networks.contains(&Network::Proxy)); } @@ -321,12 +294,8 @@ mod tests { #[test] fn it_should_not_connect_tracker_to_proxy_network_when_caddy_disabled() { let domain_config = basic_domain_tracker_config(); - let config = TrackerServiceConfig::from_domain_config( - &domain_config, - false, // has_prometheus - false, // has_mysql - false, // has_caddy - ); + let context = make_context(false, false, false); + let config = TrackerServiceConfig::from_domain_config(&domain_config, &context); assert!(!config.networks.contains(&Network::Proxy)); } @@ -334,12 +303,8 @@ mod tests { #[test] fn it_should_connect_tracker_to_all_networks_when_all_services_enabled() { let domain_config = domain_tracker_config_with_api_tls(); - let config = TrackerServiceConfig::from_domain_config( - &domain_config, - true, // has_prometheus - true, // has_mysql - true, // has_caddy - ); + let context = make_context(true, true, true); + let config = TrackerServiceConfig::from_domain_config(&domain_config, &context); assert_eq!( config.networks, @@ -350,12 +315,8 @@ mod tests { #[test] fn it_should_have_no_networks_when_minimal_deployment() { let domain_config = basic_domain_tracker_config(); - let config = TrackerServiceConfig::from_domain_config( - &domain_config, - false, // has_prometheus - false, // has_mysql (SQLite) - false, // has_caddy - ); + let context = make_context(false, false, false); + let config = TrackerServiceConfig::from_domain_config(&domain_config, &context); assert!(config.networks.is_empty()); } @@ -367,12 +328,8 @@ mod tests { #[test] fn it_should_serialize_networks_to_name_strings() { let domain_config = domain_tracker_config_with_api_tls(); - let config = TrackerServiceConfig::from_domain_config( - &domain_config, - true, // has_prometheus - true, // has_mysql - false, // has_caddy - ); + let context = make_context(true, true, false); + let config = TrackerServiceConfig::from_domain_config(&domain_config, &context); let json = serde_json::to_value(&config).expect("serialization should succeed"); @@ -388,7 +345,8 @@ mod tests { #[test] fn it_should_derive_udp_ports() { let domain_config = domain_tracker_config_with_udp_ports(&[6969, 6970]); - let config = TrackerServiceConfig::from_domain_config(&domain_config, false, false, false); + let context = make_context(false, false, false); + let config = TrackerServiceConfig::from_domain_config(&domain_config, &context); // UDP ports + API port (no TLS) = 3 ports // Filter just UDP ports @@ -405,7 +363,8 @@ mod tests { #[test] fn it_should_derive_http_ports_without_tls() { let domain_config = domain_tracker_config_with_http_ports(&[7070, 7071]); - let config = TrackerServiceConfig::from_domain_config(&domain_config, false, false, false); + let context = make_context(false, false, false); + let config = TrackerServiceConfig::from_domain_config(&domain_config, &context); // HTTP tracker ports only (API has TLS so not exposed) assert_eq!(config.ports.len(), 2); @@ -416,7 +375,8 @@ mod tests { #[test] fn it_should_derive_api_port_when_no_tls() { let domain_config = basic_domain_tracker_config(); - let config = TrackerServiceConfig::from_domain_config(&domain_config, false, false, false); + let context = make_context(false, false, false); + let config = TrackerServiceConfig::from_domain_config(&domain_config, &context); // UDP port + API port = 2 ports // API port is the second one @@ -427,7 +387,8 @@ mod tests { #[test] fn it_should_not_derive_api_port_when_tls_enabled() { let domain_config = minimal_domain_tracker_config_with_api_tls(); - let config = TrackerServiceConfig::from_domain_config(&domain_config, false, false, false); + let context = make_context(false, false, false); + let config = TrackerServiceConfig::from_domain_config(&domain_config, &context); // No UDP, no HTTP without TLS, API has TLS = no ports assert!(config.ports.is_empty()); @@ -436,7 +397,8 @@ mod tests { #[test] fn it_should_serialize_ports_with_binding_and_description() { let domain_config = basic_domain_tracker_config(); - let config = TrackerServiceConfig::from_domain_config(&domain_config, false, false, false); + let context = make_context(false, false, false); + let config = TrackerServiceConfig::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/template.rs b/src/infrastructure/templating/docker_compose/template/wrappers/docker_compose/template.rs index b03815cc..41690779 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 @@ -84,6 +84,7 @@ mod tests { use super::super::context::{MysqlSetupConfig, TrackerServiceConfig}; use super::*; + use crate::domain::topology::EnabledServices; use crate::domain::tracker::{ DatabaseConfig as TrackerDatabaseConfig, HealthCheckApiConfig, HttpApiConfig, HttpTrackerConfig, SqliteConfig, TrackerConfig, TrackerCoreConfig, UdpTrackerConfig, @@ -116,12 +117,8 @@ mod tests { /// Helper to create `TrackerServiceConfig` for tests (no TLS, no networks) fn test_tracker_config() -> TrackerServiceConfig { let domain_config = test_domain_tracker_config(); - TrackerServiceConfig::from_domain_config( - &domain_config, - false, // has_prometheus - false, // has_mysql - false, // has_caddy - ) + let context = EnabledServices::from(&[]); + TrackerServiceConfig::from_domain_config(&domain_config, &context) } #[test] From 49a9eef05325c8270c1016c1f2823e0934d2cb2c Mon Sep 17 00:00:00 2001 From: Jose Celano Date: Mon, 26 Jan 2026 11:37:52 +0000 Subject: [PATCH 09/19] docs: [#301] update Phase 4 progress - all main goals complete - Mark Steps 6.7-6.10 as not needed (Caddy/MySQL have static networks) - Mark Steps 7.1-7.4 as complete (infrastructure uses domain traits) - Update Goals section - all main objectives achieved - Defer Step 7.5 type renaming as optional - Remove builder.rs from planned files (not needed) Phase 4 DDD alignment essentially complete. --- ...-phase-4-service-topology-ddd-alignment.md | 29 +++++++++---------- 1 file changed, 14 insertions(+), 15 deletions(-) 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 2eecaf19..809c4cc7 100644 --- a/docs/issues/301-phase-4-service-topology-ddd-alignment.md +++ b/docs/issues/301-phase-4-service-topology-ddd-alignment.md @@ -29,8 +29,8 @@ These are business rules that should be in the domain layer: ## Goals - [x] 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) +- [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 @@ -38,11 +38,11 @@ These are business rules that should be in the domain layer: **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 @@ -347,18 +347,18 @@ impl TrackerServiceContext { - 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) -- [ ] 6.7 Create `src/domain/topology/builder.rs` with `DockerComposeTopologyBuilder` -- [ ] 6.8 Move Caddy network computation to builder -- [ ] 6.9 Move MySQL network computation to builder -- [ ] 6.10 Add unit tests for builder network derivation +- [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 -- [ ] 7.1 Remove `compute_networks()` from `TrackerServiceConfig` -- [ ] 7.2 Remove `compute_networks()` from `PrometheusServiceConfig` -- [ ] 7.3 Remove `compute_networks()` from `GrafanaServiceConfig` -- [ ] 7.4 Update `DockerComposeContextBuilder` to receive topology from domain -- [ ] 7.5 Rename service config types to `*Context` (optional, for clarity) +- [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` +- [ ] 7.5 Rename service config types to `*Context` (optional, deferred) - Not critical for DDD alignment ### Step 8: Cleanup and Verification @@ -375,7 +375,6 @@ impl TrackerServiceContext { | ----------------------------------------- | -------------------------------------------- | | `src/domain/topology/traits.rs` | `PortDerivation`, `NetworkDerivation` traits | | `src/domain/topology/enabled_services.rs` | `EnabledServices` set for topology queries | -| `src/domain/topology/builder.rs` | `DockerComposeTopologyBuilder` | | `src/domain/topology/fixed_ports.rs` | Caddy/MySQL port functions | ### Modified Files From d98df1ef19e88474e9ec5076ef97cf3147f9f6c7 Mon Sep 17 00:00:00 2001 From: Jose Celano Date: Mon, 26 Jan 2026 11:39:18 +0000 Subject: [PATCH 10/19] docs: [#301] mark Phase 4 acceptance criteria complete - Check all acceptance criteria items - Add NetworkDerivation and EnabledServices criteria - Update Design Decisions table with builder decision - Document final architecture: trait-based without builder --- ...-phase-4-service-topology-ddd-alignment.md | 28 ++++++++++--------- 1 file changed, 15 insertions(+), 13 deletions(-) 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 809c4cc7..b4e7a2f4 100644 --- a/docs/issues/301-phase-4-service-topology-ddd-alignment.md +++ b/docs/issues/301-phase-4-service-topology-ddd-alignment.md @@ -402,25 +402,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`) 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? | Deferred (optional) | Not critical for DDD alignment. Can be done in a future cleanup phase. | +| 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. | +| 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 From 1e28b4366879e8b66bae54b7e78d7f5c7ccfd545 Mon Sep 17 00:00:00 2001 From: Jose Celano Date: Mon, 26 Jan 2026 12:02:49 +0000 Subject: [PATCH 11/19] refactor(domain): [#301] replace fixed_ports.rs with domain types for Caddy/MySQL - Create src/domain/caddy/config.rs with CaddyConfig implementing PortDerivation and NetworkDerivation - Create src/domain/mysql/config.rs with MysqlServiceConfig implementing PortDerivation and NetworkDerivation - Delete src/domain/topology/fixed_ports.rs (no longer needed) - Update infrastructure CaddyDockerServiceConfig and MysqlDockerServiceConfig to use from_domain_config() - Update progress doc with Step 5 changes and elaborate on Step 7.5 All services now follow the same trait-based pattern for consistency and Open/Closed compliance. --- ...-phase-4-service-topology-ddd-alignment.md | 73 ++++--- src/domain/caddy/config.rs | 206 ++++++++++++++++++ src/domain/caddy/mod.rs | 5 + src/domain/mod.rs | 6 + src/domain/mysql/config.rs | 167 ++++++++++++++ src/domain/mysql/mod.rs | 9 + src/domain/topology/fixed_ports.rs | 140 ------------ src/domain/topology/mod.rs | 4 +- .../wrappers/docker_compose/context/caddy.rs | 85 +++++--- .../wrappers/docker_compose/context/mod.rs | 4 +- .../wrappers/docker_compose/context/mysql.rs | 86 ++++++-- 11 files changed, 562 insertions(+), 223 deletions(-) create mode 100644 src/domain/caddy/config.rs create mode 100644 src/domain/caddy/mod.rs create mode 100644 src/domain/mysql/config.rs create mode 100644 src/domain/mysql/mod.rs delete mode 100644 src/domain/topology/fixed_ports.rs 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 b4e7a2f4..031c9547 100644 --- a/docs/issues/301-phase-4-service-topology-ddd-alignment.md +++ b/docs/issues/301-phase-4-service-topology-ddd-alignment.md @@ -332,10 +332,16 @@ impl TrackerServiceContext { ### Step 5: Fixed Port Services (Caddy, MySQL) -- [x] 5.1 Create `src/domain/topology/fixed_ports.rs` with `caddy_ports()` and `mysql_ports()` -- [x] 5.2 Add unit tests for fixed port functions +- [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 @@ -358,7 +364,13 @@ impl TrackerServiceContext { - [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` -- [ ] 7.5 Rename service config types to `*Context` (optional, deferred) - Not critical for DDD alignment +- [ ] 7.5 Rename infrastructure service config types to `*Context` (optional, deferred) + - **What**: Rename infrastructure DTOs to better reflect their purpose as template contexts + - **Current names**: `TrackerServiceConfig`, `GrafanaServiceConfig`, `PrometheusServiceConfig`, `CaddyDockerServiceConfig`, `MysqlDockerServiceConfig` + - **Proposed names**: `TrackerServiceContext`, `GrafanaServiceContext`, `PrometheusServiceContext`, `CaddyServiceContext`, `MysqlServiceContext` + - **Why deferred**: Not critical for DDD alignment since the types are already pure DTOs + - **Benefit**: Clearer naming distinction between domain configs (`*Config`) and infrastructure contexts (`*Context`) + - **Scope**: ~5 files + all usages (~20-30 files), type aliases can ease migration ### Step 8: Cleanup and Verification @@ -371,30 +383,35 @@ impl TrackerServiceContext { ### New Files -| File | Purpose | -| ----------------------------------------- | -------------------------------------------- | -| `src/domain/topology/traits.rs` | `PortDerivation`, `NetworkDerivation` traits | -| `src/domain/topology/enabled_services.rs` | `EnabledServices` set for topology queries | -| `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` | Use `from_domain_config()`, rename to `CaddyDockerServiceConfig` | +| `src/infrastructure/.../context/mysql.rs` | Use `from_domain_config()`, rename to `MysqlDockerServiceConfig` | +| `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 | ### 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 @@ -407,7 +424,7 @@ impl TrackerServiceContext { **Task-Specific Criteria**: - [x] `PortDerivation` trait defined in `domain/topology/traits.rs` -- [x] All service configs (`TrackerConfig`, `GrafanaConfig`, `PrometheusConfig`) implement `PortDerivation` +- [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 @@ -417,12 +434,12 @@ impl TrackerServiceContext { ## 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? | Deferred (optional) | Not critical for DDD alignment. Can be done in a future cleanup phase. | -| 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. | -| 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. | +| 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? | Deferred (optional) | Not critical for DDD alignment. Can be done in a future cleanup phase. | +| 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/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/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/topology/fixed_ports.rs b/src/domain/topology/fixed_ports.rs deleted file mode 100644 index 8e05d464..00000000 --- a/src/domain/topology/fixed_ports.rs +++ /dev/null @@ -1,140 +0,0 @@ -//! Fixed port definitions for services without configuration -//! -//! This module provides port bindings for services that have static port -//! configurations (no user-configurable ports). These are services where -//! the port exposure is fixed by the service's nature, not by configuration. -//! -//! ## Services -//! -//! - **Caddy**: Always exposes 80, 443, and 443/udp for TLS termination -//! - **`MySQL`**: Never exposes ports (internal-only database access) -//! -//! ## Port Rules Reference -//! -//! | Rule | Service | Description | -//! |---------|---------|-------------------------------------------| -//! | PORT-09 | Caddy | Always exposes 80, 443, 443/udp | -//! | PORT-11 | MySQL | No exposed ports (internal only) | - -use super::PortBinding; - -/// 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 -/// -/// # Examples -/// -/// ```rust -/// use torrust_tracker_deployer_lib::domain::topology::caddy_ports; -/// -/// let ports = caddy_ports(); -/// assert_eq!(ports.len(), 3); -/// assert!(ports.iter().any(|p| p.host_port() == 80)); -/// assert!(ports.iter().any(|p| p.host_port() == 443)); -/// ``` -#[must_use] -pub fn 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 `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. -/// -/// # Examples -/// -/// ```rust -/// use torrust_tracker_deployer_lib::domain::topology::mysql_ports; -/// -/// let ports = mysql_ports(); -/// assert!(ports.is_empty()); -/// ``` -#[must_use] -pub fn mysql_ports() -> Vec { - vec![] -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::domain::tracker::Protocol; - - // ========================================================================= - // Caddy port tests (PORT-09) - // ========================================================================= - - mod caddy { - use super::*; - - #[test] - fn it_should_expose_port_80_for_acme_challenge() { - let ports = caddy_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 ports = caddy_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_http3() { - let ports = caddy_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("HTTP/3")); - } - - #[test] - fn it_should_have_exactly_three_ports() { - let ports = caddy_ports(); - - assert_eq!(ports.len(), 3); - } - } - - // ========================================================================= - // MySQL port tests (PORT-11) - // ========================================================================= - - mod mysql { - use super::*; - - #[test] - fn it_should_have_no_exposed_ports() { - // PORT-11: MySQL no exposed ports - let ports = mysql_ports(); - - assert!(ports.is_empty()); - } - } -} diff --git a/src/domain/topology/mod.rs b/src/domain/topology/mod.rs index b74bd5a7..758b48d3 100644 --- a/src/domain/topology/mod.rs +++ b/src/domain/topology/mod.rs @@ -21,12 +21,11 @@ //! - [`ServiceTopology`] - Topology information for a single service //! - [`TopologyError`] - Validation errors (e.g., port conflicts) //! - [`PortDerivation`] - Trait for services that derive their port bindings -//! - [`caddy_ports`], [`mysql_ports`] - Fixed port functions for static services +//! - [`NetworkDerivation`] - Trait for services that derive their network assignments pub mod aggregate; pub mod enabled_services; pub mod error; -pub mod fixed_ports; pub mod network; pub mod port; pub mod service; @@ -36,7 +35,6 @@ pub mod traits; pub use aggregate::{DockerComposeTopology, ServiceTopology}; pub use enabled_services::EnabledServices; pub use error::{PortConflict, TopologyError}; -pub use fixed_ports::{caddy_ports, mysql_ports}; pub use network::Network; pub use port::PortBinding; pub use service::Service; 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 fdb5c32b..884d5f33 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 (`CaddyDockerServiceConfig`) 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 +//! - `CaddyDockerServiceConfig` (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,7 +16,8 @@ use serde::Serialize; -use crate::domain::topology::{caddy_ports, Network}; +use crate::domain::caddy::CaddyConfig; +use crate::domain::topology::{EnabledServices, Network, NetworkDerivation, PortDerivation}; use super::port_definition::PortDefinition; @@ -29,15 +30,16 @@ use super::port_definition::PortDefinition; /// # 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::CaddyDockerServiceConfig; +/// use torrust_tracker_deployer_lib::domain::caddy::CaddyConfig; +/// use torrust_tracker_deployer_lib::domain::topology::{EnabledServices, Network}; /// -/// let caddy = CaddyServiceConfig::new(); +/// let caddy = CaddyDockerServiceConfig::from_domain_config(&CaddyConfig::new(), &EnabledServices::default()); /// assert_eq!(caddy.networks, vec![Network::Proxy]); /// assert_eq!(caddy.ports.len(), 3); // 80, 443, 443/udp /// ``` #[derive(Debug, Clone, Serialize, PartialEq)] -pub struct CaddyServiceConfig { +pub struct CaddyDockerServiceConfig { /// Port bindings for Docker Compose /// /// Caddy exposes ports 80 (HTTP for ACME), 443 (HTTPS), and 443/udp (QUIC). @@ -49,55 +51,64 @@ pub struct CaddyServiceConfig { pub networks: Vec, } -impl CaddyServiceConfig { - /// Creates a new `CaddyServiceConfig` with derived ports and default networks +impl CaddyDockerServiceConfig { + /// Creates a new `CaddyDockerServiceConfig` from domain configuration /// - /// Caddy connects to: - /// - `proxy_network`: For reverse proxying to backend services + /// Uses the domain `PortDerivation` and `NetworkDerivation` traits, + /// ensuring business rules live in the domain layer. /// - /// Caddy exposes: - /// - Port 80: HTTP for ACME challenge - /// - Port 443: HTTPS - /// - Port 443/udp: HTTP/3 QUIC + /// # Arguments + /// + /// * `config` - The domain Caddy configuration + /// * `enabled_services` - Topology context with information about enabled services #[must_use] - pub fn new() -> Self { - let port_bindings = 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], - } + Self { ports, networks } + } + + /// Creates a new `CaddyDockerServiceConfig` 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()) } } -impl Default for CaddyServiceConfig { +impl Default for CaddyDockerServiceConfig { fn default() -> Self { Self::new() } } +/// Type alias for backward compatibility +pub type CaddyServiceConfig = CaddyDockerServiceConfig; + #[cfg(test)] mod tests { use super::*; #[test] fn it_should_connect_caddy_to_proxy_network() { - let caddy = CaddyServiceConfig::new(); + let caddy = CaddyDockerServiceConfig::new(); assert_eq!(caddy.networks, vec![Network::Proxy]); } #[test] fn it_should_implement_default() { - let caddy = CaddyServiceConfig::default(); + let caddy = CaddyDockerServiceConfig::default(); assert_eq!(caddy.networks, vec![Network::Proxy]); } #[test] fn it_should_serialize_network_to_name_string() { - let caddy = CaddyServiceConfig::new(); + let caddy = CaddyDockerServiceConfig::new(); let json = serde_json::to_value(&caddy).expect("serialization should succeed"); @@ -107,14 +118,14 @@ mod tests { #[test] fn it_should_expose_three_ports() { - let caddy = CaddyServiceConfig::new(); + let caddy = CaddyDockerServiceConfig::new(); assert_eq!(caddy.ports.len(), 3); } #[test] fn it_should_serialize_ports_with_binding_and_description() { - let caddy = CaddyServiceConfig::new(); + let caddy = CaddyDockerServiceConfig::new(); let json = serde_json::to_value(&caddy).expect("serialization should succeed"); @@ -122,4 +133,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 = CaddyDockerServiceConfig::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 = CaddyDockerServiceConfig::from_domain_config(&config, &enabled_services); + + // Verify networks come from domain trait + assert_eq!(caddy.networks, vec![Network::Proxy]); + } } 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 4561146d..c31be5a1 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 @@ -19,10 +19,10 @@ mod tracker; // Re-exports pub use builder::{DockerComposeContextBuilder, PortConflictError}; -pub use caddy::CaddyServiceConfig; +pub use caddy::{CaddyDockerServiceConfig, CaddyServiceConfig}; pub use database::{DatabaseConfig, MysqlSetupConfig}; pub use grafana::GrafanaServiceConfig; -pub use mysql::MysqlServiceConfig; +pub use mysql::{MysqlDockerServiceConfig, MysqlServiceConfig}; pub use network_definition::NetworkDefinition; pub use port_definition::PortDefinition; pub use prometheus::PrometheusServiceConfig; 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 c3ef271a..943a7226 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,20 +4,23 @@ //! //! ## 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. +//! - `MysqlDockerServiceConfig` (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::{mysql_ports, Network}; +use crate::domain::mysql::MysqlServiceConfig as DomainMysqlConfig; +use crate::domain::topology::{EnabledServices, Network, NetworkDerivation, PortDerivation}; use super::port_definition::PortDefinition; @@ -30,14 +33,16 @@ use super::port_definition::PortDefinition; /// # 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::MysqlDockerServiceConfig; +/// use torrust_tracker_deployer_lib::domain::mysql::MysqlServiceConfig; +/// use torrust_tracker_deployer_lib::domain::topology::EnabledServices; /// -/// let mysql = MysqlServiceConfig::new(); +/// let mysql = MysqlDockerServiceConfig::from_domain_config(&MysqlServiceConfig::new(), &EnabledServices::default()); /// assert_eq!(mysql.networks, vec![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 { +pub struct MysqlDockerServiceConfig { /// Port bindings for Docker Compose /// /// `MySQL` never exposes ports externally - only tracker can access it via internal network. @@ -49,52 +54,67 @@ pub struct MysqlServiceConfig { pub networks: Vec, } -impl MysqlServiceConfig { - /// Creates a new `MysqlServiceConfig` with default networks and empty ports +impl MysqlDockerServiceConfig { + /// Creates a new `MysqlDockerServiceConfig` from domain configuration + /// + /// Uses the domain `PortDerivation` and `NetworkDerivation` traits, + /// ensuring business rules live in the domain layer. /// - /// `MySQL` connects to: - /// - `database_network`: For database access by the tracker + /// # Arguments /// - /// `MySQL` never exposes ports externally for security. + /// * `config` - The domain `MySQL` service configuration + /// * `enabled_services` - Topology context with information about enabled services #[must_use] - pub fn new() -> Self { - let port_bindings = 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 } + } - Self { - ports, - networks: vec![Network::Database], - } + /// Creates a new `MysqlDockerServiceConfig` 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()) } } -impl Default for MysqlServiceConfig { +impl Default for MysqlDockerServiceConfig { fn default() -> Self { Self::new() } } +/// Type alias for backward compatibility +pub type MysqlServiceConfig = MysqlDockerServiceConfig; + #[cfg(test)] mod tests { use super::*; #[test] fn it_should_connect_mysql_to_database_network() { - let mysql = MysqlServiceConfig::new(); + let mysql = MysqlDockerServiceConfig::new(); assert_eq!(mysql.networks, vec![Network::Database]); } #[test] fn it_should_implement_default() { - let mysql = MysqlServiceConfig::default(); + let mysql = MysqlDockerServiceConfig::default(); assert_eq!(mysql.networks, vec![Network::Database]); } #[test] fn it_should_serialize_network_to_name_string() { - let mysql = MysqlServiceConfig::new(); + let mysql = MysqlDockerServiceConfig::new(); let json = serde_json::to_value(&mysql).expect("serialization should succeed"); @@ -104,8 +124,28 @@ mod tests { #[test] fn it_should_not_expose_any_ports() { - let mysql = MysqlServiceConfig::new(); + let mysql = MysqlDockerServiceConfig::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 = MysqlDockerServiceConfig::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 = MysqlDockerServiceConfig::from_domain_config(&config, &enabled_services); + + // Verify networks come from domain trait + assert_eq!(mysql.networks, vec![Network::Database]); + } } From 7a9d035c838b6d1e828239f297d43d3094a6ed12 Mon Sep 17 00:00:00 2001 From: Jose Celano Date: Mon, 26 Jan 2026 12:37:39 +0000 Subject: [PATCH 12/19] refactor(infrastructure): [#301] rename service configs to *Context MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Rename TrackerServiceConfig → TrackerServiceContext - Rename GrafanaServiceConfig → GrafanaServiceContext - Rename PrometheusServiceConfig → PrometheusServiceContext - Rename CaddyDockerServiceConfig → CaddyServiceContext - Rename MysqlDockerServiceConfig → MysqlServiceContext Clear naming distinction: domain uses *Config, infrastructure uses *Context. Keep TrackerPorts as internal builder API alias only. --- .../rendering/docker_compose_templates.rs | 10 ++-- .../template/renderer/docker_compose.rs | 10 ++-- .../template/renderer/project_generator.rs | 4 +- .../docker_compose/context/builder.rs | 40 +++++++------- .../wrappers/docker_compose/context/caddy.rs | 35 ++++++------- .../docker_compose/context/grafana.rs | 18 +++---- .../wrappers/docker_compose/context/mod.rs | 52 ++++++++++--------- .../wrappers/docker_compose/context/mysql.rs | 31 +++++------ .../docker_compose/context/prometheus.rs | 18 +++---- .../docker_compose/context/tracker.rs | 43 ++++++++------- .../template/wrappers/docker_compose/mod.rs | 2 +- .../wrappers/docker_compose/template.rs | 8 +-- 12 files changed, 135 insertions(+), 136 deletions(-) diff --git a/src/application/steps/rendering/docker_compose_templates.rs b/src/application/steps/rendering/docker_compose_templates.rs index 4ee7777f..4646445b 100644 --- a/src/application/steps/rendering/docker_compose_templates.rs +++ b/src/application/steps/rendering/docker_compose_templates.rs @@ -34,7 +34,7 @@ use crate::domain::template::TemplateManager; 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,7 +155,7 @@ 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(); // Determine which features are enabled (affects tracker networks) @@ -184,7 +184,7 @@ impl RenderDockerComposeTemplatesStep { let topology_context = EnabledServices::from(&enabled_services); - TrackerServiceConfig::from_domain_config(tracker_config, &topology_context) + TrackerServiceContext::from_domain_config(tracker_config, &topology_context) } /// Check if Caddy is enabled (HTTPS with at least one TLS-configured service) @@ -211,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); @@ -221,7 +221,7 @@ impl RenderDockerComposeTemplatesStep { fn create_mysql_contexts( admin_token: String, - tracker: TrackerServiceConfig, + tracker: TrackerServiceContext, port: u16, database_name: String, username: String, 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 04fc0095..970ec758 100644 --- a/src/infrastructure/templating/docker_compose/template/renderer/docker_compose.rs +++ b/src/infrastructure/templating/docker_compose/template/renderer/docker_compose.rs @@ -195,7 +195,7 @@ mod tests { TrackerCoreConfig, UdpTrackerConfig, }; use crate::infrastructure::templating::docker_compose::template::wrappers::docker_compose::{ - DockerComposeContext, MysqlSetupConfig, TrackerServiceConfig, + DockerComposeContext, MysqlSetupConfig, TrackerServiceContext, }; /// Helper to create a domain `TrackerConfig` for tests @@ -222,11 +222,11 @@ mod tests { .unwrap() } - /// Helper to create `TrackerServiceConfig` for tests (no TLS, no networks) - fn test_tracker_config() -> TrackerServiceConfig { + /// 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(&[]); - TrackerServiceConfig::from_domain_config(&domain_config, &context) + TrackerServiceContext::from_domain_config(&domain_config, &context) } #[test] @@ -381,7 +381,7 @@ mod tests { // Create tracker config with prometheus enabled let domain_config = test_domain_tracker_config(); let topology_context = EnabledServices::from(&[Service::Prometheus]); - let tracker = TrackerServiceConfig::from_domain_config(&domain_config, &topology_context); + 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 compose_context = DockerComposeContext::builder(tracker) 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 4725f6bb..1462d135 100644 --- a/src/infrastructure/templating/docker_compose/template/renderer/project_generator.rs +++ b/src/infrastructure/templating/docker_compose/template/renderer/project_generator.rs @@ -187,7 +187,7 @@ mod tests { use super::*; use crate::domain::topology::EnabledServices; use crate::domain::tracker::TrackerConfig; - use crate::infrastructure::templating::docker_compose::template::wrappers::docker_compose::TrackerServiceConfig; + 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 @@ -211,7 +211,7 @@ mod tests { // Use default TrackerConfig from domain layer let tracker_config = TrackerConfig::default(); let context = EnabledServices::from(&[]); - let tracker = TrackerServiceConfig::from_domain_config(&tracker_config, &context); + 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 31ea4188..9961e7f9 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 @@ -7,14 +7,14 @@ use crate::domain::grafana::GrafanaConfig; use crate::domain::prometheus::PrometheusConfig; 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 { @@ -162,24 +162,24 @@ impl DockerComposeContextBuilder { let prometheus = self .prometheus_config .as_ref() - .map(|config| PrometheusServiceConfig::from_domain_config(config, &topology_context)); + .map(|config| PrometheusServiceContext::from_domain_config(config, &topology_context)); // Build Grafana service config if enabled let grafana = self .grafana_config .as_ref() - .map(|config| GrafanaServiceConfig::from_domain_config(config, &topology_context)); + .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 }; @@ -209,11 +209,11 @@ 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(); @@ -454,20 +454,20 @@ mod tests { } /// Helper to create a minimal tracker config - fn minimal_tracker_config() -> TrackerServiceConfig { + fn minimal_tracker_config() -> TrackerServiceContext { let domain_config = minimal_domain_tracker_config(); let context = EnabledServices::from(&[]); - TrackerServiceConfig::from_domain_config(&domain_config, &context) + 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 { + ) -> TrackerServiceContext { let domain_config = domain_tracker_config_with_ports(udp_ports, http_ports); let context = EnabledServices::from(&[]); - TrackerServiceConfig::from_domain_config(&domain_config, &context) + 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 884d5f33..15222d62 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 (`CaddyDockerServiceConfig`) 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: //! -//! - `CaddyDockerServiceConfig` (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 @@ -30,16 +30,16 @@ use super::port_definition::PortDefinition; /// # Example /// /// ```rust -/// use torrust_tracker_deployer_lib::infrastructure::templating::docker_compose::template::wrappers::docker_compose::context::CaddyDockerServiceConfig; +/// 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 = CaddyDockerServiceConfig::from_domain_config(&CaddyConfig::new(), &EnabledServices::default()); +/// let caddy = CaddyServiceContext::from_domain_config(&CaddyConfig::new(), &EnabledServices::default()); /// assert_eq!(caddy.networks, vec![Network::Proxy]); /// assert_eq!(caddy.ports.len(), 3); // 80, 443, 443/udp /// ``` #[derive(Debug, Clone, Serialize, PartialEq)] -pub struct CaddyDockerServiceConfig { +pub struct CaddyServiceContext { /// Port bindings for Docker Compose /// /// Caddy exposes ports 80 (HTTP for ACME), 443 (HTTPS), and 443/udp (QUIC). @@ -51,8 +51,8 @@ pub struct CaddyDockerServiceConfig { pub networks: Vec, } -impl CaddyDockerServiceConfig { - /// Creates a new `CaddyDockerServiceConfig` from domain configuration +impl CaddyServiceContext { + /// Creates a new `CaddyServiceContext` from domain configuration /// /// Uses the domain `PortDerivation` and `NetworkDerivation` traits, /// ensuring business rules live in the domain layer. @@ -70,7 +70,7 @@ impl CaddyDockerServiceConfig { Self { ports, networks } } - /// Creates a new `CaddyDockerServiceConfig` with default configuration + /// Creates a new `CaddyServiceContext` with default configuration /// /// Convenience method that creates a default `CaddyConfig` and empty enabled services. #[must_use] @@ -79,36 +79,33 @@ impl CaddyDockerServiceConfig { } } -impl Default for CaddyDockerServiceConfig { +impl Default for CaddyServiceContext { fn default() -> Self { Self::new() } } -/// Type alias for backward compatibility -pub type CaddyServiceConfig = CaddyDockerServiceConfig; - #[cfg(test)] mod tests { use super::*; #[test] fn it_should_connect_caddy_to_proxy_network() { - let caddy = CaddyDockerServiceConfig::new(); + let caddy = CaddyServiceContext::new(); assert_eq!(caddy.networks, vec![Network::Proxy]); } #[test] fn it_should_implement_default() { - let caddy = CaddyDockerServiceConfig::default(); + let caddy = CaddyServiceContext::default(); assert_eq!(caddy.networks, vec![Network::Proxy]); } #[test] fn it_should_serialize_network_to_name_string() { - let caddy = CaddyDockerServiceConfig::new(); + let caddy = CaddyServiceContext::new(); let json = serde_json::to_value(&caddy).expect("serialization should succeed"); @@ -118,14 +115,14 @@ mod tests { #[test] fn it_should_expose_three_ports() { - let caddy = CaddyDockerServiceConfig::new(); + let caddy = CaddyServiceContext::new(); assert_eq!(caddy.ports.len(), 3); } #[test] fn it_should_serialize_ports_with_binding_and_description() { - let caddy = CaddyDockerServiceConfig::new(); + let caddy = CaddyServiceContext::new(); let json = serde_json::to_value(&caddy).expect("serialization should succeed"); @@ -138,7 +135,7 @@ mod tests { fn it_should_use_domain_traits_for_port_derivation() { let config = CaddyConfig::new(); let enabled_services = EnabledServices::default(); - let caddy = CaddyDockerServiceConfig::from_domain_config(&config, &enabled_services); + let caddy = CaddyServiceContext::from_domain_config(&config, &enabled_services); // Verify ports come from domain trait assert_eq!(caddy.ports.len(), 3); @@ -148,7 +145,7 @@ mod tests { fn it_should_use_domain_traits_for_network_derivation() { let config = CaddyConfig::new(); let enabled_services = EnabledServices::default(); - let caddy = CaddyDockerServiceConfig::from_domain_config(&config, &enabled_services); + let caddy = CaddyServiceContext::from_domain_config(&config, &enabled_services); // Verify networks come from domain trait assert_eq!(caddy.networks, vec![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 c52603c1..06aca43e 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 @@ -15,7 +15,7 @@ use super::port_definition::PortDefinition; /// including admin credentials, TLS settings, port mappings, and network connections. /// All logic is pre-computed in Rust to keep the Tera template simple. #[derive(Serialize, Debug, Clone)] -pub struct GrafanaServiceConfig { +pub struct GrafanaServiceContext { /// Grafana admin username pub admin_user: String, /// Grafana admin password @@ -36,8 +36,8 @@ pub struct GrafanaServiceConfig { pub networks: Vec, } -impl GrafanaServiceConfig { - /// Creates a new `GrafanaServiceConfig` from domain configuration +impl GrafanaServiceContext { + /// Creates a new `GrafanaServiceContext` from domain configuration /// /// Uses the domain `PortDerivation` and `NetworkDerivation` traits, /// ensuring business rules live in the domain layer. @@ -93,7 +93,7 @@ mod tests { #[test] fn it_should_connect_grafana_to_visualization_network() { let context = make_context(false); - let config = GrafanaServiceConfig::from_domain_config(&make_config(false), &context); + let config = GrafanaServiceContext::from_domain_config(&make_config(false), &context); assert!(config.networks.contains(&Network::Visualization)); } @@ -101,7 +101,7 @@ mod tests { #[test] fn it_should_not_connect_grafana_to_proxy_network_when_caddy_disabled() { let context = make_context(false); - let config = GrafanaServiceConfig::from_domain_config(&make_config(false), &context); + let config = GrafanaServiceContext::from_domain_config(&make_config(false), &context); assert_eq!(config.networks, vec![Network::Visualization]); assert!(!config.networks.contains(&Network::Proxy)); @@ -110,7 +110,7 @@ mod tests { #[test] fn it_should_connect_grafana_to_proxy_network_when_caddy_enabled() { let context = make_context(true); - let config = GrafanaServiceConfig::from_domain_config(&make_config(true), &context); + let config = GrafanaServiceContext::from_domain_config(&make_config(true), &context); assert_eq!( config.networks, @@ -121,7 +121,7 @@ mod tests { #[test] fn it_should_serialize_networks_to_name_strings() { let context = make_context(true); - let config = GrafanaServiceConfig::from_domain_config(&make_config(true), &context); + let config = GrafanaServiceContext::from_domain_config(&make_config(true), &context); let json = serde_json::to_value(&config).expect("serialization should succeed"); @@ -133,7 +133,7 @@ mod tests { #[test] fn it_should_expose_port_3000_when_tls_disabled() { let context = make_context(false); - let config = GrafanaServiceConfig::from_domain_config(&make_config(false), &context); + let config = GrafanaServiceContext::from_domain_config(&make_config(false), &context); assert_eq!(config.ports.len(), 1); assert_eq!(config.ports[0].binding(), "3000:3000"); @@ -142,7 +142,7 @@ mod tests { #[test] fn it_should_not_expose_ports_when_tls_enabled() { let context = make_context(true); - let config = GrafanaServiceConfig::from_domain_config(&make_config(true), &context); + let config = GrafanaServiceContext::from_domain_config(&make_config(true), &context); 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 c31be5a1..f7fa5846 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 @@ -17,16 +17,18 @@ mod port_definition; mod prometheus; 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::{TrackerPorts, TrackerServiceContext}; + +// Re-exports - other types pub use builder::{DockerComposeContextBuilder, PortConflictError}; -pub use caddy::{CaddyDockerServiceConfig, CaddyServiceConfig}; pub use database::{DatabaseConfig, MysqlSetupConfig}; -pub use grafana::GrafanaServiceConfig; -pub use mysql::{MysqlDockerServiceConfig, MysqlServiceConfig}; pub use network_definition::NetworkDefinition; pub use port_definition::PortDefinition; -pub use prometheus::PrometheusServiceConfig; -pub use tracker::{TrackerPorts, TrackerServiceConfig}; /// Context for rendering the docker-compose.yml template /// @@ -36,13 +38,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. @@ -51,13 +53,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. @@ -79,14 +81,14 @@ 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}; /// /// // Create tracker config from domain configuration /// let domain_config = TrackerConfig::default(); /// let context = EnabledServices::from(&[]); - /// let tracker_config = TrackerServiceConfig::from_domain_config( + /// let tracker_config = TrackerServiceContext::from_domain_config( /// &domain_config, /// &context, /// ); @@ -98,7 +100,7 @@ impl DockerComposeContext { /// // MySQL /// let domain_config_for_mysql = TrackerConfig::default(); /// let mysql_context = EnabledServices::from(&[Service::MySQL]); - /// let tracker_config_with_mysql = TrackerServiceConfig::from_domain_config( + /// let tracker_config_with_mysql = TrackerServiceContext::from_domain_config( /// &domain_config_for_mysql, /// &mysql_context, /// ); @@ -127,31 +129,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() } @@ -243,21 +245,21 @@ mod tests { .unwrap() } - /// Helper to create `TrackerServiceConfig` for tests (no TLS, no networks) - fn test_tracker_config() -> TrackerServiceConfig { + /// 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(&[]); - TrackerServiceConfig::from_domain_config(&domain_config, &context) + TrackerServiceContext::from_domain_config(&domain_config, &context) } - /// Helper to create `TrackerServiceConfig` with specific network configuration + /// 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, - ) -> TrackerServiceConfig { + ) -> TrackerServiceContext { use crate::domain::topology::Service; let domain_config = if use_api_tls { domain_tracker_config_with_api_tls(6868) @@ -275,7 +277,7 @@ mod tests { services.push(Service::Caddy); } let context = EnabledServices::from(&services); - TrackerServiceConfig::from_domain_config(&domain_config, &context) + TrackerServiceContext::from_domain_config(&domain_config, &context) } #[test] 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 943a7226..0e90e3a6 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 @@ -9,7 +9,7 @@ //! - `MysqlSetupConfig` (in database.rs): Contains credentials and initialization settings //! for Docker Compose environment variables (root password, database name, user, etc.) //! -//! - `MysqlDockerServiceConfig` (this module): Contains service definition settings like networks, +//! - `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 @@ -33,16 +33,16 @@ use super::port_definition::PortDefinition; /// # Example /// /// ```rust -/// use torrust_tracker_deployer_lib::infrastructure::templating::docker_compose::template::wrappers::docker_compose::context::MysqlDockerServiceConfig; +/// 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 = MysqlDockerServiceConfig::from_domain_config(&MysqlServiceConfig::new(), &EnabledServices::default()); +/// let mysql = MysqlServiceContext::from_domain_config(&MysqlServiceConfig::new(), &EnabledServices::default()); /// assert_eq!(mysql.networks, vec![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 MysqlDockerServiceConfig { +pub struct MysqlServiceContext { /// Port bindings for Docker Compose /// /// `MySQL` never exposes ports externally - only tracker can access it via internal network. @@ -54,8 +54,8 @@ pub struct MysqlDockerServiceConfig { pub networks: Vec, } -impl MysqlDockerServiceConfig { - /// Creates a new `MysqlDockerServiceConfig` from domain configuration +impl MysqlServiceContext { + /// Creates a new `MysqlServiceContext` from domain configuration /// /// Uses the domain `PortDerivation` and `NetworkDerivation` traits, /// ensuring business rules live in the domain layer. @@ -76,7 +76,7 @@ impl MysqlDockerServiceConfig { Self { ports, networks } } - /// Creates a new `MysqlDockerServiceConfig` with default configuration + /// Creates a new `MysqlServiceContext` with default configuration /// /// Convenience method that creates a default domain config and empty enabled services. #[must_use] @@ -85,36 +85,33 @@ impl MysqlDockerServiceConfig { } } -impl Default for MysqlDockerServiceConfig { +impl Default for MysqlServiceContext { fn default() -> Self { Self::new() } } -/// Type alias for backward compatibility -pub type MysqlServiceConfig = MysqlDockerServiceConfig; - #[cfg(test)] mod tests { use super::*; #[test] fn it_should_connect_mysql_to_database_network() { - let mysql = MysqlDockerServiceConfig::new(); + let mysql = MysqlServiceContext::new(); assert_eq!(mysql.networks, vec![Network::Database]); } #[test] fn it_should_implement_default() { - let mysql = MysqlDockerServiceConfig::default(); + let mysql = MysqlServiceContext::default(); assert_eq!(mysql.networks, vec![Network::Database]); } #[test] fn it_should_serialize_network_to_name_string() { - let mysql = MysqlDockerServiceConfig::new(); + let mysql = MysqlServiceContext::new(); let json = serde_json::to_value(&mysql).expect("serialization should succeed"); @@ -124,7 +121,7 @@ mod tests { #[test] fn it_should_not_expose_any_ports() { - let mysql = MysqlDockerServiceConfig::new(); + let mysql = MysqlServiceContext::new(); assert!(mysql.ports.is_empty()); } @@ -133,7 +130,7 @@ mod tests { fn it_should_use_domain_traits_for_port_derivation() { let config = DomainMysqlConfig::new(); let enabled_services = EnabledServices::default(); - let mysql = MysqlDockerServiceConfig::from_domain_config(&config, &enabled_services); + let mysql = MysqlServiceContext::from_domain_config(&config, &enabled_services); // Verify ports come from domain trait (empty for MySQL) assert!(mysql.ports.is_empty()); @@ -143,7 +140,7 @@ mod tests { fn it_should_use_domain_traits_for_network_derivation() { let config = DomainMysqlConfig::new(); let enabled_services = EnabledServices::default(); - let mysql = MysqlDockerServiceConfig::from_domain_config(&config, &enabled_services); + let mysql = MysqlServiceContext::from_domain_config(&config, &enabled_services); // Verify networks come from domain trait assert_eq!(mysql.networks, vec![Network::Database]); 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 34d7da3f..8b3e2162 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 @@ -14,7 +14,7 @@ use super::port_definition::PortDefinition; /// including the scrape interval, port mappings, and network connections. All logic /// is pre-computed in Rust to keep the Tera template simple. #[derive(Serialize, Debug, Clone)] -pub struct PrometheusServiceConfig { +pub struct PrometheusServiceContext { /// Scrape interval in seconds pub scrape_interval_in_secs: u32, /// Port bindings for Docker Compose @@ -29,8 +29,8 @@ pub struct PrometheusServiceConfig { pub networks: Vec, } -impl PrometheusServiceConfig { - /// Creates a new `PrometheusServiceConfig` from domain configuration +impl PrometheusServiceContext { + /// Creates a new `PrometheusServiceContext` from domain configuration /// /// Uses the domain `PortDerivation` and `NetworkDerivation` traits, /// ensuring business rules live in the domain layer. @@ -81,7 +81,7 @@ mod tests { #[test] fn it_should_connect_prometheus_to_metrics_network() { let context = make_context(false); - let config = PrometheusServiceConfig::from_domain_config(&make_config(15), &context); + let config = PrometheusServiceContext::from_domain_config(&make_config(15), &context); assert!(config.networks.contains(&Network::Metrics)); } @@ -89,7 +89,7 @@ mod tests { #[test] fn it_should_not_connect_prometheus_to_visualization_network_when_grafana_disabled() { let context = make_context(false); - let config = PrometheusServiceConfig::from_domain_config(&make_config(15), &context); + let config = PrometheusServiceContext::from_domain_config(&make_config(15), &context); assert_eq!(config.networks, vec![Network::Metrics]); assert!(!config.networks.contains(&Network::Visualization)); @@ -98,7 +98,7 @@ mod tests { #[test] fn it_should_connect_prometheus_to_visualization_network_when_grafana_enabled() { let context = make_context(true); - let config = PrometheusServiceConfig::from_domain_config(&make_config(30), &context); + let config = PrometheusServiceContext::from_domain_config(&make_config(30), &context); assert_eq!( config.networks, @@ -109,7 +109,7 @@ mod tests { #[test] fn it_should_serialize_networks_to_name_strings() { let context = make_context(true); - let config = PrometheusServiceConfig::from_domain_config(&make_config(15), &context); + let config = PrometheusServiceContext::from_domain_config(&make_config(15), &context); let json = serde_json::to_value(&config).expect("serialization should succeed"); @@ -121,7 +121,7 @@ mod tests { #[test] fn it_should_expose_localhost_port_9090() { let context = make_context(false); - let config = PrometheusServiceConfig::from_domain_config(&make_config(15), &context); + 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"); @@ -130,7 +130,7 @@ mod tests { #[test] fn it_should_serialize_ports_with_binding_and_description() { let context = make_context(false); - let config = PrometheusServiceConfig::from_domain_config(&make_config(15), &context); + 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/tracker.rs b/src/infrastructure/templating/docker_compose/template/wrappers/docker_compose/context/tracker.rs index 9de2293c..1af4fe1e 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 @@ -8,13 +8,13 @@ use crate::domain::tracker::TrackerConfig; use super::port_definition::PortDefinition; -/// 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. #[derive(Serialize, Debug, Clone)] -pub struct TrackerServiceConfig { +pub struct TrackerServiceContext { /// 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) @@ -43,8 +43,8 @@ pub struct TrackerServiceConfig { pub networks: Vec, } -impl TrackerServiceConfig { - /// Creates a new `TrackerServiceConfig` from domain configuration +impl TrackerServiceContext { + /// Creates a new `TrackerServiceContext` from domain configuration /// /// Uses the domain `PortDerivation` trait for port derivation logic, /// ensuring business rules live in the domain layer. @@ -95,8 +95,11 @@ impl TrackerServiceConfig { } } -// Type alias for backward compatibility -pub type TrackerPorts = TrackerServiceConfig; +/// Alias for the builder API +/// +/// This is used in the builder pattern to accept tracker configuration. +/// It's NOT a backward compatibility alias for the old name. +pub type TrackerPorts = TrackerServiceContext; #[cfg(test)] mod tests { @@ -250,7 +253,7 @@ mod tests { fn it_should_connect_tracker_to_metrics_network_when_prometheus_enabled() { let domain_config = basic_domain_tracker_config(); let context = make_context(true, false, false); - let config = TrackerServiceConfig::from_domain_config(&domain_config, &context); + let config = TrackerServiceContext::from_domain_config(&domain_config, &context); assert!(config.networks.contains(&Network::Metrics)); } @@ -259,7 +262,7 @@ mod tests { fn it_should_not_connect_tracker_to_metrics_network_when_prometheus_disabled() { let domain_config = basic_domain_tracker_config(); let context = make_context(false, false, false); - let config = TrackerServiceConfig::from_domain_config(&domain_config, &context); + let config = TrackerServiceContext::from_domain_config(&domain_config, &context); assert!(!config.networks.contains(&Network::Metrics)); } @@ -268,7 +271,7 @@ mod tests { fn it_should_connect_tracker_to_database_network_when_mysql_enabled() { let domain_config = basic_domain_tracker_config(); let context = make_context(false, true, false); - let config = TrackerServiceConfig::from_domain_config(&domain_config, &context); + let config = TrackerServiceContext::from_domain_config(&domain_config, &context); assert!(config.networks.contains(&Network::Database)); } @@ -277,7 +280,7 @@ mod tests { fn it_should_not_connect_tracker_to_database_network_when_mysql_disabled() { let domain_config = basic_domain_tracker_config(); let context = make_context(false, false, false); - let config = TrackerServiceConfig::from_domain_config(&domain_config, &context); + let config = TrackerServiceContext::from_domain_config(&domain_config, &context); assert!(!config.networks.contains(&Network::Database)); } @@ -286,7 +289,7 @@ mod tests { fn it_should_connect_tracker_to_proxy_network_when_caddy_enabled() { let domain_config = domain_tracker_config_with_api_tls(); let context = make_context(false, false, true); - let config = TrackerServiceConfig::from_domain_config(&domain_config, &context); + let config = TrackerServiceContext::from_domain_config(&domain_config, &context); assert!(config.networks.contains(&Network::Proxy)); } @@ -295,7 +298,7 @@ mod tests { fn it_should_not_connect_tracker_to_proxy_network_when_caddy_disabled() { let domain_config = basic_domain_tracker_config(); let context = make_context(false, false, false); - let config = TrackerServiceConfig::from_domain_config(&domain_config, &context); + let config = TrackerServiceContext::from_domain_config(&domain_config, &context); assert!(!config.networks.contains(&Network::Proxy)); } @@ -304,7 +307,7 @@ mod tests { fn it_should_connect_tracker_to_all_networks_when_all_services_enabled() { let domain_config = domain_tracker_config_with_api_tls(); let context = make_context(true, true, true); - let config = TrackerServiceConfig::from_domain_config(&domain_config, &context); + let config = TrackerServiceContext::from_domain_config(&domain_config, &context); assert_eq!( config.networks, @@ -316,7 +319,7 @@ mod tests { fn it_should_have_no_networks_when_minimal_deployment() { let domain_config = basic_domain_tracker_config(); let context = make_context(false, false, false); - let config = TrackerServiceConfig::from_domain_config(&domain_config, &context); + let config = TrackerServiceContext::from_domain_config(&domain_config, &context); assert!(config.networks.is_empty()); } @@ -329,7 +332,7 @@ mod tests { fn it_should_serialize_networks_to_name_strings() { let domain_config = domain_tracker_config_with_api_tls(); let context = make_context(true, true, false); - let config = TrackerServiceConfig::from_domain_config(&domain_config, &context); + let config = TrackerServiceContext::from_domain_config(&domain_config, &context); let json = serde_json::to_value(&config).expect("serialization should succeed"); @@ -346,7 +349,7 @@ mod tests { fn it_should_derive_udp_ports() { let domain_config = domain_tracker_config_with_udp_ports(&[6969, 6970]); let context = make_context(false, false, false); - let config = TrackerServiceConfig::from_domain_config(&domain_config, &context); + let config = TrackerServiceContext::from_domain_config(&domain_config, &context); // UDP ports + API port (no TLS) = 3 ports // Filter just UDP ports @@ -364,7 +367,7 @@ mod tests { fn it_should_derive_http_ports_without_tls() { let domain_config = domain_tracker_config_with_http_ports(&[7070, 7071]); let context = make_context(false, false, false); - let config = TrackerServiceConfig::from_domain_config(&domain_config, &context); + 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); @@ -376,7 +379,7 @@ mod tests { fn it_should_derive_api_port_when_no_tls() { let domain_config = basic_domain_tracker_config(); let context = make_context(false, false, false); - let config = TrackerServiceConfig::from_domain_config(&domain_config, &context); + let config = TrackerServiceContext::from_domain_config(&domain_config, &context); // UDP port + API port = 2 ports // API port is the second one @@ -388,7 +391,7 @@ mod tests { fn it_should_not_derive_api_port_when_tls_enabled() { let domain_config = minimal_domain_tracker_config_with_api_tls(); let context = make_context(false, false, false); - let config = TrackerServiceConfig::from_domain_config(&domain_config, &context); + let config = TrackerServiceContext::from_domain_config(&domain_config, &context); // No UDP, no HTTP without TLS, API has TLS = no ports assert!(config.ports.is_empty()); @@ -398,7 +401,7 @@ mod tests { fn it_should_serialize_ports_with_binding_and_description() { let domain_config = basic_domain_tracker_config(); let context = make_context(false, false, false); - let config = TrackerServiceConfig::from_domain_config(&domain_config, &context); + 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 41690779..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,7 +81,7 @@ impl DockerComposeTemplate { #[cfg(test)] mod tests { - use super::super::context::{MysqlSetupConfig, TrackerServiceConfig}; + use super::super::context::{MysqlSetupConfig, TrackerServiceContext}; use super::*; use crate::domain::topology::EnabledServices; @@ -114,11 +114,11 @@ mod tests { .unwrap() } - /// Helper to create `TrackerServiceConfig` for tests (no TLS, no networks) - fn test_tracker_config() -> TrackerServiceConfig { + /// 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(&[]); - TrackerServiceConfig::from_domain_config(&domain_config, &context) + TrackerServiceContext::from_domain_config(&domain_config, &context) } #[test] From 8452fb58186461dfad95591bf55529df06604937 Mon Sep 17 00:00:00 2001 From: Jose Celano Date: Mon, 26 Jan 2026 12:46:06 +0000 Subject: [PATCH 13/19] refactor(infrastructure): [#301] remove TrackerPorts type alias Remove confusing backward compatibility alias. Use TrackerServiceContext directly in the builder API for clarity and consistency. --- .../template/wrappers/docker_compose/context/mod.rs | 6 +++--- .../template/wrappers/docker_compose/context/tracker.rs | 6 ------ 2 files changed, 3 insertions(+), 9 deletions(-) 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 f7fa5846..dfe0d3b9 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 @@ -22,7 +22,7 @@ pub use caddy::CaddyServiceContext; pub use grafana::GrafanaServiceContext; pub use mysql::MysqlServiceContext; pub use prometheus::PrometheusServiceContext; -pub use tracker::{TrackerPorts, TrackerServiceContext}; +pub use tracker::TrackerServiceContext; // Re-exports - other types pub use builder::{DockerComposeContextBuilder, PortConflictError}; @@ -117,8 +117,8 @@ impl DockerComposeContext { /// 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 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 1af4fe1e..035fe70d 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 @@ -95,12 +95,6 @@ impl TrackerServiceContext { } } -/// Alias for the builder API -/// -/// This is used in the builder pattern to accept tracker configuration. -/// It's NOT a backward compatibility alias for the old name. -pub type TrackerPorts = TrackerServiceContext; - #[cfg(test)] mod tests { use super::*; From f47326fef90827a54325ef3adaf44cd023064a6e Mon Sep 17 00:00:00 2001 From: Jose Celano Date: Mon, 26 Jan 2026 12:49:08 +0000 Subject: [PATCH 14/19] docs: [#301] update Phase 4 progress - Step 7.5 complete MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mark infrastructure service config renames as completed: - TrackerServiceConfig → TrackerServiceContext - GrafanaServiceConfig → GrafanaServiceContext - PrometheusServiceConfig → PrometheusServiceContext - CaddyDockerServiceConfig → CaddyServiceContext - MysqlDockerServiceConfig → MysqlServiceContext All Phase 4 implementation steps are now complete. --- ...-phase-4-service-topology-ddd-alignment.md | 40 +++++++++---------- 1 file changed, 19 insertions(+), 21 deletions(-) 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 031c9547..fac2b754 100644 --- a/docs/issues/301-phase-4-service-topology-ddd-alignment.md +++ b/docs/issues/301-phase-4-service-topology-ddd-alignment.md @@ -364,13 +364,11 @@ impl TrackerServiceContext { - [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` -- [ ] 7.5 Rename infrastructure service config types to `*Context` (optional, deferred) - - **What**: Rename infrastructure DTOs to better reflect their purpose as template contexts - - **Current names**: `TrackerServiceConfig`, `GrafanaServiceConfig`, `PrometheusServiceConfig`, `CaddyDockerServiceConfig`, `MysqlDockerServiceConfig` - - **Proposed names**: `TrackerServiceContext`, `GrafanaServiceContext`, `PrometheusServiceContext`, `CaddyServiceContext`, `MysqlServiceContext` - - **Why deferred**: Not critical for DDD alignment since the types are already pure DTOs - - **Benefit**: Clearer naming distinction between domain configs (`*Config`) and infrastructure contexts (`*Context`) - - **Scope**: ~5 files + all usages (~20-30 files), type aliases can ease migration +- [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 @@ -392,19 +390,19 @@ impl TrackerServiceContext { ### Modified Files -| 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` | Use `from_domain_config()`, rename to `CaddyDockerServiceConfig` | -| `src/infrastructure/.../context/mysql.rs` | Use `from_domain_config()`, rename to `MysqlDockerServiceConfig` | -| `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 @@ -437,7 +435,7 @@ impl TrackerServiceContext { | 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? | Deferred (optional) | Not critical for DDD alignment. Can be done in a future cleanup phase. | +| 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. | From e87b7ae6029429e16ab46f3f0bcdccb379345813 Mon Sep 17 00:00:00 2001 From: Jose Celano Date: Mon, 26 Jan 2026 13:10:22 +0000 Subject: [PATCH 15/19] refactor(infrastructure): [#301] remove legacy fields from TrackerServiceContext Remove unused fields that duplicated domain logic: - udp_tracker_ports, http_tracker_ports_without_tls - http_api_port, http_api_has_tls, needs_ports_section These fields were serialized but never used by templates (templates use ports/networks fields). Simplifies from_domain_config to 2 lines. Improves readability and removes redundant logic duplication. --- .../wrappers/docker_compose/context/mod.rs | 16 +++-- .../docker_compose/context/tracker.rs | 60 +++---------------- 2 files changed, 17 insertions(+), 59 deletions(-) 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 dfe0d3b9..8d39d135 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 @@ -287,9 +287,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] @@ -316,9 +319,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] 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 035fe70d..8c719a53 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 @@ -15,23 +15,6 @@ use super::port_definition::PortDefinition; /// in Rust to keep the Tera template simple. #[derive(Serialize, Debug, Clone)] pub struct TrackerServiceContext { - /// 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 /// /// Pre-computed list of all ports the tracker should expose. @@ -46,52 +29,23 @@ pub struct TrackerServiceContext { impl TrackerServiceContext { /// Creates a new `TrackerServiceContext` from domain configuration /// - /// Uses the domain `PortDerivation` trait for port derivation logic, + /// Uses the domain `PortDerivation` and `NetworkDerivation` traits, /// ensuring business rules live in the domain layer. /// /// # Arguments /// /// * `config` - The domain Tracker configuration - /// * `context` - Topology context with information about enabled services + /// * `enabled_services` - Topology context with information about enabled services #[must_use] pub fn from_domain_config(config: &TrackerConfig, enabled_services: &EnabledServices) -> Self { - // Extract port info for legacy fields - let udp_tracker_ports: Vec = config - .udp_trackers() - .iter() - .map(|t| t.bind_address().port()) - .collect(); - - let http_tracker_ports_without_tls: Vec = config - .http_trackers() + let networks = config.derive_networks(enabled_services); + let ports = config + .derive_ports() .iter() - .filter(|t| !t.use_tls_proxy()) - .map(|t| t.bind_address().port()) + .map(PortDefinition::from) .collect(); - let http_api_port = config.http_api().bind_address().port(); - let http_api_has_tls = config.http_api().use_tls_proxy(); - - let needs_ports_section = !udp_tracker_ports.is_empty() - || !http_tracker_ports_without_tls.is_empty() - || !http_api_has_tls; - - // Use domain NetworkDerivation trait for network logic - let networks = config.derive_networks(enabled_services); - - // Use domain PortDerivation trait for port logic - let port_bindings = config.derive_ports(); - let ports = port_bindings.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, - } + Self { ports, networks } } } From 195c80ef52bd0563def786f0449c2585535877d7 Mon Sep 17 00:00:00 2001 From: Jose Celano Date: Mon, 26 Jan 2026 13:24:16 +0000 Subject: [PATCH 16/19] refactor(infrastructure): [#301] remove unused fields from Grafana/Prometheus ServiceContext Remove fields not used by docker-compose.yml.tera template: - GrafanaServiceContext: admin_user, admin_password, has_tls - PrometheusServiceContext: scrape_interval_in_secs Credentials are handled by env/context.rs for .env template. Scrape interval is handled by PrometheusContext for prometheus.yml template. Simplifies from_domain_config to 2 lines in both types. --- .../docker_compose/context/grafana.rs | 32 ++++++------------- .../wrappers/docker_compose/context/mod.rs | 5 ++- .../docker_compose/context/prometheus.rs | 24 ++++++-------- 3 files changed, 20 insertions(+), 41 deletions(-) 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 06aca43e..7bb375f6 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 @@ -5,24 +5,16 @@ use serde::Serialize; use crate::domain::grafana::GrafanaConfig; use crate::domain::topology::{EnabledServices, Network, NetworkDerivation, PortDerivation}; -use crate::shared::secrets::Password; use super::port_definition::PortDefinition; /// 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. #[derive(Serialize, Debug, Clone)] pub struct GrafanaServiceContext { - /// 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. @@ -48,19 +40,13 @@ impl GrafanaServiceContext { /// * `context` - Topology context with information about enabled services #[must_use] pub fn from_domain_config(config: &GrafanaConfig, enabled_services: &EnabledServices) -> Self { - // Use domain NetworkDerivation trait for network logic let networks = config.derive_networks(enabled_services); - // Use domain PortDerivation trait for port logic - let port_bindings = config.derive_ports(); - let ports = port_bindings.iter().map(PortDefinition::from).collect(); - - Self { - admin_user: config.admin_user().to_string(), - admin_password: config.admin_password().clone(), - has_tls: config.use_tls_proxy(), - ports, - networks, - } + let ports = config + .derive_ports() + .iter() + .map(PortDefinition::from) + .collect(); + Self { ports, networks } } } 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 8d39d135..141afaea 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 @@ -394,7 +394,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] @@ -417,7 +417,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] @@ -471,7 +471,6 @@ mod tests { let grafana = context.grafana().unwrap(); assert_eq!(grafana.networks, vec![Network::Visualization]); - assert!(!grafana.has_tls); } #[test] 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 8b3e2162..db6d5454 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 @@ -10,13 +10,11 @@ use super::port_definition::PortDefinition; /// 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. #[derive(Serialize, Debug, Clone)] pub struct PrometheusServiceContext { - /// Scrape interval in seconds - pub scrape_interval_in_secs: u32, /// Port bindings for Docker Compose /// /// Prometheus exposes port 9090 on localhost only for security. @@ -44,17 +42,13 @@ impl PrometheusServiceContext { config: &PrometheusConfig, enabled_services: &EnabledServices, ) -> Self { - // Use domain NetworkDerivation trait for network logic let networks = config.derive_networks(enabled_services); - // Use domain PortDerivation trait for port logic - let port_bindings = config.derive_ports(); - let ports = port_bindings.iter().map(PortDefinition::from).collect(); - - Self { - scrape_interval_in_secs: config.scrape_interval_in_secs(), - ports, - networks, - } + let ports = config + .derive_ports() + .iter() + .map(PortDefinition::from) + .collect(); + Self { ports, networks } } } From 19623869a2d499561055db8b4845dc69c08de588 Mon Sep 17 00:00:00 2001 From: Jose Celano Date: Mon, 26 Jan 2026 14:06:53 +0000 Subject: [PATCH 17/19] refactor(infrastructure): [#301] extract ServiceTopology shared type MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extract common ports/networks fields into ServiceTopology type. All Docker Compose ServiceContext types now use this shared topology via #[serde(flatten)] to maintain template compatibility. Makes the architectural pattern explicit: - Topology (ports/networks) → docker-compose.yml template - Configuration (credentials) → .env template Changes: - Add service_topology.rs with shared ServiceTopology struct - Update TrackerServiceContext to embed ServiceTopology - Update GrafanaServiceContext to embed ServiceTopology - Update PrometheusServiceContext to embed ServiceTopology - Update CaddyServiceContext to embed ServiceTopology - Update MysqlServiceContext to embed ServiceTopology - Add ports() and networks() accessor methods - Update builder.rs to use accessor methods - Update all tests to use accessor methods --- .../docker_compose/context/builder.rs | 20 +-- .../wrappers/docker_compose/context/caddy.rs | 45 ++++--- .../docker_compose/context/grafana.rs | 50 ++++--- .../wrappers/docker_compose/context/mod.rs | 34 ++--- .../wrappers/docker_compose/context/mysql.rs | 45 ++++--- .../docker_compose/context/prometheus.rs | 46 ++++--- .../context/service_topology.rs | 125 ++++++++++++++++++ .../docker_compose/context/tracker.rs | 61 +++++---- 8 files changed, 306 insertions(+), 120 deletions(-) create mode 100644 src/infrastructure/templating/docker_compose/template/wrappers/docker_compose/context/service_topology.rs 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 9961e7f9..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 @@ -218,20 +218,20 @@ impl DockerComposeContextBuilder { 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) @@ -263,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()), ), ]; 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 15222d62..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 @@ -20,6 +20,7 @@ use crate::domain::caddy::CaddyConfig; use crate::domain::topology::{EnabledServices, Network, NetworkDerivation, PortDerivation}; use super::port_definition::PortDefinition; +use super::service_topology::ServiceTopology; /// Caddy reverse proxy service configuration for Docker Compose /// @@ -27,6 +28,8 @@ use super::port_definition::PortDefinition; /// 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 @@ -35,20 +38,16 @@ use super::port_definition::PortDefinition; /// use torrust_tracker_deployer_lib::domain::topology::{EnabledServices, Network}; /// /// let caddy = CaddyServiceContext::from_domain_config(&CaddyConfig::new(), &EnabledServices::default()); -/// assert_eq!(caddy.networks, vec![Network::Proxy]); -/// assert_eq!(caddy.ports.len(), 3); // 80, 443, 443/udp +/// assert_eq!(caddy.networks(), &[Network::Proxy]); +/// assert_eq!(caddy.ports().len(), 3); // 80, 443, 443/udp /// ``` #[derive(Debug, Clone, Serialize, PartialEq)] pub struct CaddyServiceContext { - /// 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 + /// 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 CaddyServiceContext { @@ -67,7 +66,9 @@ impl CaddyServiceContext { let ports = port_bindings.iter().map(PortDefinition::from).collect(); let networks = config.derive_networks(enabled_services); - Self { ports, networks } + Self { + topology: ServiceTopology::new(ports, networks), + } } /// Creates a new `CaddyServiceContext` with default configuration @@ -77,6 +78,18 @@ impl CaddyServiceContext { 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 CaddyServiceContext { @@ -93,14 +106,14 @@ mod tests { fn it_should_connect_caddy_to_proxy_network() { 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 = CaddyServiceContext::default(); - assert_eq!(caddy.networks, vec![Network::Proxy]); + assert_eq!(caddy.networks(), &[Network::Proxy]); } #[test] @@ -117,7 +130,7 @@ mod tests { fn it_should_expose_three_ports() { let caddy = CaddyServiceContext::new(); - assert_eq!(caddy.ports.len(), 3); + assert_eq!(caddy.ports().len(), 3); } #[test] @@ -138,7 +151,7 @@ mod tests { let caddy = CaddyServiceContext::from_domain_config(&config, &enabled_services); // Verify ports come from domain trait - assert_eq!(caddy.ports.len(), 3); + assert_eq!(caddy.ports().len(), 3); } #[test] @@ -148,6 +161,6 @@ mod tests { let caddy = CaddyServiceContext::from_domain_config(&config, &enabled_services); // Verify networks come from domain trait - assert_eq!(caddy.networks, vec![Network::Proxy]); + 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 7bb375f6..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 @@ -7,25 +7,22 @@ use crate::domain::grafana::GrafanaConfig; use crate::domain::topology::{EnabledServices, Network, NetworkDerivation, PortDerivation}; use super::port_definition::PortDefinition; +use super::service_topology::ServiceTopology; /// Grafana service configuration for Docker Compose /// /// 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 GrafanaServiceContext { - /// 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 + /// 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 GrafanaServiceContext { @@ -46,7 +43,21 @@ impl GrafanaServiceContext { .iter() .map(PortDefinition::from) .collect(); - Self { ports, networks } + Self { + topology: ServiceTopology::new(ports, networks), + } + } + + /// 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 } } @@ -81,7 +92,7 @@ mod tests { 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] @@ -89,8 +100,8 @@ mod tests { 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] @@ -98,10 +109,7 @@ mod tests { 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] @@ -121,8 +129,8 @@ mod tests { 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] @@ -130,6 +138,6 @@ mod tests { 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 141afaea..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 @@ -15,6 +15,7 @@ mod mysql; mod network_definition; mod port_definition; mod prometheus; +mod service_topology; mod tracker; // Re-exports - service contexts @@ -29,6 +30,7 @@ pub use builder::{DockerComposeContextBuilder, PortConflictError}; pub use database::{DatabaseConfig, MysqlSetupConfig}; pub use network_definition::NetworkDefinition; pub use port_definition::PortDefinition; +pub use service_topology::ServiceTopology; /// Context for rendering the docker-compose.yml template /// @@ -288,11 +290,11 @@ mod tests { assert_eq!(context.database().driver(), "sqlite3"); assert!(context.database().mysql().is_none()); // 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"); + 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] @@ -320,9 +322,9 @@ mod tests { assert_eq!(mysql.port, 3306); // 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().len(), 4); + assert_eq!(context.tracker().ports()[0].binding(), "6868:6868/udp"); + assert_eq!(context.tracker().ports()[1].binding(), "6969:6969/udp"); } #[test] @@ -394,7 +396,7 @@ mod tests { .build(); assert!(context.prometheus().is_some()); - assert!(!context.prometheus().unwrap().ports.is_empty()); + assert!(!context.prometheus().unwrap().ports().is_empty()); } #[test] @@ -432,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] @@ -452,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] ); } @@ -470,7 +472,7 @@ mod tests { .build(); let grafana = context.grafana().unwrap(); - assert_eq!(grafana.networks, vec![Network::Visualization]); + assert_eq!(grafana.networks(), &[Network::Visualization]); } #[test] @@ -482,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] ); } 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 0e90e3a6..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 @@ -23,6 +23,7 @@ use crate::domain::mysql::MysqlServiceConfig as DomainMysqlConfig; use crate::domain::topology::{EnabledServices, Network, NetworkDerivation, PortDerivation}; use super::port_definition::PortDefinition; +use super::service_topology::ServiceTopology; /// `MySQL` service configuration for Docker Compose /// @@ -30,6 +31,8 @@ use super::port_definition::PortDefinition; /// 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 @@ -38,20 +41,16 @@ use super::port_definition::PortDefinition; /// use torrust_tracker_deployer_lib::domain::topology::EnabledServices; /// /// let mysql = MysqlServiceContext::from_domain_config(&MysqlServiceConfig::new(), &EnabledServices::default()); -/// assert_eq!(mysql.networks, vec![torrust_tracker_deployer_lib::domain::topology::Network::Database]); -/// assert!(mysql.ports.is_empty()); // MySQL never exposes ports externally +/// 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 MysqlServiceContext { - /// 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 + /// 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 MysqlServiceContext { @@ -73,7 +72,9 @@ impl MysqlServiceContext { let ports = port_bindings.iter().map(PortDefinition::from).collect(); let networks = config.derive_networks(enabled_services); - Self { ports, networks } + Self { + topology: ServiceTopology::new(ports, networks), + } } /// Creates a new `MysqlServiceContext` with default configuration @@ -83,6 +84,18 @@ impl MysqlServiceContext { 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 MysqlServiceContext { @@ -99,14 +112,14 @@ mod tests { fn it_should_connect_mysql_to_database_network() { 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 = MysqlServiceContext::default(); - assert_eq!(mysql.networks, vec![Network::Database]); + assert_eq!(mysql.networks(), &[Network::Database]); } #[test] @@ -123,7 +136,7 @@ mod tests { fn it_should_not_expose_any_ports() { let mysql = MysqlServiceContext::new(); - assert!(mysql.ports.is_empty()); + assert!(mysql.ports().is_empty()); } #[test] @@ -133,7 +146,7 @@ mod tests { let mysql = MysqlServiceContext::from_domain_config(&config, &enabled_services); // Verify ports come from domain trait (empty for MySQL) - assert!(mysql.ports.is_empty()); + assert!(mysql.ports().is_empty()); } #[test] @@ -143,6 +156,6 @@ mod tests { let mysql = MysqlServiceContext::from_domain_config(&config, &enabled_services); // Verify networks come from domain trait - assert_eq!(mysql.networks, vec![Network::Database]); + assert_eq!(mysql.networks(), &[Network::Database]); } } 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 db6d5454..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 @@ -7,24 +7,22 @@ use crate::domain::prometheus::PrometheusConfig; use crate::domain::topology::{EnabledServices, Network, NetworkDerivation, PortDerivation}; use super::port_definition::PortDefinition; +use super::service_topology::ServiceTopology; /// Prometheus service configuration for Docker Compose /// /// 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 PrometheusServiceContext { - /// Port bindings for Docker Compose - /// - /// Prometheus exposes port 9090 on localhost only for security. - pub ports: Vec, - /// Networks the Prometheus service should connect to + /// 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 PrometheusServiceContext { @@ -48,7 +46,21 @@ impl PrometheusServiceContext { .iter() .map(PortDefinition::from) .collect(); - Self { ports, networks } + Self { + topology: ServiceTopology::new(ports, networks), + } + } + + /// 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 } } @@ -77,7 +89,7 @@ mod tests { 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] @@ -85,8 +97,8 @@ mod tests { 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] @@ -95,8 +107,8 @@ mod tests { 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] ); } @@ -117,8 +129,8 @@ mod tests { 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] 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 8c719a53..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 @@ -7,23 +7,22 @@ use crate::domain::topology::{EnabledServices, Network, NetworkDerivation, PortD use crate::domain::tracker::TrackerConfig; use super::port_definition::PortDefinition; +use super::service_topology::ServiceTopology; /// 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 TrackerServiceContext { - /// Port bindings for Docker Compose - /// - /// 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 + /// Service topology (ports and networks) /// - /// 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 TrackerServiceContext { @@ -45,7 +44,21 @@ impl TrackerServiceContext { .map(PortDefinition::from) .collect(); - Self { ports, networks } + Self { + topology: ServiceTopology::new(ports, networks), + } + } + + /// 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 } } @@ -203,7 +216,7 @@ mod tests { 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] @@ -212,7 +225,7 @@ mod tests { 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] @@ -221,7 +234,7 @@ mod tests { 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] @@ -230,7 +243,7 @@ mod tests { 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] @@ -239,7 +252,7 @@ mod tests { 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] @@ -248,7 +261,7 @@ mod tests { 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] @@ -258,8 +271,8 @@ mod tests { 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] ); } @@ -269,7 +282,7 @@ mod tests { 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()); } // ========================================================================== @@ -302,7 +315,7 @@ mod tests { // UDP ports + API port (no TLS) = 3 ports // Filter just UDP ports let udp_ports: Vec<_> = config - .ports + .ports() .iter() .filter(|p| p.binding().ends_with("/udp")) .collect(); @@ -318,9 +331,9 @@ mod tests { 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"); + assert_eq!(config.ports().len(), 2); + assert_eq!(config.ports()[0].binding(), "7070:7070"); + assert_eq!(config.ports()[1].binding(), "7071:7071"); } #[test] @@ -331,7 +344,7 @@ mod tests { // UDP port + API port = 2 ports // API port is the second one - let api_port = config.ports.iter().find(|p| p.binding() == "1212:1212"); + let api_port = config.ports().iter().find(|p| p.binding() == "1212:1212"); assert!(api_port.is_some()); } @@ -342,7 +355,7 @@ mod tests { let config = TrackerServiceContext::from_domain_config(&domain_config, &context); // No UDP, no HTTP without TLS, API has TLS = no ports - assert!(config.ports.is_empty()); + assert!(config.ports().is_empty()); } #[test] From 2f3314aff5102fd97e1ac1dabc8c0effa5af2920 Mon Sep 17 00:00:00 2001 From: Jose Celano Date: Mon, 26 Jan 2026 15:07:06 +0000 Subject: [PATCH 18/19] fix(domain): [#301] re-enable aggregate tests with clippy allow attribute --- src/domain/topology/aggregate.rs | 427 ++++++++++++++----------------- 1 file changed, 199 insertions(+), 228 deletions(-) diff --git a/src/domain/topology/aggregate.rs b/src/domain/topology/aggregate.rs index 63082a91..4ee07a0d 100644 --- a/src/domain/topology/aggregate.rs +++ b/src/domain/topology/aggregate.rs @@ -256,6 +256,7 @@ impl DockerComposeTopology { } #[cfg(test)] +#[allow(clippy::large_stack_arrays)] // False positive with vec![] macro for ServiceTopology items mod tests { use super::*; @@ -311,26 +312,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 +348,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 +429,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() { From 14a27e4d8068becc8a1b8174dd052298c075e631 Mon Sep 17 00:00:00 2001 From: Jose Celano Date: Mon, 26 Jan 2026 16:29:49 +0000 Subject: [PATCH 19/19] fix: [#304] add crate-level allow for clippy::large_stack_arrays false positive it actually allocates on the heap. This is a known upstream bug: https://github.com/rust-lang/rust-clippy/issues/12586 The fix was merged in April 2024 (should be in Rust 1.80.0+) but still triggers on Rust 1.93.0, suggesting a potential regression. See: docs/issues/304-clippy-large-stack-arrays-false-positive.md --- ...lippy-large-stack-arrays-false-positive.md | 114 ++++++++++++++++++ src/domain/topology/aggregate.rs | 1 - src/lib.rs | 6 + 3 files changed, 120 insertions(+), 1 deletion(-) create mode 100644 docs/issues/304-clippy-large-stack-arrays-false-positive.md 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/domain/topology/aggregate.rs b/src/domain/topology/aggregate.rs index 4ee07a0d..5ca5d58f 100644 --- a/src/domain/topology/aggregate.rs +++ b/src/domain/topology/aggregate.rs @@ -256,7 +256,6 @@ impl DockerComposeTopology { } #[cfg(test)] -#[allow(clippy::large_stack_arrays)] // False positive with vec![] macro for ServiceTopology items mod tests { use super::*; 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;