Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions project-words.txt
Original file line number Diff line number Diff line change
Expand Up @@ -303,3 +303,5 @@ zeroize
ключ
конфиг
файл
Datagram
connectionless
22 changes: 19 additions & 3 deletions schemas/environment-config.json
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,17 @@
"admin_password"
]
},
"HealthCheckApiSection": {
"type": "object",
"properties": {
"bind_address": {
"type": "string"
}
},
"required": [
"bind_address"
]
},
"HetznerProviderSection": {
"description": "Hetzner-specific configuration section\n\nUses raw `String` fields for JSON deserialization. Convert to domain\n`HetznerConfig` via `ProviderSection::to_provider_config()`.\n\n# Examples\n\n```rust\nuse torrust_tracker_deployer_lib::application::command_handlers::create::config::HetznerProviderSection;\n\nlet section = HetznerProviderSection {\n api_token: \"your-api-token\".to_string(),\n server_type: \"cx22\".to_string(),\n location: \"nbg1\".to_string(),\n image: \"ubuntu-24.04\".to_string(),\n};\n```",
"type": "object",
Expand Down Expand Up @@ -320,13 +331,17 @@
]
},
"TrackerSection": {
"description": "Tracker configuration section (application DTO)\n\nAggregates all tracker configuration sections: core, UDP trackers,\nHTTP trackers, and HTTP API.\n\n# Examples\n\n```json\n{\n \"core\": {\n \"database\": {\n \"driver\": \"sqlite3\",\n \"database_name\": \"tracker.db\"\n },\n \"private\": false\n },\n \"udp_trackers\": [\n { \"bind_address\": \"0.0.0.0:6969\" }\n ],\n \"http_trackers\": [\n { \"bind_address\": \"0.0.0.0:7070\" }\n ],\n \"http_api\": {\n \"bind_address\": \"0.0.0.0:1212\",\n \"admin_token\": \"MyAccessToken\"\n }\n}\n```",
"description": "Tracker configuration section (application DTO)\n\nAggregates all tracker configuration sections: core, UDP trackers,\nHTTP trackers, and HTTP API.\n\n# Examples\n\n```json\n{\n \"core\": {\n \"database\": {\n \"driver\": \"sqlite3\",\n \"database_name\": \"tracker.db\"\n },\n \"private\": false\n },\n \"udp_trackers\": [\n { \"bind_address\": \"0.0.0.0:6969\" }\n ],\n \"http_trackers\": [\n { \"bind_address\": \"0.0.0.0:7070\" }\n ],\n \"http_api\": {\n \"bind_address\": \"0.0.0.0:1212\",\n \"admin_token\": \"MyAccessToken\"\n },\n \"health_check_api\": {\n \"bind_address\": \"127.0.0.1:1313\"\n }\n}\n```",
"type": "object",
"properties": {
"core": {
"description": "Core tracker configuration (database, privacy mode)",
"$ref": "#/$defs/TrackerCoreSection"
},
"health_check_api": {
"description": "Health Check API configuration",
"$ref": "#/$defs/HealthCheckApiSection"
},
"http_api": {
"description": "HTTP API configuration",
"$ref": "#/$defs/HttpApiSection"
Expand All @@ -350,7 +365,8 @@
"core",
"udp_trackers",
"http_trackers",
"http_api"
"http_api",
"health_check_api"
]
},
"UdpTrackerSection": {
Expand All @@ -365,4 +381,4 @@
]
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,9 @@ use super::tracker::TrackerSection;
/// "http_api": {
/// "bind_address": "0.0.0.0:1212",
/// "admin_token": "MyAccessToken"
/// },
/// "health_check_api": {
/// "bind_address": "127.0.0.1:1313"
/// }
/// },
/// "prometheus": {
Expand Down Expand Up @@ -417,6 +420,7 @@ impl EnvironmentCreationConfig {
bind_address: "0.0.0.0:1212".to_string(),
admin_token: "MyAccessToken".to_string(),
},
health_check_api: super::tracker::HealthCheckApiSection::default(),
},
prometheus: Some(PrometheusSection::default()),
grafana: Some(GrafanaSection::default()),
Expand Down Expand Up @@ -572,6 +576,9 @@ mod tests {
"http_api": {
"bind_address": "0.0.0.0:1212",
"admin_token": "MyAccessToken"
},
"health_check_api": {
"bind_address": "127.0.0.1:1313"
}
}
}"#;
Expand Down Expand Up @@ -633,6 +640,9 @@ mod tests {
"http_api": {
"bind_address": "0.0.0.0:1212",
"admin_token": "MyAccessToken"
},
"health_check_api": {
"bind_address": "127.0.0.1:1313"
}
}
}"#;
Expand Down
26 changes: 26 additions & 0 deletions src/application/command_handlers/create/config/errors.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
use std::path::PathBuf;
use thiserror::Error;

