use std::time::Duration;
use testcontainers::{
ContainerAsync, GenericImage, ImageExt,
core::{ExecCommand, IntoContainerPort, WaitFor, wait::HttpWaitStrategy},
runners::AsyncRunner,
};
struct NginxImageConfig {
image_name: String,
image_tag: String,
conf_path: String,
}
impl NginxImageConfig {
fn full_image(&self) -> String {
format!("{}:{}", self.image_name, self.image_tag)
}
}
fn nginx_image_config() -> NginxImageConfig {
if let Ok(image) = std::env::var("NGINX_IMAGE") {
let (name, tag) = match image.rsplit_once(':') {
Some((n, t)) => (n.to_string(), t.to_string()),
None => (image.clone(), "latest".to_string()),
};
let conf_path = if name.contains("openresty") {
"/usr/local/openresty/nginx/conf/nginx.conf".to_string()
} else if name.contains("freenginx") {
"/usr/local/nginx/conf/nginx.conf".to_string()
} else {
"/etc/nginx/nginx.conf".to_string()
};
NginxImageConfig {
image_name: name,
image_tag: tag,
conf_path,
}
} else {
let version = std::env::var("NGINX_VERSION").unwrap_or_else(|_| "1.27".to_string());
NginxImageConfig {
image_name: "nginx".to_string(),
image_tag: version,
conf_path: "/etc/nginx/nginx.conf".to_string(),
}
}
}
fn is_openresty_image() -> bool {
std::env::var("NGINX_IMAGE")
.map(|v| v.contains("openresty"))
.unwrap_or(false)
}
fn is_freenginx_image() -> bool {
std::env::var("NGINX_IMAGE")
.map(|v| v.contains("freenginx"))
.unwrap_or(false)
}
pub fn nginx_html_root() -> &'static str {
if is_openresty_image() {
"/usr/local/openresty/nginx/html"
} else if is_freenginx_image() {
"/usr/local/nginx/html"
} else {
"/usr/share/nginx/html"
}
}
pub fn nginx_conf_dir() -> &'static str {
if is_openresty_image() {
"/usr/local/openresty/nginx/conf"
} else if is_freenginx_image() {
"/usr/local/nginx/conf"
} else {
"/etc/nginx"
}
}
pub fn nginx_server_name() -> &'static str {
if is_openresty_image() {
"openresty"
} else if is_freenginx_image() {
"freenginx"
} else {
"nginx"
}
}
pub fn nginx_version() -> String {
std::env::var("NGINX_VERSION").unwrap_or_else(|_| "1.27".to_string())
}
pub fn nginx_image_tag() -> String {
if let Ok(image) = std::env::var("NGINX_IMAGE") {
match image.rsplit_once(':') {
Some((_, tag)) => tag.to_string(),
None => "latest".to_string(),
}
} else {
nginx_version()
}
}
pub fn nginx_version_at_least(threshold_major: u32, threshold_minor: u32) -> bool {
let tag = nginx_image_tag();
parse_version_at_least(&tag, threshold_major, threshold_minor)
}
fn parse_version_at_least(tag: &str, threshold_major: u32, threshold_minor: u32) -> bool {
let parts: Vec<&str> = tag.split('.').collect();
if parts.len() >= 2 {
if let (Ok(major), Ok(minor)) = (parts[0].parse::<u32>(), parts[1].parse::<u32>()) {
return (major, minor) >= (threshold_major, threshold_minor);
}
}
false
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn version_at_least_exact_match() {
assert!(parse_version_at_least("1.29", 1, 29));
}
#[test]
fn version_at_least_higher_minor() {
assert!(parse_version_at_least("1.30", 1, 29));
}
#[test]
fn version_at_least_lower_minor() {
assert!(!parse_version_at_least("1.28", 1, 29));
}
#[test]
fn version_at_least_with_patch() {
assert!(parse_version_at_least("1.29.7", 1, 29));
assert!(!parse_version_at_least("1.28.3", 1, 29));
}
#[test]
fn version_at_least_non_numeric_tag() {
assert!(!parse_version_at_least("latest", 1, 29));
assert!(!parse_version_at_least("noble", 1, 29));
}
#[test]
fn version_at_least_higher_major() {
assert!(parse_version_at_least("2.0", 1, 29));
}
#[test]
fn version_at_least_boundary() {
assert!(parse_version_at_least("1.29", 1, 29));
assert!(!parse_version_at_least("1.29", 1, 30));
}
}
pub fn nginx_image() -> (String, String) {
let cfg = nginx_image_config();
(cfg.image_name, cfg.image_tag)
}
pub fn nginx_conf_path() -> String {
let cfg = nginx_image_config();
cfg.conf_path
}
pub struct NginxContainer {
container: ContainerAsync<GenericImage>,
host: String,
port: u16,
tls: bool,
bridge_ip: Option<String>,
}
#[derive(Debug)]
pub struct ExecOutput {
pub stdout: String,
pub stderr: String,
pub exit_code: i64,
}
impl ExecOutput {
pub fn output(&self) -> String {
format!("{}{}", self.stdout, self.stderr)
}
}
impl NginxContainer {
pub async fn start(config: impl Into<Vec<u8>>) -> Self {
Self::builder().start(config).await
}
#[deprecated(note = "use NginxContainer::builder().health_path(...).start(...) instead")]
pub async fn start_with_health_path(config: impl Into<Vec<u8>>, health_path: &str) -> Self {
Self::builder().health_path(health_path).start(config).await
}
pub fn builder() -> NginxContainerBuilder {
NginxContainerBuilder {
network: None,
health_path: "/".to_string(),
entrypoint: None,
cmd: None,
wait_for: None,
expose_port: Some(80),
}
}
pub async fn start_ssl(config: impl Into<Vec<u8>>) -> Self {
let startup_script = concat!(
"openssl req -x509 -nodes -days 1 -newkey rsa:2048 ",
"-keyout /tmp/key.pem -out /tmp/cert.pem ",
"-subj '/CN=test' 2>/dev/null && ",
"exec nginx -g 'daemon off; error_log /dev/stderr notice;'"
);
Self::builder()
.entrypoint("sh")
.cmd(vec!["-c", startup_script])
.wait_for(WaitFor::message_on_stderr("start worker process"))
.expose_port(Some(443))
.start(config)
.await
}
pub async fn exec(&self, cmd: &[&str]) -> ExecOutput {
let exec_cmd = ExecCommand::new(cmd.iter().map(|s| s.to_string()).collect::<Vec<_>>());
let mut result = self
.container
.exec(exec_cmd)
.await
.expect("Failed to exec command in container");
let stdout =
String::from_utf8_lossy(&result.stdout_to_vec().await.unwrap_or_default()).to_string();
let stderr =
String::from_utf8_lossy(&result.stderr_to_vec().await.unwrap_or_default()).to_string();
let exit_code = result.exit_code().await.ok().flatten().unwrap_or(-1);
ExecOutput {
stdout,
stderr,
exit_code,
}
}
pub async fn exec_shell(&self, script: &str) -> ExecOutput {
self.exec(&["sh", "-c", script]).await
}
pub async fn ssl_certificate(&self) -> reqwest::tls::Certificate {
let output = self.exec(&["cat", "/tmp/cert.pem"]).await;
assert!(
output.exit_code == 0 && !output.stdout.is_empty(),
"Failed to read /tmp/cert.pem from container: {}",
output.stderr
);
reqwest::tls::Certificate::from_pem(output.stdout.as_bytes())
.expect("Failed to parse certificate PEM")
}
pub fn url(&self, path: &str) -> String {
let scheme = if self.tls { "https" } else { "http" };
format!("{scheme}://{}:{}{}", self.host, self.port, path)
}
pub fn host(&self) -> &str {
&self.host
}
pub fn port(&self) -> u16 {
self.port
}
pub fn bridge_ip(&self) -> Option<&str> {
self.bridge_ip.as_deref()
}
}
pub struct NginxContainerBuilder {
network: Option<String>,
health_path: String,
entrypoint: Option<String>,
cmd: Option<Vec<String>>,
wait_for: Option<WaitFor>,
expose_port: Option<u16>,
}
impl NginxContainerBuilder {
pub fn network(mut self, network: &str) -> Self {
self.network = Some(network.to_string());
self
}
pub fn health_path(mut self, path: &str) -> Self {
self.health_path = path.to_string();
self
}
pub fn entrypoint(mut self, entrypoint: &str) -> Self {
self.entrypoint = Some(entrypoint.to_string());
self
}
pub fn cmd(mut self, cmd: Vec<&str>) -> Self {
self.cmd = Some(cmd.into_iter().map(|s| s.to_string()).collect());
self
}
pub fn wait_for(mut self, wait_for: WaitFor) -> Self {
self.wait_for = Some(wait_for);
self
}
pub fn expose_port(mut self, port: Option<u16>) -> Self {
self.expose_port = port;
self
}
pub async fn start(self, config: impl Into<Vec<u8>>) -> NginxContainer {
let img = nginx_image_config();
let mut generic = GenericImage::new(&img.image_name, &img.image_tag);
if let Some(ref entrypoint) = self.entrypoint {
generic = generic.with_entrypoint(entrypoint);
}
let wait = self.wait_for.unwrap_or_else(|| {
WaitFor::http(
HttpWaitStrategy::new(&self.health_path).with_expected_status_code(200u16),
)
});
if let Some(port) = self.expose_port {
generic = generic.with_exposed_port(port.tcp());
}
let mut image = generic
.with_wait_for(wait)
.with_copy_to(&img.conf_path, config.into())
.with_startup_timeout(Duration::from_secs(120));
if let Some(ref cmd) = self.cmd {
let cmd_refs: Vec<&str> = cmd.iter().map(|s| s.as_str()).collect();
image = image.with_cmd(cmd_refs);
}
if let Some(ref network) = self.network {
image = image.with_network(network);
}
let container = image.start().await.unwrap_or_else(|e| {
panic!(
"Failed to start {} container (is Docker running?): {}",
img.full_image(),
e
)
});
let host = container.get_host().await.unwrap().to_string();
let port = if let Some(p) = self.expose_port {
container.get_host_port_ipv4(p).await.unwrap()
} else {
0
};
let bridge_ip = if self.network.is_some() {
Some(
container
.get_bridge_ip_address()
.await
.expect("Failed to get bridge IP address")
.to_string(),
)
} else {
None
};
NginxContainer {
container,
host,
port,
tls: self.expose_port == Some(443),
bridge_ip,
}
}
}
#[derive(Debug)]
pub struct NginxConfigTestResult {
pub success: bool,
pub output: String,
}
impl NginxConfigTestResult {
pub fn assert_fails_with(&self, expected: &str) {
assert!(
!self.success,
"Expected nginx -t to fail, but it succeeded. Output:\n{}",
self.output
);
assert!(
self.output.contains(expected),
"Expected output to contain {:?}, got:\n{}",
expected,
self.output
);
}
pub fn assert_success(&self) {
assert!(
self.success,
"Expected nginx -t to succeed, but it failed. Output:\n{}",
self.output
);
}
pub fn assert_warns_with(&self, expected: &str) {
assert!(
self.success,
"Expected nginx -t to succeed with warnings, but it failed. Output:\n{}",
self.output
);
assert!(
self.output.contains(expected),
"Expected output to contain {:?}, got:\n{}",
expected,
self.output
);
}
pub fn assert_success_without_warnings(&self) {
assert!(
self.success,
"Expected nginx -t to succeed, but it failed. Output:\n{}",
self.output
);
assert!(
!self.output.contains("[warn]"),
"Expected no warnings, but got:\n{}",
self.output
);
}
}
pub fn nginx_config_test(config: &str) -> NginxConfigTestResult {
let img = nginx_image_config();
let mut tmpfile = std::env::temp_dir();
tmpfile.push(format!(
"nginx-lint-container-test-{}-{:?}.conf",
std::process::id(),
std::thread::current().id()
));
std::fs::write(&tmpfile, config).expect("Failed to write temp nginx config");
let result = std::process::Command::new("docker")
.args([
"run",
"--rm",
"-v",
&format!("{}:{}:ro", tmpfile.display(), img.conf_path),
&img.full_image(),
"nginx",
"-t",
])
.output()
.expect("Failed to run docker (is Docker installed and running?)");
let _ = std::fs::remove_file(&tmpfile);
let stdout = String::from_utf8_lossy(&result.stdout);
let stderr = String::from_utf8_lossy(&result.stderr);
NginxConfigTestResult {
success: result.status.success(),
output: format!("{stdout}{stderr}"),
}
}