#![cfg(unix)]
use std::process::Command;
mod common;
fn image() -> Option<String> {
std::env::var("ROUTER_DEPLOY_TEST_IMAGE")
.ok()
.filter(|value| !value.trim().is_empty())
}
fn docker_available() -> bool {
Command::new("docker")
.args(["info", "--format", "{{.ServerVersion}}"])
.output()
.is_ok_and(|output| output.status.success())
}
struct Deployment {
root: tempfile::TempDir,
port: u16,
}
impl Drop for Deployment {
fn drop(&mut self) {
let _ = Command::new("docker")
.args(["rm", "-f", link_assistant_router::deploy::CONTAINER])
.output();
}
}
impl Deployment {
fn new() -> Self {
let _ = Command::new("docker")
.args(["rm", "-f", link_assistant_router::deploy::CONTAINER])
.output();
Self {
root: tempfile::tempdir().expect("deployment root"),
port: free_port(),
}
}
fn deploy(&self, extra: &[&str]) -> std::process::Output {
let image = extra
.iter()
.position(|argument| *argument == "--image")
.and_then(|at| extra.get(at + 1).map(|value| (*value).to_string()))
.unwrap_or_else(|| image().expect("an image was named"));
let extra: Vec<&str> = {
let mut kept = Vec::with_capacity(extra.len());
let mut skip_next = false;
for argument in extra {
if skip_next {
skip_next = false;
continue;
}
if *argument == "--image" {
skip_next = true;
continue;
}
kept.push(*argument);
}
kept
};
let mut arguments = vec![
"deploy".to_string(),
"--port".to_string(),
self.port.to_string(),
"--image".to_string(),
image,
"--root".to_string(),
self.root.path().display().to_string(),
"--data-dir".to_string(),
self.root.path().join("state").display().to_string(),
];
arguments.extend(extra.iter().map(|value| (*value).to_string()));
Command::new(env!("CARGO_BIN_EXE_link-assistant-router"))
.args(&arguments)
.env("TOKEN_SECRET", "deploy-docker-test-secret")
.env("NO_COLOR", "1")
.output()
.expect("the router binary runs")
}
fn health(&self) -> bool {
use std::io::{Read as _, Write as _};
let Ok(mut stream) = std::net::TcpStream::connect(("127.0.0.1", self.port)) else {
return false;
};
let _ = stream.set_read_timeout(Some(std::time::Duration::from_secs(5)));
if stream
.write_all(b"GET /api/health HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n")
.is_err()
{
return false;
}
let mut response = String::new();
stream.read_to_string(&mut response).is_ok() && response.starts_with("HTTP/1.1 200")
}
}
fn deployed_container_id() -> Option<String> {
let output = Command::new("docker")
.args([
"inspect",
"--format",
"{{.Id}}",
link_assistant_router::deploy::CONTAINER,
])
.output()
.ok()?;
output
.status
.success()
.then(|| String::from_utf8_lossy(&output.stdout).trim().to_string())
}
fn free_port() -> u16 {
std::net::TcpListener::bind("127.0.0.1:0")
.expect("bind ephemeral")
.local_addr()
.expect("address")
.port()
}
fn ready(test: &str) -> Option<String> {
if !docker_available() {
common::tiers::unavailable(
common::tiers::Tier::Integration,
test,
"a container runtime is not available",
);
return None;
}
let named = image();
if named.is_none() {
common::tiers::unavailable(
common::tiers::Tier::Integration,
test,
"ROUTER_DEPLOY_TEST_IMAGE names no image",
);
}
named
}
#[test]
fn deploy_brings_up_a_container_and_a_second_run_changes_nothing() {
if ready("deploy_brings_up_a_container_and_a_second_run_changes_nothing").is_none() {
return;
}
let deployment = Deployment::new();
let first = deployment.deploy(&[]);
let stdout = String::from_utf8_lossy(&first.stdout);
assert!(
first.status.success(),
"the first converge succeeds: {stdout}{}",
String::from_utf8_lossy(&first.stderr)
);
assert!(
deployment.health(),
"the deployment answers /api/health: {stdout}"
);
let created = deployed_container_id().expect("a container exists");
let second = deployment.deploy(&[]);
let repeated = String::from_utf8_lossy(&second.stdout);
assert!(second.status.success(), "{repeated}");
assert!(
!repeated.contains("acted"),
"a converged deployment acts on nothing: {repeated}"
);
assert_eq!(
deployed_container_id().as_deref(),
Some(created.as_str()),
"the same container is kept rather than replaced"
);
}
#[test]
fn status_reports_without_changing_the_deployment() {
if ready("status_reports_without_changing_the_deployment").is_none() {
return;
}
let deployment = Deployment::new();
assert!(deployment.deploy(&[]).status.success());
let before = deployed_container_id().expect("a container");
let status = deployment.deploy(&["--status"]);
let stdout = String::from_utf8_lossy(&status.stdout);
assert!(status.status.success(), "{stdout}");
assert!(!stdout.contains("acted"), "--status is a report: {stdout}");
assert_eq!(deployed_container_id().as_deref(), Some(before.as_str()));
}
#[test]
fn a_stopped_deployment_is_restored_on_the_next_run() {
if ready("a_stopped_deployment_is_restored_on_the_next_run").is_none() {
return;
}
let deployment = Deployment::new();
assert!(deployment.deploy(&[]).status.success());
let before = deployed_container_id().expect("a container");
assert!(
Command::new("docker")
.args(["stop", link_assistant_router::deploy::CONTAINER])
.output()
.expect("docker stop")
.status
.success()
);
let restored = deployment.deploy(&[]);
let stdout = String::from_utf8_lossy(&restored.stdout);
assert!(restored.status.success(), "{stdout}");
assert!(deployment.health(), "it answers again: {stdout}");
assert_eq!(
deployed_container_id().as_deref(),
Some(before.as_str()),
"the container is started rather than replaced, so its store survives"
);
}
#[test]
fn down_removes_only_this_deployment() {
if ready("down_removes_only_this_deployment").is_none() {
return;
}
let deployment = Deployment::new();
assert!(deployment.deploy(&[]).status.success());
let others_before = other_container_ids();
let refused = deployment.deploy(&["--down"]);
assert!(!refused.status.success());
assert!(
String::from_utf8_lossy(&refused.stderr).contains("--yes"),
"the refusal says how to proceed"
);
assert!(deployed_container_id().is_some(), "it still exists");
let removed = deployment.deploy(&["--down", "--yes"]);
assert!(removed.status.success());
assert!(deployed_container_id().is_none(), "it is gone");
assert_eq!(
other_container_ids(),
others_before,
"unrelated containers are untouched"
);
}
fn other_container_ids() -> Vec<String> {
let output = Command::new("docker")
.args(["ps", "-aq"])
.output()
.expect("docker ps");
let ours = Command::new("docker")
.args([
"inspect",
"--format",
"{{.Id}}",
link_assistant_router::deploy::CONTAINER,
])
.output()
.ok()
.and_then(|output| {
output
.status
.success()
.then(|| String::from_utf8_lossy(&output.stdout).trim().to_string())
});
String::from_utf8_lossy(&output.stdout)
.lines()
.map(str::trim)
.filter(|id| !id.is_empty())
.filter(|id| ours.as_deref().is_none_or(|ours| !ours.starts_with(*id)))
.map(str::to_string)
.collect()
}
#[test]
fn a_failing_step_reports_the_step_and_its_purpose() {
if ready("a_failing_step_reports_the_step_and_its_purpose").is_none() {
return;
}
let deployment = Deployment::new();
let refused = deployment.deploy(&["--image", "ghcr.io/link-assistant/router:latest"]);
let combined = format!(
"{}{}",
String::from_utf8_lossy(&refused.stdout),
String::from_utf8_lossy(&refused.stderr)
);
assert!(!refused.status.success(), "a moving ref fails: {combined}");
for expected in ["image-ref", "expected:", "found:", "why:"] {
assert!(
combined.contains(expected),
"the report carries {expected}: {combined}"
);
}
assert!(
deployed_container_id().is_none(),
"nothing was created: {combined}"
);
}
#[test]
fn each_step_reports_its_duration() {
if ready("each_step_reports_its_duration").is_none() {
return;
}
let deployment = Deployment::new();
let output = deployment.deploy(&["--status"]);
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(stdout.contains("ms)"), "per-step timing: {stdout}");
assert!(stdout.contains("total"), "a total: {stdout}");
}
#[test]
fn deploy_output_contains_no_secret_material() {
if ready("deploy_output_contains_no_secret_material").is_none() {
return;
}
let deployment = Deployment::new();
let output = deployment.deploy(&[]);
let combined = format!(
"{}{}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
assert!(
!combined.contains("deploy-docker-test-secret"),
"the signing secret never appears in output: {combined}"
);
assert!(
!combined.contains("la_sk_"),
"no credential is echoed: {combined}"
);
}