diff --git a/ROADMAP.md b/ROADMAP.md index 79c30c1..0212056 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -46,7 +46,11 @@ input validation, auto-rollback safety, and pre-flight checks. 10 shape directories, every one validated against real nginx in Docker, with a shape checklist (`tests/configs/SHAPES.md`), a provenance manifest and a near-duplicate assertion (`tests/test-corpus.sh`) so the count cannot be gamed -- [ ] Nginx version matrix testing (1.18, 1.22, 1.25, 1.27) +- [x] Nginx version matrix testing (1.18, 1.22, 1.25, 1.27) — `tests/test-version-matrix.sh` + runs `nginx -t` in each official Docker image: a minimal full config and the + portable http-context templates must pass on all four, while HTTP/3-era + fixtures skip below their minimum version instead of failing. Wired into + `tests/run-tests.sh`; skips cleanly without Docker or a missing Hub tag ### UX - [ ] `--no-color` flag for CI environments diff --git a/tests/run-tests.sh b/tests/run-tests.sh index 87fbaaa..c34df88 100755 --- a/tests/run-tests.sh +++ b/tests/run-tests.sh @@ -92,7 +92,7 @@ bash4_pat="decl""are -A|map""file |read""array " bash4_hits=$(grep -rEn "$bash4_pat" \ "${SCRIPT_DIR}/../nginx-optimizer-lib/" \ "${SCRIPT_DIR}/run-tests.sh" "${SCRIPT_DIR}/test-corpus.sh" \ - "${SCRIPT_DIR}/test-with-nginx.sh" 2>/dev/null \ + "${SCRIPT_DIR}/test-with-nginx.sh" "${SCRIPT_DIR}/test-version-matrix.sh" 2>/dev/null \ | grep -v ":[0-9]*:[[:space:]]*#" || true) if [ -n "$bash4_hits" ]; then log_fail "Found bash 4+ constructs (macOS ships bash 3.2): $bash4_hits" @@ -1248,6 +1248,28 @@ source "${SCRIPT_DIR}/../lib/core/sysinfo.sh" _SYSINFO_RAM_MB="" _SYSINFO_CPU_CORES="" +################################################################################ +# SECTION 21: Nginx Version Matrix (issue #18) +################################################################################ +log_section "Nginx Version Matrix (Docker)" + +# tests/test-version-matrix.sh runs `nginx -t` inside the official +# nginx:1.18 / 1.22 / 1.25 / 1.27 images: a minimal full config and the +# portable http-context templates must pass on all four, while HTTP/3-era +# fixtures are gated by the version that introduced their directives +# (skip on 1.18/1.22, run on 1.25/1.27). Docker missing or the daemon down +# is a skip — same contract as test-with-nginx.sh, so CI without Docker +# stays green. +if [ ! -f "${SCRIPT_DIR}/test-version-matrix.sh" ]; then + log_fail "tests/test-version-matrix.sh missing" +elif ! command -v docker &>/dev/null || ! docker info &>/dev/null 2>&1; then + log_skip "Version matrix needs Docker (run ./tests/test-version-matrix.sh where Docker runs)" +elif bash "${SCRIPT_DIR}/test-version-matrix.sh"; then + log_pass "Version matrix passed on nginx 1.18 / 1.22 / 1.25 / 1.27" +else + log_fail "Version matrix failed" +fi + ################################################################################ # Summary ################################################################################ diff --git a/tests/test-version-matrix.sh b/tests/test-version-matrix.sh new file mode 100755 index 0000000..b1bdcaa --- /dev/null +++ b/tests/test-version-matrix.sh @@ -0,0 +1,242 @@ +#!/bin/bash +# Docker-based nginx VERSION MATRIX validation +# +# test-with-nginx.sh proves the corpus and templates parse on ONE nginx — +# whatever nginx:latest is today. This script proves the portable parts still +# parse on the OLDEST versions we claim to support, and that fixtures using +# directives introduced on newer nginx are skipped — not failed — on versions +# too old to have them. +# +# Matrix: nginx:1.18, 1.22, 1.25, 1.27 (official Docker Hub tags). +# - minimal full config + portable http-context templates: PASS on all four +# - HTTP/3 / quic / `http2 on;` / early_hints fixtures: SKIP below their +# minimum version, RUN at or above it +# +# Same contract as test-with-nginx.sh: Docker missing or daemon down exits 0. +# A missing Docker Hub tag skips THAT version with a reason, never the run. + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +CONFIGS_DIR="${SCRIPT_DIR}/configs" +TEMPLATES_DIR="${SCRIPT_DIR}/../nginx-optimizer-templates" + +# Colors +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +NC='\033[0m' + +PASS=0 +FAIL=0 +SKIP=0 + +log_pass() { echo -e "${GREEN}[PASS]${NC} $*"; PASS=$((PASS + 1)); } +log_fail() { echo -e "${RED}[FAIL]${NC} $*"; FAIL=$((FAIL + 1)); } +log_skip() { echo -e "${YELLOW}[SKIP]${NC} $*"; SKIP=$((SKIP + 1)); } + +echo "==========================================" +echo " nginx Version Matrix Validation (Docker)" +echo "==========================================" + +# Check Docker availability — same skip contract as test-with-nginx.sh +if ! command -v docker &>/dev/null; then + echo "Docker not available - skipping all tests" + exit 0 +fi + +if ! docker info &>/dev/null 2>&1; then + echo "Docker daemon not running - skipping all tests" + exit 0 +fi + +MATRIX_VERSIONS="1.18 1.22 1.25 1.27" + +# Minimal valid fixture: a complete upstream-default nginx.conf. Mounted as +# /etc/nginx/nginx.conf (not conf.d) — it already carries events{} and http{}. +MINIMAL_FIXTURE="${CONFIGS_DIR}/minimal/nginx-official-default.conf" + +# http-context templates that must parse on EVERY version in the matrix: +# gzip and open_file_cache predate 1.18 by a decade. compression.conf's brotli +# lines are commented out — stock images carry no brotli module, so this file +# is intentionally gzip-only here (compiling brotli for the matrix is out of +# scope; see the issue). +PORTABLE_TEMPLATES="compression.conf open-file-cache.conf" + +# Version-gated fixtures, one per line: "path|min-version|label". +# Below min-version they log SKIP; at or above it `nginx -t` must pass. +# early_hints needs nginx 1.29 — newer than the whole matrix — so today it +# exercises the skip path on every row; it starts running by itself the day a +# >=1.29 tag joins MATRIX_VERSIONS. +GATED_FIXTURES=" +${CONFIGS_DIR}/tls/http3-quic.conf|1.25|tls/http3-quic.conf (listen quic, http2 on, http3 on) +${TEMPLATES_DIR}/early-hints.conf|1.29|early-hints.conf (early_hints on) +" + +################################################################################ +# Helpers +################################################################################ + +# Numeric dotted compare, bash 3.2-safe: version_lt 1.9 1.18 -> true. +# (String compare would lie there — '9' > '1' lexically.) +version_lt() { + local a_maj="${1%%.*}" a_min="${1#*.}" + local b_maj="${2%%.*}" b_min="${2#*.}" + a_min="${a_min%%.*}" + b_min="${b_min%%.*}" + if [ "$a_maj" -eq "$b_maj" ]; then + [ "$a_min" -lt "$b_min" ] + else + [ "$a_maj" -lt "$b_maj" ] + fi +} + +# Returns 0 when the image is runnable (already cached, or pulled now). +# On failure sets MATRIX_SKIP_REASON for the caller's skip line — "tag gone" +# and "registry unreachable" are different reasons, not one shrug. +MATRIX_SKIP_REASON="" +ensure_image() { + local image="$1" + if docker image inspect "$image" >/dev/null 2>&1; then + return 0 + fi + if docker pull -q "$image" >/dev/null 2>&1; then + return 0 + fi + if docker manifest inspect "$image" >/dev/null 2>&1; then + MATRIX_SKIP_REASON="tag exists on Docker Hub but pull failed" + else + MATRIX_SKIP_REASON="tag not found on Docker Hub (or registry unreachable)" + fi + return 1 +} + +# Runs `nginx -t` inside image $1 with the given -v mounts (pass -v args first, +# image is appended). On failure, NGINX_T_ERR carries the first real nginx +# error line for the caller's log line. +NGINX_T_ERR="" +nginx_t() { + local image="$1" out + shift + if out=$(docker run --rm "$@" "$image" nginx -t 2>&1); then + NGINX_T_ERR="" + return 0 + fi + NGINX_T_ERR=$(printf '%s\n' "$out" | grep -E "emerg|error" | grep -v "docker-entrypoint" | head -1 || true) + return 1 +} + +################################################################################ +# Setup: self-signed cert for fixtures that reference ssl_certificate +################################################################################ + +CERT_DIR=$(mktemp -d) +openssl req -x509 -nodes -days 1 -newkey rsa:2048 \ + -keyout "$CERT_DIR/privkey.pem" \ + -out "$CERT_DIR/fullchain.pem" \ + -subj "/CN=test.example.com" 2>/dev/null + +################################################################################ +# Matrix run +################################################################################ + +for ver in $MATRIX_VERSIONS; do + image="nginx:${ver}" + echo "" + echo "--- nginx:${ver} ---" + + if ! ensure_image "$image"; then + log_skip "nginx:${ver} — ${MATRIX_SKIP_REASON}" + continue + fi + + # 1. Minimal full config — must parse on every supported version. + if nginx_t "$image" -v "${MINIMAL_FIXTURE}:/etc/nginx/nginx.conf:ro"; then + log_pass "nginx:${ver} minimal fixture (full nginx.conf)" + else + log_fail "nginx:${ver} minimal fixture rejected: ${NGINX_T_ERR}" + fi + + # 2. Portable http-context templates — wrapped in a generated full config, + # same shape as test-with-nginx.sh's generic wrapper. + for tmpl in $PORTABLE_TEMPLATES; do + tmpdir=$(mktemp -d) + cat > "$tmpdir/nginx.conf" <= ${minver}" + continue + fi + + if grep -q "ssl_certificate" "$fpath" 2>/dev/null; then + # Rewrite cert paths to the generated self-signed pair — same sed + # pipeline as test-with-nginx.sh (key/trusted/client anchored + # before the bare ssl_certificate pattern so it can't swallow them). + tmpdir=$(mktemp -d) + sed \ + -e 's|ssl_certificate_key .*|ssl_certificate_key /etc/nginx/ssl/privkey.pem;|g' \ + -e 's|ssl_trusted_certificate .*|ssl_trusted_certificate /etc/nginx/ssl/fullchain.pem;|g' \ + -e 's|ssl_client_certificate .*|ssl_client_certificate /etc/nginx/ssl/fullchain.pem;|g' \ + -e 's|ssl_certificate .*|ssl_certificate /etc/nginx/ssl/fullchain.pem;|g' \ + "$fpath" > "$tmpdir/test.conf" + if nginx_t "$image" \ + -v "$tmpdir/test.conf:/etc/nginx/conf.d/test.conf:ro" \ + -v "$CERT_DIR/fullchain.pem:/etc/nginx/ssl/fullchain.pem:ro" \ + -v "$CERT_DIR/privkey.pem:/etc/nginx/ssl/privkey.pem:ro"; then + log_pass "nginx:${ver} ${label}" + else + log_fail "nginx:${ver} ${label} rejected: ${NGINX_T_ERR}" + fi + rm -rf "$tmpdir" + else + if nginx_t "$image" -v "$fpath:/etc/nginx/conf.d/test.conf:ro"; then + log_pass "nginx:${ver} ${label}" + else + log_fail "nginx:${ver} ${label} rejected: ${NGINX_T_ERR}" + fi + fi + done <<< "$GATED_FIXTURES" +done + +################################################################################ +# Cleanup & Summary +################################################################################ + +rm -rf "$CERT_DIR" + +echo "" +echo "==========================================" +echo -e " Results: ${GREEN}${PASS} passed${NC}, ${RED}${FAIL} failed${NC}, ${YELLOW}${SKIP} skipped${NC}" +echo "==========================================" + +if [ "$FAIL" -gt 0 ]; then + echo -e "${RED}TESTS FAILED${NC}" + exit 1 +else + echo -e "${GREEN}ALL TESTS PASSED${NC}" + exit 0 +fi