Files
go-omni/scripts/verifyModules.py

209 lines
13 KiB
Python

#!/usr/bin/env python3
"""Verify feature modules and real Redis in sequential resource-limited containers."""
import argparse
from datetime import datetime, timezone
import hashlib
import json
from pathlib import Path
import shutil
import signal
import subprocess
import tempfile
import time
import uuid
def capture(command):
return subprocess.check_output(command, text=True, timeout=15).strip()
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--image", default="golang:1.25")
parser.add_argument("--redis-image", default="redis:7.4-alpine")
parser.add_argument("--advanced", action="store_true", help="also verify shared state, durable queue, S3 and MySQL migrations")
parser.add_argument("--s3-image", default="quay.io/minio/minio:RELEASE.2025-09-07T16-13-09Z")
parser.add_argument("--mysql-image", default="m.daocloud.io/docker.io/library/mysql:8.4")
parser.add_argument("--goproxy", default="")
parser.add_argument("--wall-timeout", type=int, default=900)
parser.add_argument("--output", type=Path, default=Path("performance/modulesLinux.json"))
args = parser.parse_args()
if args.wall_timeout < 1:
parser.error("wall timeout must be positive")
docker = shutil.which("docker")
if not docker:
raise SystemExit("docker is required")
root = Path(__file__).resolve().parent.parent
running = capture([docker, "ps", "--format", "{{.ID}} {{.Names}}"])
if any("go-omni-" in line for line in running.splitlines()):
raise SystemExit("another go-omni validation container is active")
before = {line.split()[0] for line in running.splitlines()}
output_path = args.output.resolve()
output_path.parent.mkdir(parents=True, exist_ok=True)
log_path = output_path.with_suffix(".txt")
prefix = "go-omni-modules-" + uuid.uuid4().hex[:12]
stages = ("build", "redis", "s3", "mysql") if args.advanced else ("build", "redis")
names = [prefix + "-" + stage for stage in stages]
paths = [root / "go.mod", root / "go.sum", root / "httpx/context.go", Path(__file__).resolve()]
directories = ["cache", "httpc", "task", "middleware", "cmd/go-omni", "examples/modules"]
if args.advanced:
directories += ["auth", "config", "logx", "openapi", "telemetry", "resilience", "storage", "queue", "audit", "pagination", "database/migrate", "internal/storagefixture", "examples/platform"]
for directory in directories:
paths.extend(p for p in (root / directory).rglob("*") if p.is_file() and p.suffix in (".go", ".tmpl"))
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": {str(p.relative_to(root)): hashlib.sha256(p.read_bytes()).hexdigest() for p in sorted(set(paths))},
"containerLimits": {"cpus": 2, "memoryBytes": 2147483648, "memoryAndSwapBytes": 2147483648, "pidsLimit": 256},
"wallTimeoutSeconds": args.wall_timeout, "imageIds": {}, "commands": [], "cleanedContainers": [],
"notes": [
"Full repository race/vet/build plus independent generated-module CRUD in the compiler container.",
"Real Redis uses a statically linked non-race test binary, loopback only, network=none and no published ports.",
"At most one validation container; no pre-existing service or database is accessed.",
],
}
output_path.write_text(json.dumps(report, indent=2) + "\n")
deadline = time.monotonic() + args.wall_timeout
failure = None
def interrupt(signum, frame):
raise KeyboardInterrupt
previous = {sig: signal.signal(sig, interrupt) for sig in (signal.SIGTERM, signal.SIGHUP)}
with log_path.open("w") as log:
def execute(command):
remaining = deadline - time.monotonic()
if remaining <= 0:
raise TimeoutError("module verification wall timeout")
report["commands"].append(command)
subprocess.run(command, stdout=log, stderr=subprocess.STDOUT, check=True, timeout=remaining)
log.flush()
def container(name, arguments):
try:
execute([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)
finally:
result = subprocess.run([docker, "rm", "--force", name], capture_output=True, text=True, timeout=15)
if result.returncode and "No such container" not in result.stderr:
raise RuntimeError("could not clean up " + name)
report["cleanedContainers"].append(name)
try:
images = [args.image, args.redis_image]
if args.advanced:
images += [args.s3_image, args.mysql_image]
for image in images:
result = subprocess.run([docker, "image", "inspect", image], capture_output=True, timeout=15)
if result.returncode:
print("Pulling " + image, flush=True)
execute([docker, "pull", image])
report["imageIds"][image] = capture([docker, "image", "inspect", image, "--format", "{{.Id}}"])
with tempfile.TemporaryDirectory(prefix="goOmniModules") as temporary:
# The Redis stage runs as an unprivileged uid and must traverse
# this directory to execute the read-only test binary.
Path(temporary).chmod(0o755)
print("Full Linux race/vet/build and generated-project CRUD; 2 CPUs, 2048 MiB, 256 pids", flush=True)
arguments = ["-v", f"{root}:/workspace:ro", "-v", f"{temporary}:/output", "-w", "/workspace",
"-e", "GOWORK=off", "-e", "GOMAXPROCS=2", "-e", "GO_OMNI_SCAFFOLD_INTEGRATION=1"]
if args.goproxy:
arguments += ["-e", "GOPROXY=" + args.goproxy]
checks = "go version && go test -race -count=1 -timeout=240s ./... && go vet ./... && go build ./... && CGO_ENABLED=0 go test -c -o /output/redis.test ./cache/redis"
if args.advanced:
for binary, package in (("shared", "middleware/redis"), ("queue", "queue/redis"), ("s3", "storage/s3"), ("mysql", "database/migrate")):
checks += f" && CGO_ENABLED=0 go test -c -o /output/{binary}.test ./{package}"
arguments += [args.image, "sh", "-c", checks]
container(names[0], arguments)
print("Real Redis TTL/read/write/delete/failure checks; external network disabled", flush=True)
redis_check = '''set -eu
redis-server --version
redis-server --bind 127.0.0.1 --port 6379 --save '' --appendonly no --maxmemory 32mb --daemonize yes --pidfile /tmp/redis.pid --logfile /tmp/redis.log --dir /tmp
trap 'redis-cli -h 127.0.0.1 shutdown nosave >/dev/null 2>&1 || true' EXIT
attempt=0
until redis-cli -h 127.0.0.1 ping >/dev/null 2>&1; do
attempt=$((attempt + 1))
if [ "$attempt" -gt 50 ]; then cat /tmp/redis.log; exit 1; fi
sleep 0.1
done
/test/redis.test -test.v -test.run '^TestRedisIntegration$' -test.count=1 -test.timeout=30s
'''
if args.advanced:
redis_check += "/test/shared.test -test.v -test.count=1 -test.timeout=45s\n/test/queue.test -test.v -test.count=1 -test.timeout=45s\n"
container(names[1], ["--network", "none", "--read-only", "--cap-drop", "ALL", "--security-opt", "no-new-privileges",
"--user", "999:999", "--tmpfs", "/tmp:rw,nosuid,noexec,size=64m,mode=1777",
"-v", f"{temporary}:/test:ro", "--entrypoint", "sh",
"-e", "GOMAXPROCS=2", "-e", "GO_OMNI_REDIS_TEST_ADDR=127.0.0.1:6379",
args.redis_image, "-c", redis_check])
if args.advanced:
isolated = ["--network", "none", "--read-only", "--cap-drop", "ALL", "--security-opt", "no-new-privileges",
"-v", f"{temporary}:/test:ro", "--entrypoint", "sh", "-e", "GOMAXPROCS=2"]
print("Real S3 object operations and signature verification in isolated MinIO", flush=True)
s3_check = '''set -eu
minio --version
minio server /data --address 127.0.0.1:9000 --console-address 127.0.0.1:9001 >/tmp/minio.log 2>&1 &
server=$!
trap 'kill "$server" 2>/dev/null || true; wait "$server" 2>/dev/null || true' EXIT
/test/s3.test -test.v -test.run '^TestS3Integration$' -test.count=1 -test.timeout=45s || { cat /tmp/minio.log; exit 1; }
'''
container(names[2], isolated + ["--user", "1000:1000", "--tmpfs", "/tmp:rw,nosuid,noexec,size=64m,mode=1777",
"--tmpfs", "/data:rw,nosuid,noexec,size=128m,mode=1777", "-e", "MINIO_ROOT_USER=goOmniTest", "-e", "MINIO_ROOT_PASSWORD=goOmniTestSecret",
"-e", "GO_OMNI_S3_TEST_ADDR=127.0.0.1:9000", args.s3_image, "-c", s3_check])
print("Real MySQL migrations, repeat apply, dirty-state blocking and SQL spans", flush=True)
mysql_check = '''set -eu
mysqld --version
mkdir /tmp/mysql
mysqld --no-defaults --initialize-insecure --datadir=/tmp/mysql --innodb-redo-log-capacity=67108864 >/tmp/init.log 2>&1 || { cat /tmp/init.log; exit 1; }
mysqld --no-defaults --datadir=/tmp/mysql --socket=/tmp/mysql.sock --pid-file=/tmp/mysql.pid --bind-address=127.0.0.1 --port=3306 --mysqlx=OFF --skip-log-bin --skip-name-resolve --innodb-buffer-pool-size=134217728 --innodb-redo-log-capacity=67108864 --max-connections=8 --log-error=/tmp/mysql.log &
server=$!
trap 'kill "$server" 2>/dev/null || true; wait "$server" 2>/dev/null || true' EXIT
attempt=0
until mysql --no-defaults --socket=/tmp/mysql.sock -uroot -e 'SELECT 1' >/dev/null 2>&1; do
attempt=$((attempt + 1)); if [ "$attempt" -gt 200 ]; then cat /tmp/mysql.log; exit 1; fi; sleep 0.1
done
mysql --no-defaults --socket=/tmp/mysql.sock -uroot -e 'CREATE DATABASE omni_test'
/test/mysql.test -test.v -test.run '^TestMySQLIntegration$' -test.count=1 -test.timeout=60s
'''
container(names[3], isolated + ["--user", "999:999", "--tmpfs", "/tmp:rw,nosuid,noexec,size=512m,mode=1777",
"--tmpfs", "/var/lib/mysql:rw,nosuid,noexec,size=1m,mode=1777",
"-e", "GO_OMNI_MIGRATE_TEST_DSN=root@unix(/tmp/mysql.sock)/omni_test?parseTime=true&timeout=2s&readTimeout=5s&writeTimeout=5s",
args.mysql_image, "-c", mysql_check])
log.flush()
content = log_path.read_text()
if "--- PASS: TestRedisIntegration" not in content:
raise RuntimeError("missing real Redis pass evidence")
if args.advanced:
for test in ("TestSharedRedisIntegration", "TestExpiredTargetBeyondCleanupBatch", "TestQueueRedisIntegration", "TestS3Integration", "TestMySQLIntegration"):
if "--- PASS: " + test not in content:
raise RuntimeError("missing integration pass evidence: " + test)
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 previous.items():
signal.signal(sig, handler)
log.flush()
report["logSha256"] = hashlib.sha256(log_path.read_bytes()).hexdigest()
report["finishedAt"] = datetime.now(timezone.utc).isoformat()
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 failure is None and set(names) != set(report["cleanedContainers"]):
raise RuntimeError("container cleanup incomplete")
except Exception as error:
failure = str(error)
report["status"], report["error"] = "failed", failure
output_path.write_text(json.dumps(report, indent=2) + "\n")
if failure:
raise SystemExit(f"FAIL: {failure}; see {log_path}")
print(f"PASS feature module acceptance; report: {output_path}", flush=True)
if __name__ == "__main__":
main()