diff --git a/docs/issues/287-docker-compose-topology-refactoring-epic.md b/docs/issues/287-docker-compose-topology-refactoring-epic.md index 5be604fc..5b7f595c 100644 --- a/docs/issues/287-docker-compose-topology-refactoring-epic.md +++ b/docs/issues/287-docker-compose-topology-refactoring-epic.md @@ -21,18 +21,29 @@ From [docs/refactors/plans/docker-compose-topology-domain-model.md](../refactors - Derive global network lists from service configurations (single source of truth) - Move topology decision logic from template conditionals to domain layer - Enforce invariants like "if a service uses a network, that network must be defined" +- Move port exposure logic from templates to domain (Phase 3 - added post-completion) ## Tasks -Implementation follows a 5-PR strategy: +Implementation follows a 5-PR strategy (original scope), with Phase 3 added as a continuation: + +### Original 5-PR Strategy (Completed) - [x] [#288](https://github.com/torrust/torrust-tracker-deployer/issues/288) - [ADR] Bind Mount Standardization for Docker Compose (ADR-01) - [x] [#290](https://github.com/torrust/torrust-tracker-deployer/issues/290) - [BUG] Remove invalid "Grafana without Prometheus" template branch (BUG-01) - [x] [#292](https://github.com/torrust/torrust-tracker-deployer/issues/292) - [Refactor] Phase 0: Convert volumes to bind mounts with domain type (P0.1, P0.2) - [x] [#294](https://github.com/torrust/torrust-tracker-deployer/issues/294) - [Refactor] Phase 1: Create Network domain types (P1.1, P1.2) -- [ ] [#296](https://github.com/torrust/torrust-tracker-deployer/issues/296) - [Refactor] Phase 2: Create DockerComposeTopology aggregate (P2.1, P2.2) +- [x] [#296](https://github.com/torrust/torrust-tracker-deployer/issues/296) - [Refactor] Phase 2: Create DockerComposeTopology aggregate (P2.1, P2.2) → [PR #297](https://github.com/torrust/torrust-tracker-deployer/pull/297) + +### Phase 3: Port Topology (Added Extension) + +> **Note**: Phase 3 was part of the original refactoring plan analysis (see PORT-01 through PORT-11 rules in [docker-compose-topology-domain-model.md](../refactors/plans/docker-compose-topology-domain-model.md#port-exposure-rules)) but was not included in the initial 5-PR strategy. It follows the same pattern as networks and completes the topology domain model. -(Tasks will be created and linked as work progresses) +- [ ] Phase 3: Create Port Topology Domain Model (P3.1, P3.2, P3.3, P3.4) → [Draft](drafts/298-phase-3-port-topology-domain-model.md) + - P3.1: Create Port domain types (`PortBinding`, `Protocol`) + - P3.2: Extend `ServiceTopology` with port derivation + - P3.3: Add cross-service port conflict validation + - P3.4: Update template to use derived ports with descriptions ## PR Dependencies @@ -47,6 +58,9 @@ PR 4 (Phase 1) │ ▼ PR 5 (Phase 2) + │ + ▼ +PR 6 (Phase 3) ◄─── Extension: Port topology (added post-completion) ``` ## Key Decisions @@ -55,8 +69,11 @@ PR 5 (Phase 2) - Type-safe `MountOption` enum (ReadOnly, SELinux) - Domain-driven network derivation (single source of truth) - ~47 domain rules identified with test specifications +- Network descriptions rendered as YAML comments for sysadmin documentation +- Port topology follows same pattern as networks (Phase 3) ## Related - Plan Review: #285 (merged) - Refactoring Plan: [docs/refactors/plans/docker-compose-topology-domain-model.md](../refactors/plans/docker-compose-topology-domain-model.md) +- Phase 3 Draft: [drafts/298-phase-3-port-topology-domain-model.md](drafts/298-phase-3-port-topology-domain-model.md) diff --git a/docs/issues/296-phase-2-topology-aggregate.md b/docs/issues/296-phase-2-topology-aggregate.md index 71f5ef16..69078166 100644 --- a/docs/issues/296-phase-2-topology-aggregate.md +++ b/docs/issues/296-phase-2-topology-aggregate.md @@ -1,6 +1,7 @@ # [Refactor] Phase 2: Create DockerComposeTopology Aggregate **Issue**: [#296](https://github.com/torrust/torrust-tracker-deployer/issues/296) +**Pull Request**: [#297](https://github.com/torrust/torrust-tracker-deployer/pull/297) **Parent Epic**: [#287](https://github.com/torrust/torrust-tracker-deployer/issues/287) - Docker Compose Topology Domain Model Refactoring **Related**: @@ -13,11 +14,11 @@ This task creates the `DockerComposeTopology` aggregate and derives required net ## Goals -- [ ] Create `DockerComposeTopology` aggregate with service topology collection -- [ ] Create `Service` enum for type-safe service identification -- [ ] Derive required networks from service configurations (single source of truth) -- [ ] Remove conditional network logic from docker-compose template -- [ ] Enforce invariant: "if a service uses a network, that network must be defined" +- [x] Create `DockerComposeTopology` aggregate with service topology collection +- [x] Create `Service` enum for type-safe service identification +- [x] Derive required networks from service configurations (single source of truth) +- [x] Remove conditional network logic from docker-compose template +- [x] Enforce invariant: "if a service uses a network, that network must be defined" ## 🏗️ Architecture Requirements diff --git a/docs/issues/drafts/298-phase-3-port-topology-domain-model.md b/docs/issues/drafts/298-phase-3-port-topology-domain-model.md new file mode 100644 index 00000000..636340b8 --- /dev/null +++ b/docs/issues/drafts/298-phase-3-port-topology-domain-model.md @@ -0,0 +1,250 @@ +# [Refactor] Phase 3: Create Port Topology Domain Model + +**Epic**: [#287](https://github.com/torrust/torrust-tracker-deployer/issues/287) - Docker Compose Topology Domain Model Refactoring +**Related Plan**: [docs/refactors/plans/docker-compose-topology-domain-model.md](../../refactors/plans/docker-compose-topology-domain-model.md) +**Status**: Draft + +## Overview + +Move port exposure logic from Tera templates to the domain layer, following the same pattern used for networks in Phase 1-2. This creates type-safe port definitions with descriptions that render as inline YAML comments for sysadmin self-documentation. + +## Background + +This phase was originally part of the refactoring plan (see PORT-01 through PORT-11 rules in the plan) but was not included in the initial 5-PR strategy. The port exposure rules identified in the plan are currently scattered between Rust code and Tera templates, creating the same dual-source-of-truth problem that was solved for networks. + +## Problem Statement + +### Current State + +Port exposure logic is split across multiple locations: + +1. **Template conditionals** - Complex `{%- if %}` blocks for TLS-based port exposure +2. **Rust filtering** - `http_tracker_ports_without_tls` computed in context builder +3. **Hardcoded ports** - Caddy (80, 443), Prometheus (9090), MySQL (none) + +Example from template: + +```tera +{%- if tracker.needs_ports_section %} + ports: + {%- for udp_tracker in tracker.udp_trackers %} + - "{{ udp_tracker.port }}:{{ udp_tracker.port }}/udp" + {%- endfor %} + {%- for http_tracker_port in tracker.http_tracker_ports_without_tls %} + - "{{ http_tracker_port }}:{{ http_tracker_port }}" + {%- endfor %} + {%- if not tracker.http_api_has_tls %} + - "{{ tracker.http_api_port }}:{{ tracker.http_api_port }}" + {%- endif %} +{%- endif %} +``` + +### Problems + +1. **Logic in templates** - Port exposure decisions based on TLS configuration +2. **No cross-service validation** - Cannot detect host port conflicts across services +3. **Missing documentation** - Rendered YAML lacks context for what each port does +4. **Existing validation is tracker-only** - `TrackerConfig` validates internal socket conflicts but not Docker Compose host port conflicts + +### Desired State + +```yaml +services: + tracker: + ports: + # BitTorrent UDP announce (external) + - "6969:6969/udp" + # HTTP tracker announce (no TLS) + - "7070:7070" + # REST API for stats/whitelist (no TLS) + - "1212:1212" +``` + +## Proposed Solution + +### Domain Types + +Create port topology types that complement the existing network topology: + +```rust +// src/domain/topology/port.rs + +/// Network protocol for port bindings +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Protocol { + Tcp, + Udp, +} + +/// A port binding in Docker Compose +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PortBinding { + /// Host port (None = internal only, not exposed) + host_port: Option, + /// Container port + container_port: u16, + /// Protocol (TCP or UDP) + protocol: Protocol, + /// Host IP to bind to (None = 0.0.0.0) + host_ip: Option, +} + +impl PortBinding { + /// Short description of what this port does + pub fn description(&self) -> &'static str { + // Derived from context (service type + port purpose) + } +} +``` + +### ServiceTopology Extension + +Extend `ServiceTopology` to include port definitions: + +```rust +// src/domain/topology/aggregate.rs + +pub struct ServiceTopology { + service: Service, + networks: Vec, + ports: Vec, // NEW +} + +impl ServiceTopology { + /// Derives ports from service configuration + pub fn ports(&self) -> &[PortBinding] { + &self.ports + } +} +``` + +### Aggregate Validation + +Add cross-service port conflict detection to `DockerComposeTopology`: + +```rust +impl DockerComposeTopology { + /// Validates no host port conflicts across all services + fn validate_port_uniqueness(&self) -> Result<(), TopologyError> { + // Collect all exposed (host_port, protocol) pairs + // Error if duplicates found + } +} +``` + +### Relationship with TrackerConfig Validation + +| Validation | Location | Scope | +| -------------------------------------------------------------- | ----------------------------------- | ---------------------------- | +| Socket address uniqueness (UDP tracker vs HTTP tracker vs API) | `TrackerConfig::new()` | Tracker application internal | +| Host port uniqueness (Tracker vs Grafana vs Caddy) | `DockerComposeTopology::validate()` | Docker Compose deployment | + +**Key insight**: `TrackerConfig` validates what the Torrust Tracker binary can bind to. `DockerComposeTopology` validates what Docker Compose can expose to the host. + +## Port Rules from Original Plan + +These rules (PORT-01 through PORT-11) will be moved to domain: + +| Rule ID | Rule Description | Target Location | +| ------- | ------------------------------------------------------------------------- | --------------------------------------- | +| PORT-01 | Tracker needs ports section if UDP OR HTTP without TLS OR API without TLS | `ServiceTopology::ports()` | +| PORT-02 | UDP ports always exposed (no TLS) | `PortBinding` for UDP tracker | +| PORT-03 | HTTP ports WITHOUT TLS exposed directly | `PortBinding` with host_port | +| PORT-04 | HTTP ports WITH TLS NOT exposed (Caddy handles) | No `PortBinding` created | +| PORT-05 | API exposed only when no TLS | Conditional `PortBinding` | +| PORT-06 | API NOT exposed when TLS | No `PortBinding` created | +| PORT-07 | Grafana 3000 exposed only without TLS | Conditional `PortBinding` | +| PORT-08 | Grafana 3000 NOT exposed with TLS | No `PortBinding` created | +| PORT-09 | Caddy always exposes 80, 443, 443/udp | Fixed `PortBinding`s | +| PORT-10 | Prometheus 9090 on localhost only | `PortBinding` with `host_ip: 127.0.0.1` | +| PORT-11 | MySQL no exposed ports | Empty `ports` in `ServiceTopology` | + +## Template Simplification + +After refactoring: + +```tera +services: + {{ service.name }}: +{%- if service.ports | length > 0 %} + ports: +{%- for port in service.ports %} + # {{ port.description }} + - "{{ port.binding }}" +{%- endfor %} +{%- endif %} +``` + +## Tasks + +### P3.1: Create Port Domain Types + +- [ ] Create `Protocol` enum (or reuse from `domain/tracker`) +- [ ] Create `PortBinding` struct with description support +- [ ] Add `PortDefinition` context type for template rendering +- [ ] Add unit tests for port binding creation + +### P3.2: Extend ServiceTopology with Ports + +- [ ] Add `ports: Vec` to `ServiceTopology` +- [ ] Implement port derivation for each service type: + - Tracker: UDP ports, HTTP ports (TLS-dependent), API (TLS-dependent) + - Caddy: 80, 443, 443/udp (fixed) + - Prometheus: 9090 (localhost) + - Grafana: 3000 (TLS-dependent) + - MySQL: none +- [ ] Add unit tests for each service's port derivation + +### P3.3: Add Cross-Service Port Validation + +- [ ] Implement `DockerComposeTopology::validate_port_uniqueness()` +- [ ] Create `TopologyError::PortConflict` variant +- [ ] Add tests for conflict detection scenarios + +### P3.4: Update Template and Context + +- [ ] Create `PortDefinition` with `binding()` and `description()` for template +- [ ] Update `DockerComposeContext` to use derived ports +- [ ] Simplify `docker-compose.yml.tera` port sections +- [ ] Remove conditional port logic from template + +## Acceptance Criteria + +- [ ] All PORT-\* rules from refactoring plan are implemented in domain +- [ ] Port descriptions render as YAML comments in output +- [ ] Cross-service port conflicts are detected and reported with actionable error +- [ ] Template has no conditional port logic (just loops) +- [ ] No duplication with `TrackerConfig` validation (different scopes) +- [ ] All existing E2E tests pass +- [ ] Pre-commit checks pass + +## Testing Strategy + +### Unit Tests + +- Port derivation for each service type +- TLS-dependent port inclusion/exclusion +- Port conflict detection +- Description generation + +### Integration Tests + +- Context builder produces correct port definitions +- Template renders ports with descriptions + +### E2E Tests + +- Existing tests continue to pass (no behavioral change) + +## Out of Scope + +- Volume topology (no cross-service invariants currently) +- Service dependency ordering (separate concern) +- Health check configuration + +## References + +- [Original refactoring plan - Port rules](../../refactors/plans/docker-compose-topology-domain-model.md#port-exposure-rules) +- [TrackerConfig validation](../../../src/domain/tracker/config/mod.rs) - Internal socket conflict validation +- [Network topology implementation (Phase 1-2)](./287-docker-compose-topology-refactoring-epic.md) +- [UDP/TCP port sharing documentation](../../external-issues/tracker/udp-tcp-port-sharing-allowed.md) diff --git a/docs/issues/drafts/add-ai-discoverable-documentation-headers-to-templates.md b/docs/issues/drafts/add-ai-discoverable-documentation-headers-to-templates.md index 5fd5227c..51bbcbe1 100644 --- a/docs/issues/drafts/add-ai-discoverable-documentation-headers-to-templates.md +++ b/docs/issues/drafts/add-ai-discoverable-documentation-headers-to-templates.md @@ -149,6 +149,37 @@ This would require passing the version to the template context. - [Template System Architecture](../../docs/contributing/templates/template-system-architecture.md) - [Tera Template Guide](../../docs/contributing/templates/tera.md) +## Already Implemented: Inline Network Descriptions + +As a first step toward self-documenting rendered files, we've implemented inline network descriptions in docker-compose.yml. The `Network` enum now has a `description()` method that provides short purpose strings for each network type: + +| Network | Description | +| ------------- | ------------------------------------------- | +| Database | `Database isolation: Tracker ↔ MySQL` | +| Metrics | `Metrics scraping: Tracker ↔ Prometheus` | +| Visualization | `Dashboard queries: Prometheus ↔ Grafana` | +| Proxy | `TLS termination: Caddy ↔ backend services` | + +These descriptions are rendered as YAML comments in the generated docker-compose.yml: + +```yaml +networks: + # Database isolation: Tracker ↔ MySQL + db_network: + driver: bridge + # Metrics scraping: Tracker ↔ Prometheus + metrics_network: + driver: bridge +``` + +**Implementation details:** + +- `src/domain/topology/network.rs` - `Network::description()` method +- `context/network_definition.rs` - `NetworkDefinition.description` field +- `templates/docker-compose/docker-compose.yml.tera` - Renders `# {{ net.description }}` + +This pattern can be extended to other configuration elements (services, volumes, etc.) as we implement the full documentation headers. + ## Open Questions 1. Should we include the deployer version in the header? diff --git a/docs/refactors/plans/docker-compose-topology-domain-model.md b/docs/refactors/plans/docker-compose-topology-domain-model.md index c1dc344c..74595970 100644 --- a/docs/refactors/plans/docker-compose-topology-domain-model.md +++ b/docs/refactors/plans/docker-compose-topology-domain-model.md @@ -48,16 +48,16 @@ This refactoring plan addresses architectural issues in the docker-compose templ This table provides a single view of all work items for easy tracking: -| ID | Phase | Task | Status | Depends On | Notes | -| ------ | --------- | ----------------------------------------------------------- | -------------- | ---------- | ----------------------------------------------------------------------------------------- | -| BUG-01 | Priority | Remove invalid "Grafana without Prometheus" template branch | ✅ Completed | - | Dead code, see [BUG-01](#bug-01-template-handles-invalid-grafana-without-prometheus-case) | -| ADR-01 | Pre-Phase | Create ADR for Bind Mount Standardization | ✅ Completed | - | See [Pre-Phase Task](#pre-phase-task-create-adr-for-bind-mount-standardization) | -| P0.1 | Phase 0 | Convert Named Volumes to Bind Mounts | ✅ Completed | ADR-01 | See [Proposal 0.1](#proposal-01-convert-named-volumes-to-bind-mounts) | -| P0.2 | Phase 0 | Create BindMount Domain Type | ✅ Completed | P0.1 | See [Proposal 0.2](#proposal-02-create-bindmount-domain-type) | -| P1.1 | Phase 1 | Create Network Domain Types | ✅ Completed | - | See [Proposal 1.1](#proposal-11-create-network-domain-types) | -| P1.2 | Phase 1 | Migrate Service Configs to Use Network Enum | ✅ Completed | P1.1 | See [Proposal 1.2](#proposal-12-migrate-service-configs-to-use-network-enum) | -| P2.1 | Phase 2 | Create DockerComposeTopology Aggregate | ⏳ Not Started | P1.2 | See [Proposal 2.1](#proposal-21-create-dockercomposetopology-aggregate) | -| P2.2 | Phase 2 | Derive Required Networks in Context | ⏳ Not Started | P2.1 | See [Proposal 2.2](#proposal-22-derive-required-networks-in-context) | +| ID | Phase | Task | Status | Depends On | Notes | +| ------ | --------- | ----------------------------------------------------------- | ------------ | ---------- | ----------------------------------------------------------------------------------------- | +| BUG-01 | Priority | Remove invalid "Grafana without Prometheus" template branch | ✅ Completed | - | Dead code, see [BUG-01](#bug-01-template-handles-invalid-grafana-without-prometheus-case) | +| ADR-01 | Pre-Phase | Create ADR for Bind Mount Standardization | ✅ Completed | - | See [Pre-Phase Task](#pre-phase-task-create-adr-for-bind-mount-standardization) | +| P0.1 | Phase 0 | Convert Named Volumes to Bind Mounts | ✅ Completed | ADR-01 | See [Proposal 0.1](#proposal-01-convert-named-volumes-to-bind-mounts) | +| P0.2 | Phase 0 | Create BindMount Domain Type | ✅ Completed | P0.1 | See [Proposal 0.2](#proposal-02-create-bindmount-domain-type) | +| P1.1 | Phase 1 | Create Network Domain Types | ✅ Completed | - | See [Proposal 1.1](#proposal-11-create-network-domain-types) | +| P1.2 | Phase 1 | Migrate Service Configs to Use Network Enum | ✅ Completed | P1.1 | See [Proposal 1.2](#proposal-12-migrate-service-configs-to-use-network-enum) | +| P2.1 | Phase 2 | Create DockerComposeTopology Aggregate | ✅ Completed | P1.2 | See [Proposal 2.1](#proposal-21-create-dockercomposetopology-aggregate) | +| P2.2 | Phase 2 | Derive Required Networks in Context | ✅ Completed | P2.1 | See [Proposal 2.2](#proposal-22-derive-required-networks-in-context) | **Status Legend**: @@ -72,14 +72,16 @@ This table provides a single view of all work items for easy tracking: Track completed items with dates and any relevant notes: -| Date | ID | Task | Commit/PR | Notes | -| ---------- | ------ | ----------------------------------------------------------- | ----------------------------------------------------------------------- | --------------------------------------------------- | -| 2025-07-17 | ADR-01 | Create ADR for Bind Mount Standardization | [PR #289](https://github.com/torrust/torrust-tracker-deployer/pull/289) | Documented 9 reasons for bind mount standardization | -| 2025-07-17 | BUG-01 | Remove invalid "Grafana without Prometheus" template branch | [PR #291](https://github.com/torrust/torrust-tracker-deployer/pull/291) | Removed dead code from docker-compose template | -| 2025-07-17 | P0.1 | Convert Named Volumes to Bind Mounts | [PR #293](https://github.com/torrust/torrust-tracker-deployer/pull/293) | Bind mounts with BindMount domain type | -| 2025-07-17 | P0.2 | Create BindMount Domain Type | [PR #293](https://github.com/torrust/torrust-tracker-deployer/pull/293) | Storage playbooks with proper ownership | -| 2025-07-17 | P1.1 | Create Network Domain Types | [PR #295](https://github.com/torrust/torrust-tracker-deployer/pull/295) | Network enum in src/domain/topology/ | -| 2025-07-17 | P1.2 | Migrate Service Configs to Use Network Enum | [PR #295](https://github.com/torrust/torrust-tracker-deployer/pull/295) | All service configs use Vec | +| Date | ID | Task | Commit/PR | Notes | +| ---------- | ------ | ----------------------------------------------------------- | ----------------------------------------------------------------------- | ----------------------------------------------------- | +| 2025-07-17 | ADR-01 | Create ADR for Bind Mount Standardization | [PR #289](https://github.com/torrust/torrust-tracker-deployer/pull/289) | Documented 9 reasons for bind mount standardization | +| 2025-07-17 | BUG-01 | Remove invalid "Grafana without Prometheus" template branch | [PR #291](https://github.com/torrust/torrust-tracker-deployer/pull/291) | Removed dead code from docker-compose template | +| 2025-07-17 | P0.1 | Convert Named Volumes to Bind Mounts | [PR #293](https://github.com/torrust/torrust-tracker-deployer/pull/293) | Bind mounts with BindMount domain type | +| 2025-07-17 | P0.2 | Create BindMount Domain Type | [PR #293](https://github.com/torrust/torrust-tracker-deployer/pull/293) | Storage playbooks with proper ownership | +| 2025-07-17 | P1.1 | Create Network Domain Types | [PR #295](https://github.com/torrust/torrust-tracker-deployer/pull/295) | Network enum in src/domain/topology/ | +| 2025-07-17 | P1.2 | Migrate Service Configs to Use Network Enum | [PR #295](https://github.com/torrust/torrust-tracker-deployer/pull/295) | All service configs use Vec | +| 2025-07-25 | P2.1 | Create DockerComposeTopology Aggregate | [PR #297](https://github.com/torrust/torrust-tracker-deployer/pull/297) | Service enum + aggregate in domain/topology/ | +| 2025-07-25 | P2.2 | Derive Required Networks in Context | [PR #297](https://github.com/torrust/torrust-tracker-deployer/pull/297) | required_networks loop replaces template conditionals | ### Discarded Proposals diff --git a/src/domain/mod.rs b/src/domain/mod.rs index 3eadb53e..f6c62a02 100644 --- a/src/domain/mod.rs +++ b/src/domain/mod.rs @@ -35,4 +35,4 @@ pub use instance_name::{InstanceName, InstanceNameError}; pub use profile_name::{ProfileName, ProfileNameError}; pub use provider::{HetznerConfig, LxdConfig, Provider, ProviderConfig}; pub use template::{TemplateEngine, TemplateEngineError, TemplateManager, TemplateManagerError}; -pub use topology::Network; +pub use topology::{DockerComposeTopology, Network, Service, ServiceTopology}; diff --git a/src/domain/topology/aggregate.rs b/src/domain/topology/aggregate.rs new file mode 100644 index 00000000..00f28629 --- /dev/null +++ b/src/domain/topology/aggregate.rs @@ -0,0 +1,341 @@ +//! Docker Compose Topology Aggregate +//! +//! This module defines the [`DockerComposeTopology`] aggregate that collects +//! all service topologies and derives the required networks. +//! +//! ## Design +//! +//! The topology aggregate is the single source of truth for: +//! - Which services are enabled +//! - Which networks each service uses +//! - The complete list of required networks (derived from services) +//! +//! ## Invariants +//! +//! - Every network used by a service is included in `required_networks()` +//! - No orphan networks (networks defined but not used by any service) +//! - Networks are returned in deterministic order + +use std::collections::HashSet; + +use super::network::Network; +use super::service::Service; + +/// Topology information for a single service +/// +/// Contains the service identifier and its network assignments. +#[derive(Debug, Clone)] +pub struct ServiceTopology { + /// The service this topology describes + pub service: Service, + /// Networks this service is connected to + pub networks: Vec, +} + +impl ServiceTopology { + /// Creates a new service topology + #[must_use] + pub fn new(service: Service, networks: Vec) -> Self { + Self { service, networks } + } +} + +/// Docker Compose deployment topology aggregate +/// +/// This aggregate ensures all invariants are maintained: +/// - Networks used by services are derived and always defined +/// - No orphan networks exist +/// - Network lists are deterministic +/// +/// # Examples +/// +/// ```rust +/// use torrust_tracker_deployer_lib::domain::topology::{ +/// DockerComposeTopology, ServiceTopology, Service, Network +/// }; +/// +/// let topology = DockerComposeTopology::new(vec![ +/// ServiceTopology::new(Service::Tracker, vec![Network::Database, Network::Metrics]), +/// ServiceTopology::new(Service::MySQL, vec![Network::Database]), +/// ]); +/// +/// let required = topology.required_networks(); +/// assert!(required.contains(&Network::Database)); +/// assert!(required.contains(&Network::Metrics)); +/// assert!(!required.contains(&Network::Visualization)); // Not used +/// ``` +#[derive(Debug, Clone)] +pub struct DockerComposeTopology { + /// All services in the deployment with their network assignments + services: Vec, +} + +impl DockerComposeTopology { + /// Creates a new topology aggregate from service topologies + #[must_use] + pub fn new(services: Vec) -> Self { + Self { services } + } + + /// Returns all networks required by enabled services + /// + /// This is the single source of truth - the template's `networks:` section + /// should iterate over this, not use conditionals. + /// + /// The networks are returned in deterministic alphabetical order by name + /// to ensure template stability. + /// + /// # Invariants + /// + /// - Every network used by any service is included + /// - No orphan networks (only networks actually used are returned) + #[must_use] + pub fn required_networks(&self) -> Vec { + let unique: HashSet = self + .services + .iter() + .flat_map(|s| s.networks.iter().copied()) + .collect(); + + // Return in deterministic order for template stability + let mut networks: Vec = unique.into_iter().collect(); + networks.sort_by_key(Network::name); + networks + } + + /// Returns the services in this topology + #[must_use] + pub fn services(&self) -> &[ServiceTopology] { + &self.services + } +} + +#[cfg(test)] +mod tests { + use super::*; + + mod service_topology { + use super::*; + + #[test] + fn it_should_create_service_topology_with_networks() { + let topology = + ServiceTopology::new(Service::Tracker, vec![Network::Database, Network::Metrics]); + + assert_eq!(topology.service, Service::Tracker); + assert_eq!(topology.networks.len(), 2); + assert!(topology.networks.contains(&Network::Database)); + assert!(topology.networks.contains(&Network::Metrics)); + } + + #[test] + fn it_should_create_service_topology_with_empty_networks() { + let topology = ServiceTopology::new(Service::Tracker, vec![]); + + assert_eq!(topology.service, Service::Tracker); + assert!(topology.networks.is_empty()); + } + } + + mod required_networks { + use super::*; + + #[test] + fn it_should_derive_required_networks_from_all_services() { + let topology = DockerComposeTopology::new(vec![ + ServiceTopology::new(Service::Tracker, vec![Network::Database, Network::Metrics]), + ServiceTopology::new(Service::MySQL, vec![Network::Database]), + ServiceTopology::new(Service::Prometheus, vec![Network::Metrics]), + ]); + + let required = topology.required_networks(); + + assert!(required.contains(&Network::Database)); + assert!(required.contains(&Network::Metrics)); + } + + #[test] + fn it_should_not_have_orphan_networks() { + let topology = DockerComposeTopology::new(vec![ServiceTopology::new( + Service::Tracker, + vec![Network::Metrics], + )]); + + let required = topology.required_networks(); + + // Only Metrics is used, so only Metrics should be in required + assert_eq!(required.len(), 1); + assert!(required.contains(&Network::Metrics)); + assert!(!required.contains(&Network::Database)); + assert!(!required.contains(&Network::Visualization)); + assert!(!required.contains(&Network::Proxy)); + } + + #[test] + fn it_should_return_networks_in_deterministic_order() { + // Create topology with networks added in non-alphabetical order + let topology = DockerComposeTopology::new(vec![ + ServiceTopology::new(Service::Caddy, vec![Network::Proxy]), + ServiceTopology::new(Service::Tracker, vec![Network::Database]), + ServiceTopology::new(Service::Prometheus, vec![Network::Metrics]), + ServiceTopology::new(Service::Grafana, vec![Network::Visualization]), + ]); + + 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::new(Service::Tracker, vec![Network::Metrics]), + ServiceTopology::new(Service::Prometheus, vec![Network::Metrics]), + ]); + + 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() { + let topology = DockerComposeTopology::new(vec![]); + + let required = topology.required_networks(); + + assert!(required.is_empty()); + } + + #[test] + fn it_should_return_empty_when_services_have_no_networks() { + let topology = + DockerComposeTopology::new(vec![ServiceTopology::new(Service::Tracker, vec![])]); + + let required = topology.required_networks(); + + assert!(required.is_empty()); + } + } + + mod configuration_combinations { + use super::*; + + #[test] + fn it_should_configure_minimal_deployment() { + // Tracker only, no optional services + let topology = + DockerComposeTopology::new(vec![ServiceTopology::new(Service::Tracker, vec![])]); + + let required = topology.required_networks(); + + assert!(required.is_empty()); + } + + #[test] + fn it_should_configure_deployment_with_mysql() { + let topology = DockerComposeTopology::new(vec![ + ServiceTopology::new(Service::Tracker, vec![Network::Database]), + ServiceTopology::new(Service::MySQL, vec![Network::Database]), + ]); + + 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::new(Service::Tracker, vec![Network::Metrics]), + ServiceTopology::new( + Service::Prometheus, + vec![Network::Metrics, Network::Visualization], + ), + ServiceTopology::new(Service::Grafana, vec![Network::Visualization]), + ]); + + 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::new(Service::Tracker, vec![Network::Database, Network::Metrics]), + ServiceTopology::new(Service::MySQL, vec![Network::Database]), + ServiceTopology::new( + Service::Prometheus, + vec![Network::Metrics, Network::Visualization], + ), + ServiceTopology::new(Service::Grafana, vec![Network::Visualization]), + ]); + + 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::new( + Service::Tracker, + vec![Network::Database, Network::Metrics, Network::Proxy], + ), + ServiceTopology::new(Service::MySQL, vec![Network::Database]), + ServiceTopology::new( + Service::Prometheus, + vec![Network::Metrics, Network::Visualization], + ), + ServiceTopology::new( + Service::Grafana, + vec![Network::Visualization, Network::Proxy], + ), + ServiceTopology::new(Service::Caddy, vec![Network::Proxy]), + ]); + + 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::new(Service::Tracker, vec![Network::Proxy]), + ServiceTopology::new(Service::Caddy, vec![Network::Proxy]), + ]); + + let required = topology.required_networks(); + + assert_eq!(required.len(), 1); + assert!(required.contains(&Network::Proxy)); + } + } +} diff --git a/src/domain/topology/mod.rs b/src/domain/topology/mod.rs index 5ac30156..dabb7d83 100644 --- a/src/domain/topology/mod.rs +++ b/src/domain/topology/mod.rs @@ -7,14 +7,22 @@ //! //! These types represent business concepts with strong typing: //! - Type-safe network references (no string typos) +//! - Type-safe service identification //! - Single source of truth for network names //! - Domain-level invariant enforcement //! //! ## Components //! //! - [`Network`] - Docker Compose network enum representing isolation boundaries +//! - [`Service`] - Docker Compose service enum for type-safe service identification +//! - [`DockerComposeTopology`] - Aggregate that derives required networks from services +//! - [`ServiceTopology`] - Topology information for a single service +pub mod aggregate; pub mod network; +pub mod service; // Re-export main types for convenience +pub use aggregate::{DockerComposeTopology, ServiceTopology}; pub use network::Network; +pub use service::Service; diff --git a/src/domain/topology/network.rs b/src/domain/topology/network.rs index b0a604e3..43dbb0d5 100644 --- a/src/domain/topology/network.rs +++ b/src/domain/topology/network.rs @@ -4,14 +4,31 @@ //! used for service isolation. Each network serves a specific security purpose //! in the deployment topology. //! -//! ## Network Purposes +//! ## Security: Three-Network Segmentation (Defense in Depth) //! -//! | Network | Purpose | Connected Services | -//! |---------|---------|-------------------| -//! | `Database` | Isolates database access | Tracker ↔ `MySQL` | -//! | `Metrics` | Metrics scraping | Tracker ↔ Prometheus | -//! | `Visualization` | Dashboard queries | Prometheus ↔ Grafana | -//! | `Proxy` | TLS termination | Caddy ↔ backend services | +//! Network isolation prevents lateral movement between services and reduces attack surface. +//! Each service is placed in the minimum networks required for its function. +//! +//! ### Network Topology +//! +//! | Network | Purpose | Connected Services | Security Boundary | +//! |---------|---------|-------------------|-------------------| +//! | `Database` | Isolates database access | Tracker ↔ `MySQL` | Only tracker can access database | +//! | `Metrics` | Metrics scraping | Tracker ↔ Prometheus | Prometheus cannot access database | +//! | `Visualization` | Dashboard queries | Prometheus ↔ Grafana | Grafana cannot access tracker directly | +//! | `Proxy` | TLS termination | Caddy ↔ backend services | External traffic goes through Caddy | +//! +//! ### Security Benefits +//! +//! 1. **`MySQL` isolation**: Only tracker has database access (least privilege) +//! 2. **Metrics isolation**: Grafana must query through Prometheus (no direct tracker access) +//! 3. **Lateral movement prevention**: Compromised service cannot access unrelated services +//! 4. **Defense in depth**: Network segmentation + authentication + Docker port bindings + UFW +//! +//! ### References +//! +//! - ADR: `docs/decisions/docker-ufw-firewall-security-strategy.md` +//! - Analysis: `docs/analysis/security/docker-network-segmentation-analysis.md` //! //! ## Usage //! @@ -109,6 +126,29 @@ impl Network { "bridge" } + /// Returns a short description of the network's purpose + /// + /// This description is rendered as a comment in docker-compose.yml + /// to help system administrators understand each network's role. + /// + /// # Examples + /// + /// ```rust + /// use torrust_tracker_deployer_lib::domain::topology::Network; + /// + /// assert_eq!(Network::Database.description(), "Database isolation: Tracker ↔ MySQL"); + /// assert_eq!(Network::Metrics.description(), "Metrics scraping: Tracker ↔ Prometheus"); + /// ``` + #[must_use] + pub fn description(&self) -> &'static str { + match self { + Network::Database => "Database isolation: Tracker ↔ MySQL", + Network::Metrics => "Metrics scraping: Tracker ↔ Prometheus", + Network::Visualization => "Dashboard queries: Prometheus ↔ Grafana", + Network::Proxy => "TLS termination: Caddy ↔ backend services", + } + } + /// Returns all network variants /// /// Useful for iteration when generating the global networks section @@ -310,4 +350,40 @@ mod tests { assert!(set.contains(&Network::Database)); assert!(set.contains(&Network::Metrics)); } + + // ========================================================================== + // Description tests + // ========================================================================== + + #[test] + fn it_should_return_description_for_database_network() { + assert_eq!( + Network::Database.description(), + "Database isolation: Tracker ↔ MySQL" + ); + } + + #[test] + fn it_should_return_description_for_metrics_network() { + assert_eq!( + Network::Metrics.description(), + "Metrics scraping: Tracker ↔ Prometheus" + ); + } + + #[test] + fn it_should_return_description_for_visualization_network() { + assert_eq!( + Network::Visualization.description(), + "Dashboard queries: Prometheus ↔ Grafana" + ); + } + + #[test] + fn it_should_return_description_for_proxy_network() { + assert_eq!( + Network::Proxy.description(), + "TLS termination: Caddy ↔ backend services" + ); + } } diff --git a/src/domain/topology/service.rs b/src/domain/topology/service.rs new file mode 100644 index 00000000..dcab2927 --- /dev/null +++ b/src/domain/topology/service.rs @@ -0,0 +1,207 @@ +//! Docker Compose Service Domain Type +//! +//! This module defines the [`Service`] enum representing services in a +//! Docker Compose deployment. +//! +//! ## Services +//! +//! | Service | Purpose | Description | +//! |---------|---------|-------------| +//! | `Tracker` | Core service | BitTorrent tracker | +//! | `MySQL` | Database | Persistent storage | +//! | `Prometheus` | Metrics | Metrics collection | +//! | `Grafana` | Visualization | Metrics dashboard | +//! | `Caddy` | Proxy | TLS termination | + +use std::fmt; + +use serde::Serialize; + +/// Docker Compose services in the deployment +/// +/// This enum provides type-safe service identification, preventing typos +/// and enabling exhaustive matching in domain logic. +/// +/// # Serialization +/// +/// When serialized, the service outputs its name string (e.g., `"tracker"`), +/// making it directly usable in Tera templates. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize)] +#[serde(rename_all = "lowercase")] +pub enum Service { + /// The core `BitTorrent` tracker service + /// + /// Always present in every deployment. + Tracker, + + /// `MySQL` database service + /// + /// Provides persistent storage when `SQLite` is not used. + #[serde(rename = "mysql")] + MySQL, + + /// Prometheus metrics collection service + /// + /// Scrapes metrics from the tracker. + Prometheus, + + /// Grafana visualization service + /// + /// Displays dashboards with Prometheus data. + Grafana, + + /// Caddy reverse proxy service + /// + /// Provides automatic TLS termination with Let's Encrypt. + Caddy, +} + +impl Service { + /// Returns the service name as used in docker-compose.yml + /// + /// # Examples + /// + /// ```rust + /// use torrust_tracker_deployer_lib::domain::topology::Service; + /// + /// assert_eq!(Service::Tracker.name(), "tracker"); + /// assert_eq!(Service::MySQL.name(), "mysql"); + /// assert_eq!(Service::Prometheus.name(), "prometheus"); + /// assert_eq!(Service::Grafana.name(), "grafana"); + /// assert_eq!(Service::Caddy.name(), "caddy"); + /// ``` + #[must_use] + pub fn name(&self) -> &'static str { + match self { + Service::Tracker => "tracker", + Service::MySQL => "mysql", + Service::Prometheus => "prometheus", + Service::Grafana => "grafana", + Service::Caddy => "caddy", + } + } + + /// Returns all service variants + /// + /// Useful for iteration and validation. + /// + /// # Examples + /// + /// ```rust + /// use torrust_tracker_deployer_lib::domain::topology::Service; + /// + /// let all = Service::all(); + /// assert_eq!(all.len(), 5); + /// ``` + #[must_use] + pub fn all() -> &'static [Service] { + &[ + Service::Tracker, + Service::MySQL, + Service::Prometheus, + Service::Grafana, + Service::Caddy, + ] + } +} + +impl fmt::Display for Service { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.name()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + mod service_names { + use super::*; + + #[test] + fn it_should_return_correct_service_name_for_tracker() { + assert_eq!(Service::Tracker.name(), "tracker"); + } + + #[test] + fn it_should_return_correct_service_name_for_mysql() { + assert_eq!(Service::MySQL.name(), "mysql"); + } + + #[test] + fn it_should_return_correct_service_name_for_prometheus() { + assert_eq!(Service::Prometheus.name(), "prometheus"); + } + + #[test] + fn it_should_return_correct_service_name_for_grafana() { + assert_eq!(Service::Grafana.name(), "grafana"); + } + + #[test] + fn it_should_return_correct_service_name_for_caddy() { + assert_eq!(Service::Caddy.name(), "caddy"); + } + } + + mod display { + use super::*; + + #[test] + fn it_should_display_service_as_name() { + assert_eq!(format!("{}", Service::Tracker), "tracker"); + assert_eq!(format!("{}", Service::MySQL), "mysql"); + assert_eq!(format!("{}", Service::Prometheus), "prometheus"); + assert_eq!(format!("{}", Service::Grafana), "grafana"); + assert_eq!(format!("{}", Service::Caddy), "caddy"); + } + } + + mod all_services { + use super::*; + + #[test] + fn it_should_return_all_five_services() { + let all = Service::all(); + assert_eq!(all.len(), 5); + } + + #[test] + fn it_should_contain_tracker_service() { + assert!(Service::all().contains(&Service::Tracker)); + } + + #[test] + fn it_should_contain_mysql_service() { + assert!(Service::all().contains(&Service::MySQL)); + } + + #[test] + fn it_should_contain_prometheus_service() { + assert!(Service::all().contains(&Service::Prometheus)); + } + + #[test] + fn it_should_contain_grafana_service() { + assert!(Service::all().contains(&Service::Grafana)); + } + + #[test] + fn it_should_contain_caddy_service() { + assert!(Service::all().contains(&Service::Caddy)); + } + } + + mod serialization { + use super::*; + + #[test] + fn it_should_serialize_to_lowercase_name() { + let json = serde_json::to_string(&Service::Tracker).unwrap(); + assert_eq!(json, "\"tracker\""); + + let json = serde_json::to_string(&Service::MySQL).unwrap(); + assert_eq!(json, "\"mysql\""); + } + } +} 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 1e8b7ea2..b7562b1c 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 @@ -1,13 +1,17 @@ //! Builder for `DockerComposeContext` +use std::collections::HashSet; + // Internal crate use crate::domain::grafana::GrafanaConfig; use crate::domain::prometheus::PrometheusConfig; +use crate::domain::topology::Network; use super::caddy::CaddyServiceConfig; use super::database::{DatabaseConfig, MysqlSetupConfig, DRIVER_MYSQL, DRIVER_SQLITE}; use super::grafana::GrafanaServiceConfig; use super::mysql::MysqlServiceConfig; +use super::network_definition::NetworkDefinition; use super::prometheus::PrometheusServiceConfig; use super::{DockerComposeContext, TrackerServiceConfig}; @@ -126,6 +130,15 @@ impl DockerComposeContextBuilder { None }; + // Derive required networks from all service configurations + let required_networks = Self::derive_required_networks( + &self.tracker, + prometheus.as_ref(), + grafana.as_ref(), + caddy.as_ref(), + mysql.as_ref(), + ); + DockerComposeContext { database: self.database, tracker: self.tracker, @@ -133,6 +146,44 @@ impl DockerComposeContextBuilder { grafana, caddy, mysql, + required_networks, + } + } + + /// Derives required networks from all service configurations + /// + /// 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>, + ) -> Vec { + let mut networks: HashSet = HashSet::new(); + + // Collect from tracker (always present) + networks.extend(tracker.networks.iter().copied()); + + // Collect from optional services + if let Some(prom) = prometheus { + networks.extend(prom.networks.iter().copied()); + } + if let Some(graf) = grafana { + networks.extend(graf.networks.iter().copied()); } + if let Some(cad) = caddy { + networks.extend(cad.networks.iter().copied()); + } + if let Some(my) = mysql { + networks.extend(my.networks.iter().copied()); + } + + // Sort for deterministic output (alphabetically by name) + let mut result: Vec = + networks.into_iter().map(NetworkDefinition::from).collect(); + result.sort_by(|a, b| a.name().cmp(b.name())); + result } } 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 ddd6ed50..23108646 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 @@ -12,6 +12,7 @@ mod caddy; mod database; mod grafana; mod mysql; +mod network_definition; mod prometheus; mod tracker; @@ -21,6 +22,7 @@ pub use caddy::CaddyServiceConfig; pub use database::{DatabaseConfig, MysqlSetupConfig}; pub use grafana::GrafanaServiceConfig; pub use mysql::MysqlServiceConfig; +pub use network_definition::NetworkDefinition; pub use prometheus::PrometheusServiceConfig; pub use tracker::{TrackerPorts, TrackerServiceConfig}; @@ -54,6 +56,12 @@ pub struct DockerComposeContext { /// This is separate from `MysqlSetupConfig` which contains credentials. #[serde(skip_serializing_if = "Option::is_none")] pub mysql: Option, + /// All networks required by enabled services (derived) + /// + /// This list is computed from the networks used by all services. + /// The template should iterate over this for the global `networks:` section + /// instead of using conditionals. + pub required_networks: Vec, } impl DockerComposeContext { @@ -138,6 +146,15 @@ impl DockerComposeContext { pub fn mysql(&self) -> Option<&MysqlServiceConfig> { self.mysql.as_ref() } + + /// Get all networks required by enabled services + /// + /// This list is derived from all service network configurations and + /// should be used in the template for the global `networks:` section. + #[must_use] + pub fn required_networks(&self) -> &[NetworkDefinition] { + &self.required_networks + } } #[cfg(test)] @@ -368,4 +385,290 @@ mod tests { vec![Network::Visualization, Network::Proxy] ); } + + // P2.2: Required networks derivation tests + + mod required_networks { + use super::*; + use crate::domain::grafana::GrafanaConfig; + + #[test] + fn it_should_have_empty_required_networks_for_minimal_deployment() { + let tracker = test_tracker_config(); + let context = DockerComposeContext::builder(tracker).build(); + + assert!(context.required_networks().is_empty()); + } + + #[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 mysql_config = MysqlSetupConfig { + root_password: "root".to_string(), + database: "db".to_string(), + user: "user".to_string(), + password: "pass".to_string(), + port: 3306, + }; + let context = DockerComposeContext::builder(tracker) + .with_mysql(mysql_config) + .build(); + + let network_names: Vec<&str> = context + .required_networks() + .iter() + .map(NetworkDefinition::name) + .collect(); + assert!(network_names.contains(&"database_network")); + } + + #[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 prometheus_config = + PrometheusConfig::new(std::num::NonZeroU32::new(30).expect("30 is non-zero")); + let context = DockerComposeContext::builder(tracker) + .with_prometheus(prometheus_config) + .build(); + + let network_names: Vec<&str> = context + .required_networks() + .iter() + .map(NetworkDefinition::name) + .collect(); + assert!(network_names.contains(&"metrics_network")); + } + + #[test] + fn it_should_include_visualization_network_when_grafana_enabled() { + let tracker = test_tracker_config(); + let grafana_config = + GrafanaConfig::new("admin".to_string(), "password".to_string(), None, false); + let context = DockerComposeContext::builder(tracker) + .with_grafana(grafana_config) + .build(); + + let network_names: Vec<&str> = context + .required_networks() + .iter() + .map(NetworkDefinition::name) + .collect(); + assert!(network_names.contains(&"visualization_network")); + } + + #[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 context = DockerComposeContext::builder(tracker).with_caddy().build(); + + let network_names: Vec<&str> = context + .required_networks() + .iter() + .map(NetworkDefinition::name) + .collect(); + assert!(network_names.contains(&"proxy_network")); + } + + #[test] + fn it_should_not_include_database_network_when_mysql_disabled() { + let tracker = test_tracker_config(); + let context = DockerComposeContext::builder(tracker).build(); + + let network_names: Vec<&str> = context + .required_networks() + .iter() + .map(NetworkDefinition::name) + .collect(); + assert!(!network_names.contains(&"database_network")); + } + + #[test] + fn it_should_not_include_metrics_network_when_prometheus_disabled() { + let tracker = test_tracker_config(); + let context = DockerComposeContext::builder(tracker).build(); + + let network_names: Vec<&str> = context + .required_networks() + .iter() + .map(NetworkDefinition::name) + .collect(); + assert!(!network_names.contains(&"metrics_network")); + } + + #[test] + fn it_should_not_include_visualization_network_when_grafana_disabled() { + let tracker = test_tracker_config(); + let context = DockerComposeContext::builder(tracker).build(); + + let network_names: Vec<&str> = context + .required_networks() + .iter() + .map(NetworkDefinition::name) + .collect(); + assert!(!network_names.contains(&"visualization_network")); + } + + #[test] + fn it_should_not_include_proxy_network_when_caddy_disabled() { + let tracker = test_tracker_config(); + let context = DockerComposeContext::builder(tracker).build(); + + let network_names: Vec<&str> = context + .required_networks() + .iter() + .map(NetworkDefinition::name) + .collect(); + assert!(!network_names.contains(&"proxy_network")); + } + + #[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 mysql_config = MysqlSetupConfig { + root_password: "root".to_string(), + database: "db".to_string(), + user: "user".to_string(), + password: "pass".to_string(), + port: 3306, + }; + let prometheus_config = + PrometheusConfig::new(std::num::NonZeroU32::new(30).expect("30 is non-zero")); + let grafana_config = + GrafanaConfig::new("admin".to_string(), "password".to_string(), None, true); + let context = DockerComposeContext::builder(tracker) + .with_mysql(mysql_config) + .with_prometheus(prometheus_config) + .with_grafana(grafana_config) + .with_caddy() + .build(); + + let network_names: Vec<&str> = context + .required_networks() + .iter() + .map(NetworkDefinition::name) + .collect(); + + assert_eq!(network_names.len(), 4); + assert!(network_names.contains(&"database_network")); + assert!(network_names.contains(&"metrics_network")); + assert!(network_names.contains(&"visualization_network")); + assert!(network_names.contains(&"proxy_network")); + } + + #[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 mysql_config = MysqlSetupConfig { + root_password: "root".to_string(), + database: "db".to_string(), + user: "user".to_string(), + password: "pass".to_string(), + port: 3306, + }; + let prometheus_config = + PrometheusConfig::new(std::num::NonZeroU32::new(30).expect("30 is non-zero")); + let grafana_config = + GrafanaConfig::new("admin".to_string(), "password".to_string(), None, true); + let context = DockerComposeContext::builder(tracker) + .with_mysql(mysql_config) + .with_prometheus(prometheus_config) + .with_grafana(grafana_config) + .with_caddy() + .build(); + + let network_names: Vec<&str> = context + .required_networks() + .iter() + .map(NetworkDefinition::name) + .collect(); + + // Alphabetical order + assert_eq!( + network_names, + vec![ + "database_network", + "metrics_network", + "proxy_network", + "visualization_network" + ] + ); + } + + #[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 prometheus_config = + PrometheusConfig::new(std::num::NonZeroU32::new(30).expect("30 is non-zero")); + let grafana_config = + GrafanaConfig::new("admin".to_string(), "password".to_string(), None, false); + let context = DockerComposeContext::builder(tracker) + .with_prometheus(prometheus_config) + .with_grafana(grafana_config) + .build(); + + let network_names: Vec<&str> = context + .required_networks() + .iter() + .map(NetworkDefinition::name) + .collect(); + + // visualization_network appears only once despite being used by both + assert_eq!( + network_names + .iter() + .filter(|n| **n == "visualization_network") + .count(), + 1 + ); + } + } } diff --git a/src/infrastructure/templating/docker_compose/template/wrappers/docker_compose/context/network_definition.rs b/src/infrastructure/templating/docker_compose/template/wrappers/docker_compose/context/network_definition.rs new file mode 100644 index 00000000..f256d88b --- /dev/null +++ b/src/infrastructure/templating/docker_compose/template/wrappers/docker_compose/context/network_definition.rs @@ -0,0 +1,127 @@ +//! Network definition for Docker Compose template context +//! +//! This module contains the [`NetworkDefinition`] type used for the global +//! `networks:` section in docker-compose.yml. + +use serde::Serialize; + +use crate::domain::topology::Network; + +/// A network definition for the global `networks:` section +/// +/// This type is used in the template context to render the networks section. +/// It contains the network name and driver as strings for direct template use. +/// +/// # Examples +/// +/// ```rust +/// use torrust_tracker_deployer_lib::domain::topology::Network; +/// use torrust_tracker_deployer_lib::infrastructure::templating::docker_compose::template::wrappers::docker_compose::NetworkDefinition; +/// +/// let definition = NetworkDefinition::from(Network::Database); +/// assert_eq!(definition.name(), "database_network"); +/// assert_eq!(definition.driver(), "bridge"); +/// assert_eq!(definition.description(), "Database isolation: Tracker ↔ MySQL"); +/// ``` +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct NetworkDefinition { + /// The network name as used in docker-compose.yml + name: String, + /// The Docker network driver (e.g., "bridge") + driver: String, + /// A short description of the network's purpose (rendered as comment) + description: String, +} + +impl NetworkDefinition { + /// Returns the network name + #[must_use] + pub fn name(&self) -> &str { + &self.name + } + + /// Returns the network driver + #[must_use] + pub fn driver(&self) -> &str { + &self.driver + } + + /// Returns the network description + #[must_use] + pub fn description(&self) -> &str { + &self.description + } +} + +impl From for NetworkDefinition { + fn from(network: Network) -> Self { + Self { + name: network.name().to_string(), + driver: network.driver().to_string(), + description: network.description().to_string(), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn it_should_create_definition_from_database_network() { + let definition = NetworkDefinition::from(Network::Database); + + assert_eq!(definition.name(), "database_network"); + assert_eq!(definition.driver(), "bridge"); + assert_eq!( + definition.description(), + "Database isolation: Tracker ↔ MySQL" + ); + } + + #[test] + fn it_should_create_definition_from_metrics_network() { + let definition = NetworkDefinition::from(Network::Metrics); + + assert_eq!(definition.name(), "metrics_network"); + assert_eq!(definition.driver(), "bridge"); + assert_eq!( + definition.description(), + "Metrics scraping: Tracker ↔ Prometheus" + ); + } + + #[test] + fn it_should_create_definition_from_visualization_network() { + let definition = NetworkDefinition::from(Network::Visualization); + + assert_eq!(definition.name(), "visualization_network"); + assert_eq!(definition.driver(), "bridge"); + assert_eq!( + definition.description(), + "Dashboard queries: Prometheus ↔ Grafana" + ); + } + + #[test] + fn it_should_create_definition_from_proxy_network() { + let definition = NetworkDefinition::from(Network::Proxy); + + assert_eq!(definition.name(), "proxy_network"); + assert_eq!(definition.driver(), "bridge"); + assert_eq!( + definition.description(), + "TLS termination: Caddy ↔ backend services" + ); + } + + #[test] + fn it_should_serialize_network_definition() { + let definition = NetworkDefinition::from(Network::Database); + + let json = serde_json::to_string(&definition).unwrap(); + assert!(json.contains("\"name\":\"database_network\"")); + assert!(json.contains("\"driver\":\"bridge\"")); + assert!(json.contains("\"description\":")); + } +} 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 567ff210..ab90a246 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 @@ -2,7 +2,7 @@ pub mod context; pub mod template; pub use context::{ - DockerComposeContext, DockerComposeContextBuilder, MysqlSetupConfig, TrackerPorts, - TrackerServiceConfig, + DockerComposeContext, DockerComposeContextBuilder, MysqlSetupConfig, NetworkDefinition, + TrackerPorts, TrackerServiceConfig, }; pub use template::DockerComposeTemplate; diff --git a/templates/docker-compose/docker-compose.yml.tera b/templates/docker-compose/docker-compose.yml.tera index 8e01293d..b4b8fb6d 100644 --- a/templates/docker-compose/docker-compose.yml.tera +++ b/templates/docker-compose/docker-compose.yml.tera @@ -188,47 +188,14 @@ services: start_period: 30s {%- endif %} -# SECURITY: Three-Network Segmentation (Defense in Depth) -# ========================================================= -# Network isolation prevents lateral movement between services and reduces attack surface. -# Each service is placed in the minimum networks required for its function. -# -# Network Topology: -# database_network: Tracker ↔ MySQL -# - Only tracker can access MySQL (reduces attack vectors from 3 services to 1) -# - Prometheus/Grafana cannot access database even if compromised -# -# metrics_network: Tracker ↔ Prometheus -# - Prometheus scrapes metrics from tracker -# - Grafana cannot directly access tracker metrics -# -# visualization_network: Prometheus ↔ Grafana -# - Grafana queries Prometheus as data source -# - Grafana cannot access tracker or MySQL directly -# -# Security Benefits: -# 1. MySQL isolation: Only tracker has database access (least privilege) -# 2. Metrics isolation: Grafana must query through Prometheus (no direct tracker access) -# 3. Lateral movement prevention: Compromised service cannot access unrelated services -# 4. Defense in depth: Network segmentation + authentication + Docker port bindings + UFW -# -# See ADR: docs/decisions/docker-ufw-firewall-security-strategy.md -# See Analysis: docs/analysis/security/docker-network-segmentation-analysis.md +# Networks are derived from service configurations in Rust code. +# See: src/domain/topology/network.rs for security rationale. +{%- if required_networks | length > 0 %} networks: -{%- if mysql %} - database_network: - driver: bridge -{%- endif %} -{%- if prometheus %} - metrics_network: - driver: bridge -{%- endif %} -{%- if grafana %} - visualization_network: - driver: bridge -{%- endif %} -{%- if caddy %} - proxy_network: - driver: bridge +{%- for net in required_networks %} + # {{ net.description }} + {{ net.name }}: + driver: {{ net.driver }} +{%- endfor %} {%- endif %}