Add integration tests based on testcontainers
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
**
|
||||
!Dockerfile
|
||||
!.dockerignore
|
||||
!runtime-check.sh
|
||||
!packages.lock
|
||||
@@ -0,0 +1,19 @@
|
||||
# linux/amd64 platform manifest for the official Alpine 3.22.7 image.
|
||||
FROM --platform=linux/amd64 alpine:3.22.7@sha256:dc92f017beea8e7f43263073d717f7bc011228c95d5312e440deb4d035bb4eec
|
||||
|
||||
LABEL org.opencontainers.image.title="WireGuard Admin Alpine integration fixture" \
|
||||
org.opencontainers.image.version="3.22.7" \
|
||||
org.opencontainers.image.base.name="docker.io/library/alpine:3.22.7" \
|
||||
org.opencontainers.image.base.digest="sha256:dc92f017beea8e7f43263073d717f7bc011228c95d5312e440deb4d035bb4eec"
|
||||
|
||||
COPY packages.lock /tmp/alpine-packages.lock
|
||||
RUN apk add --no-cache $(sed '/^#/d' /tmp/alpine-packages.lock) \
|
||||
&& mkdir -p /usr/local/share/wireguard-admin \
|
||||
&& install -m 0755 /tmp/alpine-packages.lock /usr/local/share/wireguard-admin/alpine-packages.lock \
|
||||
&& rm /tmp/alpine-packages.lock
|
||||
|
||||
COPY runtime-check.sh /usr/local/bin/wireguard-admin-alpine-check
|
||||
RUN chmod 0755 /usr/local/bin/wireguard-admin-alpine-check \
|
||||
&& /usr/local/bin/wireguard-admin-alpine-check
|
||||
|
||||
CMD ["/bin/sh"]
|
||||
@@ -0,0 +1,37 @@
|
||||
# Complete installed package closure from the selected-daemon Alpine 3.22.7
|
||||
# linux/amd64 image build on 2026-10-22. Dockerfile treats every entry as an
|
||||
# exact apk constraint. Rebuilds fail if this repository no longer serves the
|
||||
# recorded versions rather than silently choosing a different closure.
|
||||
alpine-baselayout=3.7.0-r0
|
||||
alpine-baselayout-data=3.7.0-r0
|
||||
alpine-keys=2.5-r0
|
||||
alpine-release=3.22.7-r0
|
||||
apk-tools=2.14.12-r0
|
||||
bash=5.2.37-r0
|
||||
busybox=1.37.0-r20
|
||||
busybox-binsh=1.37.0-r20
|
||||
ca-certificates-bundle=20261020-r0
|
||||
iproute2=6.15.0-r0
|
||||
iproute2-minimal=6.15.0-r0
|
||||
iproute2-ss=6.15.0-r0
|
||||
iproute2-tc=6.15.0-r0
|
||||
libapk2=2.14.12-r0
|
||||
libcap2=2.78-r0
|
||||
libcrypto3=3.5.9-r0
|
||||
libelf=0.193-r0
|
||||
libmnl=1.0.5-r2
|
||||
libncursesw=6.5_p20250503-r0
|
||||
libssl3=3.5.9-r0
|
||||
libxtables=1.8.11-r1
|
||||
musl=1.2.5-r13
|
||||
musl-utils=1.2.5-r13
|
||||
ncurses-terminfo-base=6.5_p20250503-r0
|
||||
openresolv=3.14.1-r0
|
||||
readline=8.2.13-r1
|
||||
scanelf=1.3.8-r1
|
||||
ssl_client=1.37.0-r20
|
||||
wireguard-tools=1.0.20250521-r0
|
||||
wireguard-tools-wg=1.0.20250521-r0
|
||||
wireguard-tools-wg-quick=1.0.20250521-r0
|
||||
zlib=1.3.2-r1
|
||||
zstd-libs=1.5.7-r0
|
||||
Executable
+21
@@ -0,0 +1,21 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
command -v wg >/dev/null
|
||||
command -v ip >/dev/null
|
||||
command -v busybox >/dev/null
|
||||
wg --version >/dev/null
|
||||
ip -Version >/dev/null
|
||||
ip link show lo >/dev/null
|
||||
|
||||
if [ "${1:-}" = "--driver" ]; then
|
||||
shift
|
||||
[ "$#" -eq 1 ]
|
||||
[ -x "$1" ]
|
||||
# M8.4 provides this ignored cfg(test) entry point. Keep the selection
|
||||
# exact so no other ignored test can run in the fixture image.
|
||||
"$1" --ignored --exact container_driver::alpine_readiness --test-threads=1
|
||||
elif [ "$#" -ne 0 ]; then
|
||||
echo "usage: wireguard-admin-alpine-check [--driver PATH]" >&2
|
||||
exit 2
|
||||
fi
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
pub mod container_harness;
|
||||
pub mod container_process;
|
||||
pub mod container_selection;
|
||||
@@ -0,0 +1,47 @@
|
||||
mod support;
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use support::container_harness::{ProbeNames, cleanup_confirmed_probe_resources, run_probe};
|
||||
use support::container_selection::Selection;
|
||||
|
||||
const CLEANUP_BUDGET: Duration = Duration::from_secs(40);
|
||||
|
||||
#[test]
|
||||
#[ignore = "requires an explicitly selected disposable Docker daemon"]
|
||||
fn selected_daemon_wireguard_and_application_smoke() {
|
||||
let selection = Selection::from_process_environment()
|
||||
.unwrap_or_else(|category| panic!("Testcontainers selection failed ({category})"));
|
||||
let names = ProbeNames::unique();
|
||||
let result = run_probe(&names);
|
||||
|
||||
// run_probe is synchronous: all Testcontainers requests return or reach the
|
||||
// pinned client's request timeout before this ownership-checked cleanup.
|
||||
let cleanup = cleanup_confirmed_probe_resources(
|
||||
&selection,
|
||||
&names,
|
||||
&std::process::id().to_string(),
|
||||
CLEANUP_BUDGET,
|
||||
);
|
||||
|
||||
if !cleanup.is_empty() {
|
||||
eprintln!(
|
||||
"Testcontainers cleanup incomplete for owned resources {}, {}, {}, {} ({})",
|
||||
names.network,
|
||||
names.server,
|
||||
names.baseline,
|
||||
names.client,
|
||||
cleanup.join(", ")
|
||||
);
|
||||
}
|
||||
if let Err(category) = result {
|
||||
panic!("Testcontainers probe failed ({category})");
|
||||
}
|
||||
if !cleanup.is_empty() {
|
||||
panic!("Testcontainers probe passed but owned-resource cleanup was incomplete");
|
||||
}
|
||||
eprintln!(
|
||||
"Testcontainers capability and Application smoke passed; owned resources: {}, {}, {}, {}",
|
||||
names.network, names.server, names.baseline, names.client
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user