Add integration tests based on testcontainers

This commit is contained in:
2026-10-25 19:47:25 +02:00
parent 2ccd67d64c
commit f4a6540df9
24 changed files with 8693 additions and 68 deletions
File diff suppressed because it is too large Load Diff
+178
View File
@@ -0,0 +1,178 @@
use std::{
io::Read,
process::{Command, Stdio},
thread,
time::{Duration, Instant},
};
const COMMAND_DEADLINE: Duration = Duration::from_secs(15);
const OUTPUT_LIMIT: usize = 4096;
pub fn docker_text(arguments: &[&str]) -> Result<String, &'static str> {
docker_text_with_timeout(arguments, COMMAND_DEADLINE)
}
pub fn docker_text_with_timeout(
arguments: &[&str],
command_deadline: Duration,
) -> Result<String, &'static str> {
docker_text_with_timeout_and_limits(arguments, command_deadline, OUTPUT_LIMIT, OUTPUT_LIMIT)
}
pub fn docker_text_with_timeout_and_limits(
arguments: &[&str],
command_deadline: Duration,
stdout_limit: usize,
stderr_limit: usize,
) -> Result<String, &'static str> {
let mut child = Command::new("docker")
.args(arguments)
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.map_err(|_| "Docker CLI could not be started")?;
let stdout = child.stdout.take().ok_or("Docker stdout unavailable")?;
let stderr = child.stderr.take().ok_or("Docker stderr unavailable")?;
let stdout_reader = thread::spawn(move || read_capped(stdout, stdout_limit));
let stderr_reader = thread::spawn(move || read_capped(stderr, stderr_limit));
let deadline = Instant::now() + command_deadline;
let status = loop {
match child.try_wait().map_err(|_| "Docker command wait failed")? {
Some(status) => break status,
None if Instant::now() >= deadline => {
let _ = child.kill();
let _ = child.wait();
let _ = stdout_reader.join();
let _ = stderr_reader.join();
return Err("Docker command timed out");
}
None => thread::sleep(Duration::from_millis(20)),
}
};
let (stdout, stdout_exceeded) = stdout_reader
.join()
.map_err(|_| "Docker stdout reader failed")?
.map_err(|_| "Docker stdout read failed")?;
let (stderr, stderr_exceeded) = stderr_reader
.join()
.map_err(|_| "Docker stderr reader failed")?
.map_err(|_| "Docker stderr read failed")?;
if !status.success() {
return Err(classify_docker_failure(status.code(), &stderr));
}
if stdout_exceeded || stderr_exceeded {
return Err("Docker command output exceeded its limit");
}
String::from_utf8(stdout).map_err(|_| "Docker returned non-UTF-8 output")
}
fn classify_docker_error(stderr: &[u8]) -> &'static str {
let diagnostic = String::from_utf8_lossy(stderr).to_ascii_lowercase();
if diagnostic.contains("table") && diagnostic.contains("does not exist")
|| diagnostic.contains("no chain/target/match")
{
return "Docker host lacks required bridge networking support";
}
if diagnostic.contains("overlap") {
return "Docker rejected a test subnet because it overlaps an existing network";
}
if diagnostic.contains("subnet") || diagnostic.contains("ipv4") {
return "Docker rejected the IPv4 network parameters";
}
if diagnostic.contains("pool") || diagnostic.contains("address space") {
return "Docker rejected the test network address pool";
}
if diagnostic.contains("already exists") {
return "Docker reported a conflicting owned resource name";
}
if diagnostic.contains("no such container")
|| diagnostic.contains("no such network")
|| diagnostic.contains("no such object")
|| diagnostic.contains(" not found")
{
return "Docker named resource was not found";
}
if diagnostic.contains("permission denied")
|| diagnostic.contains("forbidden")
|| diagnostic.contains("operation not permitted")
{
return "Docker API denied the requested operation";
}
"Docker command returned a nonzero status"
}
fn classify_docker_failure(status_code: Option<i32>, stderr: &[u8]) -> &'static str {
if matches!(status_code, Some(124 | 137 | 143)) {
return "In-container fixture command timed out";
}
classify_docker_error(stderr)
}
fn read_capped(mut reader: impl Read, limit: usize) -> std::io::Result<(Vec<u8>, bool)> {
let mut captured = Vec::new();
let mut buffer = [0_u8; 1024];
let mut exceeded = false;
loop {
let count = reader.read(&mut buffer)?;
if count == 0 {
return Ok((captured, exceeded));
}
let remaining = limit.saturating_sub(captured.len());
captured.extend_from_slice(&buffer[..count.min(remaining)]);
exceeded |= count > remaining;
}
}
#[cfg(test)]
mod tests {
use std::io::Cursor;
use super::{OUTPUT_LIMIT, classify_docker_error, classify_docker_failure, read_capped};
#[test]
fn output_capture_is_capped_while_the_pipe_is_drained() {
let input = vec![b'x'; OUTPUT_LIMIT + 17];
let (result, exceeded) =
read_capped(Cursor::new(input), OUTPUT_LIMIT).expect("read test bytes");
assert_eq!(result.len(), OUTPUT_LIMIT);
assert!(exceeded);
}
#[test]
fn docker_errors_are_reported_as_fixed_redacted_categories() {
assert_eq!(
classify_docker_error(b"iptables: Table does not exist; /etc/wireguard/private.conf"),
"Docker host lacks required bridge networking support"
);
assert_eq!(
classify_docker_error(b"Pool overlaps with another subnet"),
"Docker rejected a test subnet because it overlaps an existing network"
);
assert_eq!(
classify_docker_error(b"Error: No such object: wga-m83-net-test"),
"Docker named resource was not found"
);
assert_eq!(
classify_docker_error(b"network wga-m83-net-test not found"),
"Docker named resource was not found"
);
}
#[test]
fn busybox_timeout_exit_status_is_classified_without_stderr() {
for status_code in [124, 137, 143] {
assert_eq!(
classify_docker_failure(Some(status_code), b""),
"In-container fixture command timed out"
);
}
assert_eq!(
classify_docker_failure(Some(2), b""),
"Docker command returned a nonzero status"
);
}
}
+125
View File
@@ -0,0 +1,125 @@
use std::{env, path::Path};
const OPT_IN: &str = "WIREGUARD_ADMIN_TESTCONTAINERS";
const SELECTED_ENDPOINT: &str = "WIREGUARD_ADMIN_TEST_DAEMON_ENDPOINT";
const SELECTED_ID: &str = "WIREGUARD_ADMIN_TEST_DAEMON_ID";
#[derive(Clone, Debug)]
pub struct Selection {
pub endpoint: String,
pub daemon_id: String,
}
impl Selection {
pub fn from_process_environment() -> Result<Self, &'static str> {
Self::from_values(
env::var(OPT_IN).ok().as_deref(),
env::var(SELECTED_ENDPOINT).ok().as_deref(),
env::var(SELECTED_ID).ok().as_deref(),
env::var("DOCKER_HOST").ok().as_deref(),
env::var("TESTCONTAINERS_COMMAND").ok().as_deref(),
)
}
fn from_values(
opt_in: Option<&str>,
endpoint: Option<&str>,
daemon_id: Option<&str>,
docker_host: Option<&str>,
testcontainers_command: Option<&str>,
) -> Result<Self, &'static str> {
if opt_in != Some("1") {
return Err("explicit opt-in is missing");
}
if testcontainers_command == Some("keep") {
return Err("Testcontainers keep mode is forbidden");
}
let endpoint = endpoint.ok_or("selected daemon endpoint is missing")?;
if !endpoint.starts_with("unix:///")
|| endpoint.contains(['\n', '\r', '\0'])
|| !Path::new(endpoint.trim_start_matches("unix://")).is_absolute()
{
return Err("only an explicitly selected local Unix socket is supported");
}
if docker_host != Some(endpoint) {
return Err("DOCKER_HOST must exactly match the selected endpoint");
}
let daemon_id = daemon_id.ok_or("selected daemon ID is missing")?;
if daemon_id.is_empty()
|| daemon_id.len() > 128
|| !daemon_id
.bytes()
.all(|byte| byte.is_ascii_alphanumeric() || byte == b'-')
{
return Err("selected daemon ID has invalid syntax");
}
Ok(Self {
endpoint: endpoint.to_owned(),
daemon_id: daemon_id.to_owned(),
})
}
}
#[cfg(test)]
mod tests {
use super::Selection;
const ENDPOINT: &str = "unix:///var/run/docker.sock";
const ID: &str = "synthetic-daemon-id";
fn selection(
opt_in: Option<&str>,
endpoint: Option<&str>,
id: Option<&str>,
docker_host: Option<&str>,
keep: Option<&str>,
) -> Result<Selection, &'static str> {
Selection::from_values(opt_in, endpoint, id, docker_host, keep)
}
#[test]
fn requires_explicit_opt_in_endpoint_and_matching_current_daemon_identity() {
assert!(selection(None, Some(ENDPOINT), Some(ID), Some(ENDPOINT), None).is_err());
assert!(selection(Some("1"), None, Some(ID), Some(ENDPOINT), None).is_err());
assert!(selection(Some("1"), Some(ENDPOINT), None, Some(ENDPOINT), None).is_err());
assert!(
selection(
Some("1"),
Some(ENDPOINT),
Some(ID),
Some("tcp://remote:2376"),
None
)
.is_err()
);
assert!(
selection(
Some("1"),
Some("tcp://remote:2376"),
Some(ID),
Some("tcp://remote:2376"),
None
)
.is_err()
);
assert!(
selection(
Some("1"),
Some(ENDPOINT),
Some(ID),
Some(ENDPOINT),
Some("keep")
)
.is_err()
);
assert_eq!(
selection(Some("1"), Some(ENDPOINT), Some(ID), Some(ENDPOINT), None)
.expect("valid explicit local daemon")
.daemon_id,
ID
);
}
}
+3
View File
@@ -0,0 +1,3 @@
pub mod container_harness;
pub mod container_process;
pub mod container_selection;