use std::net::IpAddr;
use std::path::PathBuf;
use std::time::Duration;
use reqwest::ClientBuilder;
use tracing::{info, instrument, warn};
use crate::infrastructure::remote_actions::{RemoteAction, RemoteActionError};
use crate::shared::ServiceEndpoint;
const DEFAULT_DEPLOY_DIR: &str = "/opt/torrust";
const REQUEST_TIMEOUT_SECS: u64 = 10;
pub struct RunningServicesValidator {
deploy_dir: PathBuf,
tracker_api_endpoint: ServiceEndpoint,
http_tracker_endpoints: Vec<ServiceEndpoint>,
}
impl RunningServicesValidator {
#[must_use]
pub fn new(
tracker_api_endpoint: ServiceEndpoint,
http_tracker_endpoints: Vec<ServiceEndpoint>,
) -> Self {
Self {
deploy_dir: PathBuf::from(DEFAULT_DEPLOY_DIR),
tracker_api_endpoint,
http_tracker_endpoints,
}
}
#[must_use]
pub fn with_deploy_dir(
deploy_dir: PathBuf,
tracker_api_endpoint: ServiceEndpoint,
http_tracker_endpoints: Vec<ServiceEndpoint>,
) -> Self {
Self {
deploy_dir,
tracker_api_endpoint,
http_tracker_endpoints,
}
}
async fn validate_external_accessibility(&self) -> Result<(), RemoteActionError> {
self.check_endpoint(&self.tracker_api_endpoint, "Tracker API")
.await?;
for (idx, endpoint) in self.http_tracker_endpoints.iter().enumerate() {
let name = format!("HTTP Tracker {}", idx + 1);
self.check_endpoint(endpoint, &name).await?;
}
Ok(())
}
async fn check_endpoint(
&self,
endpoint: &ServiceEndpoint,
service_name: &str,
) -> Result<(), RemoteActionError> {
let url = endpoint.url();
if endpoint.uses_tls() {
info!(
action = "running_services_validation",
check = "service_external",
service = service_name,
url = %url,
domain = ?endpoint.domain(),
resolve_to = %endpoint.server_ip(),
"Testing HTTPS endpoint (resolving domain to IP locally)"
);
} else {
info!(
action = "running_services_validation",
check = "service_external",
service = service_name,
url = %url,
"Testing HTTP endpoint"
);
}
let response = self.make_request(endpoint).await?;
if !response.status().is_success() {
return Err(RemoteActionError::ValidationFailed {
action_name: self.name().to_string(),
message: format!(
"{service_name} returned HTTP {}: {}. Service may not be healthy.",
response.status(),
response.status().canonical_reason().unwrap_or("Unknown")
),
});
}
info!(
action = "running_services_validation",
check = "service_external",
service = service_name,
url = %url,
status = "success",
"{service_name} health check passed"
);
Ok(())
}
async fn make_request(
&self,
endpoint: &ServiceEndpoint,
) -> Result<reqwest::Response, RemoteActionError> {
let url = endpoint.url();
let mut client_builder =
ClientBuilder::new().timeout(Duration::from_secs(REQUEST_TIMEOUT_SECS));
if let Some(domain) = endpoint.domain() {
client_builder = client_builder.resolve(domain, endpoint.socket_addr());
if endpoint.is_local_domain() {
warn!(
action = "running_services_validation",
domain = domain,
"Accepting self-signed certificates for .local domain"
);
client_builder = client_builder.danger_accept_invalid_certs(true);
}
}
let client = client_builder
.build()
.map_err(|e| RemoteActionError::ValidationFailed {
action_name: self.name().to_string(),
message: format!("Failed to build HTTP client: {e}"),
})?;
client.get(url.clone()).send().await.map_err(|e| {
let help_message = if endpoint.uses_tls() {
format!(
"HTTPS request to '{url}' failed: {e}. \
Check that Caddy is running and port 443 is open. \
Domain '{}' was resolved to {} for testing.",
endpoint.domain().unwrap_or("unknown"),
endpoint.server_ip()
)
} else {
format!(
"HTTP request to '{url}' failed: {e}. \
Check that service is running and firewall allows port {}.",
endpoint.port()
)
};
RemoteActionError::ValidationFailed {
action_name: self.name().to_string(),
message: help_message,
}
})
}
}
impl RemoteAction for RunningServicesValidator {
fn name(&self) -> &'static str {
"running-services-validation"
}
#[instrument(
name = "running_services_validation",
skip(self),
fields(
action_type = "validation",
component = "running_services",
server_ip = %server_ip,
deploy_dir = %self.deploy_dir.display()
)
)]
async fn execute(&self, server_ip: &IpAddr) -> Result<(), RemoteActionError> {
let _ = server_ip;
info!(
action = "running_services_validation",
deploy_dir = %self.deploy_dir.display(),
"Validating Docker Compose services are running via external accessibility"
);
self.validate_external_accessibility().await?;
info!(
action = "running_services_validation",
status = "success",
"Running services validation completed successfully"
);
Ok(())
}
}
#[cfg(test)]
mod tests {
use std::net::{IpAddr, SocketAddr};
use std::path::PathBuf;
use crate::shared::DomainName;
use super::*;
fn test_ip() -> IpAddr {
"10.0.0.1".parse().unwrap()
}
fn test_socket_addr(port: u16) -> SocketAddr {
SocketAddr::new(test_ip(), port)
}
#[test]
fn it_should_use_default_deploy_dir_when_not_specified() {
assert_eq!(DEFAULT_DEPLOY_DIR, "/opt/torrust");
}
#[test]
fn it_should_return_correct_action_name_when_queried() {
assert_eq!("running-services-validation", "running-services-validation");
}
#[test]
fn it_should_create_validator_with_http_endpoints() {
let api_endpoint =
ServiceEndpoint::http(test_socket_addr(1212), "/api/health_check").unwrap();
let tracker_endpoints =
vec![ServiceEndpoint::http(test_socket_addr(7070), "/health_check").unwrap()];
let validator = RunningServicesValidator::new(api_endpoint.clone(), tracker_endpoints);
assert_eq!(validator.tracker_api_endpoint, api_endpoint);
assert_eq!(validator.http_tracker_endpoints.len(), 1);
}
#[test]
fn it_should_create_validator_with_https_endpoints() {
let domain = DomainName::new("api.tracker.local").unwrap();
let api_endpoint = ServiceEndpoint::https(&domain, "/api/health_check", test_ip()).unwrap();
let tracker_endpoints = vec![];
let validator = RunningServicesValidator::new(api_endpoint.clone(), tracker_endpoints);
assert!(validator.tracker_api_endpoint.uses_tls());
}
#[test]
fn it_should_create_validator_with_mixed_endpoints() {
let domain = DomainName::new("api.tracker.local").unwrap();
let api_endpoint = ServiceEndpoint::https(&domain, "/api/health_check", test_ip()).unwrap();
let tracker_endpoints = vec![
ServiceEndpoint::http(test_socket_addr(7070), "/health_check").unwrap(),
ServiceEndpoint::http(test_socket_addr(7071), "/health_check").unwrap(),
];
let validator = RunningServicesValidator::new(api_endpoint, tracker_endpoints);
assert!(validator.tracker_api_endpoint.uses_tls());
assert!(!validator.http_tracker_endpoints[0].uses_tls());
assert!(!validator.http_tracker_endpoints[1].uses_tls());
}
#[test]
fn it_should_accept_empty_tracker_endpoints() {
let api_endpoint =
ServiceEndpoint::http(test_socket_addr(1212), "/api/health_check").unwrap();
let validator = RunningServicesValidator::new(api_endpoint, vec![]);
assert_eq!(validator.http_tracker_endpoints.len(), 0);
}
#[test]
fn it_should_use_custom_deploy_dir() {
let api_endpoint =
ServiceEndpoint::http(test_socket_addr(1212), "/api/health_check").unwrap();
let validator = RunningServicesValidator::with_deploy_dir(
PathBuf::from("/custom/path"),
api_endpoint,
vec![],
);
assert_eq!(validator.deploy_dir, PathBuf::from("/custom/path"));
}
}