160 lines
4.7 KiB
Rust
Raw Normal View History

use std::time::Duration;
use anyhow::{Context as _, anyhow};
use reqwest::Url;
use testing_framework_core::{
2026-01-26 16:36:51 +01:00
adjust_timeout, scenario::http_probe::NODE_ROLE, topology::generation::GeneratedTopology,
};
use tokio::{process::Command, time::timeout};
use tracing::{debug, info};
use url::ParseError;
use crate::{
errors::{ComposeRunnerError, StackReadinessError},
2025-12-10 15:26:34 +01:00
infrastructure::environment::StackEnvironment,
};
const COMPOSE_PORT_DISCOVERY_TIMEOUT: Duration = Duration::from_secs(30);
/// Host ports mapped for a single node.
#[derive(Clone, Debug)]
pub struct NodeHostPorts {
pub api: u16,
pub testing: u16,
}
2026-01-26 08:26:15 +01:00
/// All host port mappings for nodes.
#[derive(Clone, Debug)]
pub struct HostPortMapping {
2026-01-26 08:26:15 +01:00
pub nodes: Vec<NodeHostPorts>,
}
impl HostPortMapping {
2026-01-26 08:26:15 +01:00
/// Returns API ports for all nodes.
pub fn node_api_ports(&self) -> Vec<u16> {
self.nodes.iter().map(|ports| ports.api).collect()
}
}
/// Resolve host ports for all nodes from docker compose.
pub async fn discover_host_ports(
environment: &StackEnvironment,
descriptors: &GeneratedTopology,
) -> Result<HostPortMapping, ComposeRunnerError> {
debug!(
compose_file = %environment.compose_path().display(),
project = environment.project_name(),
2026-01-26 08:26:15 +01:00
nodes = descriptors.nodes().len(),
"resolving compose host ports"
);
2026-01-26 08:26:15 +01:00
let mut nodes = Vec::new();
for node in descriptors.nodes() {
let service = node_identifier(node.index());
let api = resolve_service_port(environment, &service, node.api_port()).await?;
let testing = resolve_service_port(environment, &service, node.testing_http_port()).await?;
2026-01-26 08:26:15 +01:00
nodes.push(NodeHostPorts { api, testing });
}
2026-01-26 08:26:15 +01:00
let mapping = HostPortMapping { nodes };
info!(
2026-01-26 08:26:15 +01:00
node_ports = ?mapping.nodes,
"compose host ports resolved"
);
Ok(mapping)
}
async fn resolve_service_port(
environment: &StackEnvironment,
service: &str,
container_port: u16,
) -> Result<u16, ComposeRunnerError> {
let mut cmd = Command::new("docker");
cmd.arg("compose")
.arg("-f")
.arg(environment.compose_path())
.arg("-p")
.arg(environment.project_name())
.arg("port")
.arg(service)
.arg(container_port.to_string())
.current_dir(environment.root());
let output = timeout(adjust_timeout(COMPOSE_PORT_DISCOVERY_TIMEOUT), cmd.output())
.await
.map_err(|_| ComposeRunnerError::PortDiscovery {
service: service.to_owned(),
container_port,
source: anyhow!("docker compose port timed out"),
})?
.with_context(|| format!("running docker compose port {service} {container_port}"))
.map_err(|source| ComposeRunnerError::PortDiscovery {
service: service.to_owned(),
container_port,
source,
})?;
if !output.status.success() {
return Err(ComposeRunnerError::PortDiscovery {
service: service.to_owned(),
container_port,
source: anyhow!("docker compose port exited with {}", output.status),
});
}
let stdout = String::from_utf8_lossy(&output.stdout);
for line in stdout.lines() {
let line = line.trim();
if line.is_empty() {
continue;
}
if let Some(port_str) = line.rsplit(':').next()
&& let Ok(port) = port_str.trim().parse::<u16>()
{
return Ok(port);
}
}
Err(ComposeRunnerError::PortDiscovery {
service: service.to_owned(),
container_port,
source: anyhow!("unable to parse docker compose port output: {stdout}"),
})
}
/// Wait for remote readiness using mapped host ports.
pub async fn ensure_remote_readiness_with_ports(
descriptors: &GeneratedTopology,
mapping: &HostPortMapping,
) -> Result<(), StackReadinessError> {
2026-01-26 08:26:15 +01:00
let node_urls = mapping
.nodes
.iter()
2026-01-26 16:36:51 +01:00
.map(|ports| readiness_url(NODE_ROLE, ports.api))
.collect::<Result<Vec<_>, _>>()?;
descriptors
2026-01-26 08:26:15 +01:00
.wait_remote_readiness(&node_urls)
.await
.map_err(|source| StackReadinessError::Remote { source })
}
2026-01-26 16:36:51 +01:00
fn readiness_url(role: &'static str, port: u16) -> Result<Url, StackReadinessError> {
localhost_url(port).map_err(|source| StackReadinessError::Endpoint { role, port, source })
}
fn localhost_url(port: u16) -> Result<Url, ParseError> {
Url::parse(&format!("http://{}:{port}/", compose_runner_host()))
}
2026-01-26 08:26:15 +01:00
fn node_identifier(index: usize) -> String {
format!("node-{index}")
}
pub(crate) fn compose_runner_host() -> String {
let host = std::env::var("COMPOSE_RUNNER_HOST").unwrap_or_else(|_| "127.0.0.1".to_string());
debug!(host, "compose runner host resolved for readiness URLs");
host
}