feat(compose): expose deploy metadata for attach node-control tests

This commit is contained in:
andrussal 2026-03-06 13:25:11 +01:00
parent 4195707aa7
commit 6226f51598
3 changed files with 82 additions and 57 deletions

View File

@ -1,5 +1,4 @@
use std::{
env,
process::{Command, Stdio},
thread,
time::Duration,
@ -8,20 +7,23 @@ use std::{
use anyhow::{Result, anyhow};
use lb_ext::{CoreBuilderExt as _, LbcComposeDeployer, LbcExtEnv, ScenarioBuilder};
use testing_framework_core::scenario::{AttachSource, Deployer as _, Runner};
use testing_framework_runner_compose::ComposeRunnerError;
#[tokio::test]
#[ignore = "requires Docker and mutates compose runtime state"]
async fn compose_attach_mode_restart_node_opt_in() -> Result<()> {
if env::var("TF_RUN_COMPOSE_ATTACH_NODE_CONTROL").is_err() {
return Ok(());
}
let managed = ScenarioBuilder::deployment_with(|d| d.with_node_count(1))
.enable_node_control()
.with_run_duration(Duration::from_secs(5))
.build()?;
let deployer = LbcComposeDeployer::default();
let managed_runner: Runner<LbcExtEnv> = deployer.deploy(&managed).await?;
let (managed_runner, metadata): (Runner<LbcExtEnv>, _) =
match deployer.deploy_with_metadata(&managed).await {
Ok(result) => result,
Err(ComposeRunnerError::DockerUnavailable) => return Ok(()),
Err(error) => return Err(anyhow::Error::new(error)),
};
let managed_client = managed_runner
.context()
.node_clients()
@ -29,12 +31,14 @@ async fn compose_attach_mode_restart_node_opt_in() -> Result<()> {
.into_iter()
.next()
.ok_or_else(|| anyhow!("managed compose runner returned no node clients"))?;
let api_port = managed_client
managed_client
.base_url()
.port()
.ok_or_else(|| anyhow!("managed node base url has no port"))?;
let project_name = discover_compose_project_by_published_port(api_port)?;
let project_name = metadata
.project_name
.ok_or_else(|| anyhow!("compose metadata did not include project name"))?;
let attached = ScenarioBuilder::deployment_with(|d| d.with_node_count(1))
.enable_node_control()
@ -44,7 +48,11 @@ async fn compose_attach_mode_restart_node_opt_in() -> Result<()> {
)
.build()?;
let attached_runner: Runner<LbcExtEnv> = deployer.deploy(&attached).await?;
let attached_runner: Runner<LbcExtEnv> = match deployer.deploy(&attached).await {
Ok(runner) => runner,
Err(ComposeRunnerError::DockerUnavailable) => return Ok(()),
Err(error) => return Err(anyhow::Error::new(error)),
};
let pre_restart_container = discover_compose_service_container(&project_name, "node-0")?;
let pre_restart_started_at = inspect_container_started_at(&pre_restart_container)?;
let control = attached_runner
@ -65,51 +73,6 @@ async fn compose_attach_mode_restart_node_opt_in() -> Result<()> {
Ok(())
}
fn discover_compose_project_by_published_port(port: u16) -> Result<String> {
let container_ids = run_docker_capture(["ps", "-q"])?;
let host_port_token = format!("\"HostPort\":\"{port}\"");
let mut matching_projects = Vec::new();
for container_id in container_ids
.lines()
.map(str::trim)
.filter(|id| !id.is_empty())
{
let ports = run_docker_capture([
"inspect",
"--format",
"{{json .NetworkSettings.Ports}}",
container_id,
])?;
if !ports.contains(&host_port_token) {
continue;
}
let project = run_docker_capture([
"inspect",
"--format",
"{{ index .Config.Labels \"com.docker.compose.project\" }}",
container_id,
])?;
let project = project.trim();
if !project.is_empty() {
matching_projects.push(project.to_owned());
}
}
match matching_projects.as_slice() {
[project] => Ok(project.clone()),
[] => Err(anyhow!(
"no compose project found exposing api host port {port}"
)),
_ => Err(anyhow!(
"multiple compose projects expose api host port {port}: {:?}",
matching_projects
)),
}
}
fn discover_compose_service_container(project: &str, service: &str) -> Result<String> {
let container = run_docker_capture([
"ps",

View File

@ -22,6 +22,13 @@ pub struct ComposeDeployer<E: ComposeDeployEnv> {
_env: PhantomData<E>,
}
/// Compose deployment metadata returned by compose-specific deployment APIs.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct ComposeDeploymentMetadata {
/// Docker Compose project name used for this deployment when available.
pub project_name: Option<String>,
}
impl<E: ComposeDeployEnv> Default for ComposeDeployer<E> {
fn default() -> Self {
Self::new()
@ -42,6 +49,25 @@ impl<E: ComposeDeployEnv> ComposeDeployer<E> {
self.readiness_checks = enabled;
self
}
/// Deploy and return compose-specific metadata alongside the generic
/// runner.
pub async fn deploy_with_metadata<Caps>(
&self,
scenario: &Scenario<E, Caps>,
) -> Result<(Runner<E>, ComposeDeploymentMetadata), ComposeRunnerError>
where
Caps: RequiresNodeControl + ObservabilityCapabilityProvider + Send + Sync,
{
let deployer = Self {
readiness_checks: self.readiness_checks,
_env: PhantomData,
};
orchestrator::DeploymentOrchestrator::new(deployer)
.deploy_with_metadata(scenario)
.await
}
}
#[async_trait]

View File

@ -14,7 +14,7 @@ use testing_framework_core::{
use tracing::info;
use super::{
ComposeDeployer,
ComposeDeployer, ComposeDeploymentMetadata,
attach_provider::ComposeAttachProvider,
clients::ClientBuilder,
make_cleanup_guard,
@ -48,6 +48,18 @@ impl<E: ComposeDeployEnv> DeploymentOrchestrator<E> {
&self,
scenario: &Scenario<E, Caps>,
) -> Result<Runner<E>, ComposeRunnerError>
where
Caps: RequiresNodeControl + ObservabilityCapabilityProvider + Send + Sync,
{
self.deploy_with_metadata(scenario)
.await
.map(|(runner, _)| runner)
}
pub async fn deploy_with_metadata<Caps>(
&self,
scenario: &Scenario<E, Caps>,
) -> Result<(Runner<E>, ComposeDeploymentMetadata), ComposeRunnerError>
where
Caps: RequiresNodeControl + ObservabilityCapabilityProvider + Send + Sync,
{
@ -61,7 +73,8 @@ impl<E: ComposeDeployEnv> DeploymentOrchestrator<E> {
if scenario.sources().is_attached() {
return self
.deploy_attached_only::<Caps>(scenario, source_plan)
.await;
.await
.map(|runner| (runner, attached_metadata(scenario)));
}
let deployment = scenario.deployment();
@ -103,6 +116,8 @@ impl<E: ComposeDeployEnv> DeploymentOrchestrator<E> {
.await
.map_err(|source| ComposeRunnerError::SourceOrchestration { source })?;
let project_name = prepared.environment.project_name().to_owned();
let runner = self
.build_runner::<Caps>(
scenario,
@ -121,7 +136,12 @@ impl<E: ComposeDeployEnv> DeploymentOrchestrator<E> {
readiness_enabled,
);
Ok(runner)
Ok((
runner,
ComposeDeploymentMetadata {
project_name: Some(project_name),
},
))
}
async fn deploy_attached_only<Caps>(
@ -304,6 +324,22 @@ impl<E: ComposeDeployEnv> DeploymentOrchestrator<E> {
}
}
fn attached_metadata<E, Caps>(scenario: &Scenario<E, Caps>) -> ComposeDeploymentMetadata
where
E: ComposeDeployEnv,
Caps: Send + Sync,
{
let project_name = match scenario.sources() {
ScenarioSources::Attached {
attach: AttachSource::Compose { project, .. },
..
} => project.clone(),
_ => None,
};
ComposeDeploymentMetadata { project_name }
}
struct DeployedNodes<E: ComposeDeployEnv> {
host_ports: HostPortMapping,
host: String,