178 lines
9.7 KiB
Python
178 lines
9.7 KiB
Python
#!/usr/bin/env python3
|
|
"""Verify real Nginx TLS HTTP/2 upstreams inside one bounded, isolated container."""
|
|
|
|
import argparse
|
|
from datetime import datetime, timezone
|
|
import hashlib
|
|
import json
|
|
from pathlib import Path
|
|
import re
|
|
import shutil
|
|
import signal
|
|
import subprocess
|
|
import tempfile
|
|
import time
|
|
import uuid
|
|
|
|
|
|
class ContainerRunner:
|
|
def __init__(self, docker, output, deadline):
|
|
self.docker = docker
|
|
self.output = output
|
|
self.deadline = deadline
|
|
self.commands = []
|
|
self.cleaned = []
|
|
|
|
def execute(self, command):
|
|
remaining = self.deadline - time.monotonic()
|
|
if remaining <= 0:
|
|
raise TimeoutError("HTTP/2 proxy verification exceeded its wall timeout")
|
|
self.commands.append(command)
|
|
result = subprocess.run(command, stdout=self.output, stderr=subprocess.STDOUT, timeout=remaining)
|
|
self.output.flush()
|
|
if result.returncode:
|
|
raise RuntimeError(f"command failed with exit code {result.returncode}: {command[1:3]}")
|
|
|
|
def container(self, name, arguments):
|
|
command = [
|
|
self.docker, "run", "--rm", "--name", name,
|
|
"--cpus", "2", "--memory", "2048m", "--memory-swap", "2048m", "--pids-limit", "256",
|
|
"--log-opt", "max-size=10m", "--log-opt", "max-file=1",
|
|
] + arguments
|
|
try:
|
|
self.execute(command)
|
|
finally:
|
|
# Always finish cleanup before starting the next stage. Never prune
|
|
# or target a container belonging to another verification run.
|
|
cleanup = subprocess.run(
|
|
[self.docker, "rm", "--force", name], capture_output=True, text=True, timeout=15,
|
|
)
|
|
if cleanup.returncode and "No such container" not in cleanup.stderr:
|
|
raise RuntimeError(f"could not clean up {name}: {cleanup.stderr.strip()}")
|
|
self.cleaned.append(name)
|
|
|
|
|
|
def sourceManifest(root):
|
|
paths = [root / "go.mod", root / "go.sum", Path(__file__).resolve()]
|
|
paths += list((root / "transport" / "http2").glob("*.go"))
|
|
return {str(path.relative_to(root)): hashlib.sha256(path.read_bytes()).hexdigest() for path in sorted(paths)}
|
|
|
|
|
|
def main():
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
parser.add_argument("--image", default="golang:1.25", help="compiler image")
|
|
parser.add_argument("--nginx-image", help="Nginx image supporting proxy_http_version 2; defaults to nginx:1.30.5-alpine, or nginx:1.30.5 for --race")
|
|
parser.add_argument("--race", action="store_true", help="instrument the integration test with Go's race detector; requires a glibc-based runtime image")
|
|
parser.add_argument("--goproxy", default="")
|
|
parser.add_argument("--count", type=int, default=3, help="repeat the proxy scenarios (default: 3)")
|
|
parser.add_argument("--wall-timeout", type=int, default=600, help="total pull/build/test deadline in seconds")
|
|
parser.add_argument("--output", type=Path, default=Path("performance/http2ProxyLinux.json"))
|
|
args = parser.parse_args()
|
|
if args.nginx_image is None:
|
|
args.nginx_image = "nginx:1.30.5" if args.race else "nginx:1.30.5-alpine"
|
|
if args.count < 1 or args.count > 10 or args.wall_timeout < 1:
|
|
parser.error("--count must be 1..10 and --wall-timeout must be positive")
|
|
docker = shutil.which("docker")
|
|
if not docker:
|
|
raise SystemExit("docker is required")
|
|
root = Path(__file__).resolve().parent.parent
|
|
args.output = args.output.resolve()
|
|
args.output.parent.mkdir(parents=True, exist_ok=True)
|
|
logPath = args.output.with_suffix(".txt")
|
|
runID = uuid.uuid4().hex[:12]
|
|
names = [f"go-omni-http2-proxy-{runID}-{stage}" for stage in ("build", "check")]
|
|
capture = lambda command: subprocess.check_output(command, text=True, timeout=15).strip()
|
|
running = capture([docker, "ps", "--format", "{{.ID}} {{.Names}}"])
|
|
if any("go-omni-http2-" in row for row in running.splitlines()):
|
|
raise SystemExit("another HTTP/2 validation container is running; wait for it to finish")
|
|
before = {row.split()[0] for row in running.splitlines()}
|
|
report = {
|
|
"status": "running", "startedAt": datetime.now(timezone.utc).isoformat(),
|
|
"testedCommit": capture(["git", "-C", str(root), "rev-parse", "HEAD"]),
|
|
"workingTreeStatus": capture(["git", "-C", str(root), "status", "--porcelain"]),
|
|
"sourceSha256": sourceManifest(root), "repetitions": args.count, "race": args.race,
|
|
"containerLimits": {"cpus": 2, "memoryBytes": 2147483648, "memoryAndSwapBytes": 2147483648, "pidsLimit": 256},
|
|
"wallTimeoutSeconds": args.wall_timeout, "imageIds": {},
|
|
"notes": [
|
|
"Compiler and integration stages run sequentially; at most one 2-CPU/2-GiB validation container is active.",
|
|
"The integration container has network=none, no published ports and a read-only root filesystem. Client, Nginx and origins communicate over loopback inside that container.",
|
|
"Both TLS legs validate certificates; invalid upstream CA and hostname must fail before reaching the handler.",
|
|
"HTTP/2 is verified independently on both legs. This is a functional integration test, not a throughput benchmark or a live Cloudflare deployment.",
|
|
],
|
|
}
|
|
args.output.write_text(json.dumps(report, indent=2) + "\n")
|
|
|
|
def interrupt(signum, frame):
|
|
raise KeyboardInterrupt
|
|
|
|
handlers = {sig: signal.signal(sig, interrupt) for sig in (signal.SIGTERM, signal.SIGHUP)}
|
|
failure = None
|
|
with logPath.open("w") as output:
|
|
runner = ContainerRunner(docker, output, time.monotonic() + args.wall_timeout)
|
|
try:
|
|
for imageName in (args.image, args.nginx_image):
|
|
inspected = subprocess.run([docker, "image", "inspect", imageName, "--format", "{{.Id}}"], capture_output=True, text=True, timeout=15)
|
|
if inspected.returncode:
|
|
print(f"Pulling {imageName}", flush=True)
|
|
runner.execute([docker, "pull", imageName])
|
|
report["imageIds"][imageName] = capture([docker, "image", "inspect", imageName, "--format", "{{.Id}}"])
|
|
with tempfile.TemporaryDirectory(prefix="goOmniHTTP2Proxy") as temporary:
|
|
print("Building proxy integration tests; limits: 2 CPUs, 2048 MiB, 256 tasks", flush=True)
|
|
arguments = [
|
|
"-v", f"{root}:/workspace:ro", "-v", f"{temporary}:/output", "-w", "/workspace",
|
|
"-e", "GOWORK=off", "-e", "GOMAXPROCS=2", "-e", f"CGO_ENABLED={1 if args.race else 0}",
|
|
]
|
|
if args.goproxy:
|
|
arguments += ["-e", f"GOPROXY={args.goproxy}"]
|
|
raceFlag = "-race " if args.race else ""
|
|
arguments += [args.image, "sh", "-c", f"go version && go vet ./transport/http2 && go test {raceFlag}-c -o /output/http2.test ./transport/http2"]
|
|
runner.container(names[0], arguments)
|
|
print(f"Running Nginx HTTP/2 proxy scenarios, {args.count} repetitions; external networking disabled", flush=True)
|
|
runner.container(names[1], [
|
|
"--network", "none", "--read-only", "--tmpfs", "/tmp:rw,nosuid,noexec,size=128m",
|
|
"-v", f"{temporary}:/test:ro", "--entrypoint", "/test/http2.test",
|
|
"-e", "GO_OMNI_HTTP2_PROXY_TEST=1", "-e", "GOMAXPROCS=2",
|
|
args.nginx_image, "-test.v", "-test.run", "^TestNginxHTTP2Proxy$",
|
|
"-test.timeout", "120s", "-test.count", str(args.count),
|
|
])
|
|
log = logPath.read_text()
|
|
for case in ("TLSAndConnectionReuse", "TimeoutAndCancellation", "GoAwayAndRollingDrain"):
|
|
if len(re.findall(r"--- PASS: TestNginxHTTP2Proxy/" + case + r" \(", log)) != args.count:
|
|
raise RuntimeError("missing successful integration case: " + case)
|
|
report["proxyVersions"] = sorted(set(re.findall(r"proxyVersion=(.+)", log)))
|
|
report["results"] = re.findall(r"proxyResult=(.+)", log)
|
|
version = re.search(r"^go version (\S+) (\S+)$", log, re.MULTILINE)
|
|
if version is None or not report["proxyVersions"]:
|
|
raise RuntimeError("missing Go or Nginx version evidence")
|
|
report["goVersion"], report["platform"] = version.groups()
|
|
report["status"] = "passed"
|
|
except (Exception, KeyboardInterrupt) as error:
|
|
failure = str(error) or type(error).__name__
|
|
report["status"], report["error"] = "failed", failure
|
|
finally:
|
|
for sig, handler in handlers.items():
|
|
signal.signal(sig, handler)
|
|
report["commands"] = runner.commands
|
|
report["cleanedContainers"] = runner.cleaned
|
|
report["finishedAt"] = datetime.now(timezone.utc).isoformat()
|
|
output.flush()
|
|
report["logSha256"] = hashlib.sha256(logPath.read_bytes()).hexdigest()
|
|
try:
|
|
after = set(capture([docker, "ps", "--format", "{{.ID}}"]).split())
|
|
if not before.issubset(after):
|
|
raise RuntimeError("a pre-existing container is no longer running")
|
|
report["preExistingContainersStillRunning"] = len(before)
|
|
if set(runner.cleaned) != set(names) and failure is None:
|
|
raise RuntimeError("container cleanup was not confirmed")
|
|
except Exception as error:
|
|
failure = str(error)
|
|
report["status"], report["error"] = "failed", failure
|
|
args.output.write_text(json.dumps(report, indent=2) + "\n")
|
|
if failure:
|
|
raise SystemExit(f"FAIL HTTP/2 proxy verification: {failure}; see {logPath}")
|
|
print(f"PASS HTTP/2 Nginx proxy verification; report: {args.output}", flush=True)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|