use crate::domain::tracker::TrackerConfigError;
use crate::domain::EnvironmentNameError;
use crate::domain::ProfileNameError;
use crate::shared::UsernameError;
Expand Down Expand Up @@ -105,6 +106,10 @@ pub enum CreateConfigError {
/// Invalid Prometheus configuration
#[error("Invalid Prometheus configuration: {0}")]
InvalidPrometheusConfig(String),

/// Tracker configuration validation failed
#[error("Tracker configuration validation failed: {0}")]
TrackerConfigValidation(#[from] TrackerConfigError),
}

impl CreateConfigError {
Expand Down Expand Up @@ -424,6 +429,27 @@ impl CreateConfigError {
Note: The template automatically adds the 's' suffix (e.g., 15 becomes '15s'),\n\
so you only need to specify the numeric value."
}
Self::TrackerConfigValidation(_) => {
"Tracker configuration validation failed.\n\
\n\
This error indicates a problem with the tracker service configuration,\n\
typically related to socket address (IP:Port:Protocol) conflicts.\n\
\n\
The error message above provides specific details about:\n\
- Which services are in conflict\n\
- The conflicting socket addresses\n\
- Why the configuration is invalid\n\
\n\
Common issues:\n\
1. Multiple services on same TCP port (HTTP tracker + API)\n\
2. Duplicate UDP tracker ports\n\
3. Duplicate HTTP tracker ports\n\
\n\
Note: UDP and TCP can share the same port (different protocols),\n\
but this is not recommended for clarity.\n\
\n\
Related: docs/external-issues/tracker/udp-tcp-port-sharing-allowed.md"
}
}
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
use std::net::SocketAddr;

use schemars::JsonSchema;
use serde::{Deserialize, Serialize};

use crate::application::command_handlers::create::config::errors::CreateConfigError;
use crate::domain::tracker::HealthCheckApiConfig;

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, JsonSchema)]
pub struct HealthCheckApiSection {
pub bind_address: String,
}

impl HealthCheckApiSection {
/// Converts this DTO to a domain `HealthCheckApiConfig`
///
/// # Errors
///
/// Returns `CreateConfigError::InvalidBindAddress` if the bind address cannot be parsed as a valid IP:PORT combination.
/// Returns `CreateConfigError::DynamicPortNotSupported` if port 0 (dynamic port assignment) is specified.
pub fn to_health_check_api_config(&self) -> Result<HealthCheckApiConfig, CreateConfigError> {
// Validate that the bind address can be parsed as SocketAddr
let bind_address = self.bind_address.parse::<SocketAddr>().map_err(|e| {
CreateConfigError::InvalidBindAddress {
address: self.bind_address.clone(),
source: e,
}
})?;

// Reject port 0 (dynamic port assignment)
if bind_address.port() == 0 {
return Err(CreateConfigError::DynamicPortNotSupported {
bind_address: self.bind_address.clone(),
});
}

Ok(HealthCheckApiConfig { bind_address })
}
}

impl Default for HealthCheckApiSection {
fn default() -> Self {
Self {
bind_address: "127.0.0.1:1313".to_string(),
}
}
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn it_should_convert_to_domain_config_when_bind_address_is_valid() {
let section = HealthCheckApiSection {
bind_address: "127.0.0.1:1313".to_string(),
};

let config = section.to_health_check_api_config().unwrap();

assert_eq!(
config.bind_address,
"127.0.0.1:1313".parse::<SocketAddr>().unwrap()
);
}

#[test]
fn it_should_fail_when_bind_address_is_invalid() {
let section = HealthCheckApiSection {
bind_address: "invalid".to_string(),
};

let result = section.to_health_check_api_config();

assert!(result.is_err());
assert!(matches!(
result.unwrap_err(),
CreateConfigError::InvalidBindAddress { .. }
));
}

#[test]
fn it_should_reject_dynamic_port_assignment() {
let section = HealthCheckApiSection {
bind_address: "0.0.0.0:0".to_string(),
};

let result = section.to_health_check_api_config();

assert!(result.is_err());
assert!(matches!(
result.unwrap_err(),
CreateConfigError::DynamicPortNotSupported { .. }
));
}

#[test]
fn it_should_allow_ipv6_addresses() {
let section = HealthCheckApiSection {
bind_address: "[::1]:1313".to_string(),
};

let result = section.to_health_check_api_config();

assert!(result.is_ok());
}

#[test]
fn it_should_allow_any_port_except_zero() {
let section = HealthCheckApiSection {
bind_address: "127.0.0.1:8080".to_string(),
};

let result = section.to_health_check_api_config();

assert!(result.is_ok());
}

#[test]
fn it_should_provide_default_localhost_1313() {
let section = HealthCheckApiSection::default();

assert_eq!(section.bind_address, "127.0.0.1:1313");
}
}
2 changes: 2 additions & 0 deletions src/application/command_handlers/create/config/tracker/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,14 @@
//! environment creation. These types use raw primitives (String) for
//! JSON deserialization and convert to rich domain types (`SocketAddr`).

mod health_check_api_section;
mod http_api_section;
mod http_tracker_section;
mod tracker_core_section;
mod tracker_section;
mod udp_tracker_section;

pub use health_check_api_section::HealthCheckApiSection;
pub use http_api_section::HttpApiSection;
pub use http_tracker_section::HttpTrackerSection;
pub use tracker_core_section::{DatabaseSection, TrackerCoreSection};
Expand Down
Loading
Loading