209 lines
13 KiB
Python
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()
|