feat: initialize XAS test console with native execution
This commit is contained in:
@@ -0,0 +1,175 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Run only the bundled synthetic acceptance task, with bounded lifetime."""
|
||||
import argparse
|
||||
import ctypes
|
||||
from datetime import datetime, timezone
|
||||
import hashlib
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import signal
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
|
||||
MODES = ("success", "failure", "invalid-report", "wait")
|
||||
|
||||
|
||||
def now():
|
||||
return datetime.now(timezone.utc).isoformat()
|
||||
|
||||
|
||||
def write_json(path, value):
|
||||
temporary = path.with_suffix(".tmp")
|
||||
with temporary.open("x", encoding="utf-8") as stream:
|
||||
json.dump(value, stream, ensure_ascii=False, indent=2)
|
||||
stream.write("\n")
|
||||
stream.flush()
|
||||
os.fsync(stream.fileno())
|
||||
temporary.replace(path)
|
||||
|
||||
|
||||
def subreaper():
|
||||
# Adopt the fixture's child so cancellation can reap it even in containers.
|
||||
if sys.platform == "linux":
|
||||
libc = ctypes.CDLL(None, use_errno=True)
|
||||
if libc.prctl(36, 1, 0, 0, 0) != 0:
|
||||
raise OSError(ctypes.get_errno(), "cannot enable child subreaper")
|
||||
|
||||
|
||||
def cleanup(proc):
|
||||
if os.name == "posix":
|
||||
for sig in (signal.SIGTERM, signal.SIGKILL):
|
||||
try:
|
||||
os.killpg(proc.pid, sig)
|
||||
except ProcessLookupError:
|
||||
break
|
||||
if sig == signal.SIGTERM:
|
||||
time.sleep(.15)
|
||||
elif proc.poll() is None:
|
||||
proc.terminate()
|
||||
try:
|
||||
proc.wait(timeout=2)
|
||||
except subprocess.TimeoutExpired:
|
||||
proc.kill()
|
||||
proc.wait(timeout=2)
|
||||
if sys.platform == "linux":
|
||||
deadline = time.monotonic() + 2
|
||||
while True:
|
||||
try:
|
||||
pid, _ = os.waitpid(-1, os.WNOHANG)
|
||||
except ChildProcessError:
|
||||
break
|
||||
if pid == 0:
|
||||
if time.monotonic() >= deadline:
|
||||
raise RuntimeError("child cleanup could not be confirmed")
|
||||
time.sleep(.02)
|
||||
|
||||
|
||||
def read_result(folder, run_id):
|
||||
path = folder / "result.json"
|
||||
if path.is_symlink() or path.stat().st_size > 65536:
|
||||
raise ValueError("invalid result file")
|
||||
result = json.loads(path.read_text(encoding="utf-8"))
|
||||
if (not isinstance(result, dict) or result.get("run_id") != run_id
|
||||
or result.get("synthetic") is not True
|
||||
or type(result.get("checks_passed")) is not int
|
||||
or result["checks_passed"] != 3):
|
||||
raise ValueError("result does not satisfy the acceptance contract")
|
||||
return result
|
||||
|
||||
|
||||
def arguments():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--mode", choices=MODES, default=os.getenv("RD_OPTION_MODE", "success"))
|
||||
parser.add_argument("--run-id", default=os.getenv("RD_JOB_EXECID"))
|
||||
parser.add_argument("--timeout", type=float, default=os.getenv("RD_OPTION_TIMEOUT", "8"))
|
||||
parser.add_argument("--results-root", type=Path,
|
||||
default=Path(os.getenv("XAS_RESULTS_ROOT", "/home/rundeck/test-results")))
|
||||
args = parser.parse_args()
|
||||
if args.mode not in MODES:
|
||||
parser.error("unregistered mode")
|
||||
if not args.run_id or not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9_-]{0,63}", args.run_id):
|
||||
parser.error("run-id must be 1-64 letters, digits, underscores or hyphens")
|
||||
if not math.isfinite(args.timeout) or not 2 <= args.timeout <= 30:
|
||||
parser.error("timeout must be between 2 and 30 seconds")
|
||||
if os.name != "posix" and args.mode == "wait":
|
||||
parser.error("wait/cancellation acceptance requires Linux")
|
||||
return args
|
||||
|
||||
|
||||
def main():
|
||||
args = arguments()
|
||||
os.umask(0o077)
|
||||
args.results_root.mkdir(parents=True, exist_ok=True)
|
||||
folder = args.results_root.resolve() / args.run_id
|
||||
try:
|
||||
folder.mkdir()
|
||||
except FileExistsError:
|
||||
print("Run ID already exists; history was preserved", file=sys.stderr)
|
||||
return 2
|
||||
source = Path(__file__).resolve().parent
|
||||
summary = {"schema_version": 1, "run_id": args.run_id, "synthetic": True,
|
||||
"mode": args.mode, "timeout_seconds": args.timeout,
|
||||
"started_at": now(), "status": "running", "exit_code": None,
|
||||
"source_sha256": {name: hashlib.sha256((source / name).read_bytes()).hexdigest()
|
||||
for name in ("run_smoke.py", "smoke_task.py")}}
|
||||
write_json(folder / "request.json", summary)
|
||||
cancelled = False
|
||||
|
||||
def on_signal(signum, frame):
|
||||
nonlocal cancelled
|
||||
cancelled = True
|
||||
|
||||
signal.signal(signal.SIGTERM, on_signal)
|
||||
signal.signal(signal.SIGINT, on_signal)
|
||||
proc = None
|
||||
rc = 1
|
||||
try:
|
||||
subreaper()
|
||||
proc = subprocess.Popen([sys.executable, "-u", str(source / "smoke_task.py"),
|
||||
args.mode, args.run_id, str(folder)],
|
||||
start_new_session=(os.name == "posix"))
|
||||
deadline = time.monotonic() + args.timeout
|
||||
while proc.poll() is None and not cancelled and time.monotonic() < deadline:
|
||||
time.sleep(.04)
|
||||
summary["exit_code"] = proc.poll()
|
||||
if cancelled:
|
||||
summary["status"], rc = "cancelled", 130
|
||||
elif proc.poll() is None:
|
||||
summary["status"], rc = "timed_out", 124
|
||||
elif proc.returncode != 0:
|
||||
summary["status"] = "failed"
|
||||
else:
|
||||
try:
|
||||
summary["result"] = read_result(folder, args.run_id)
|
||||
summary["status"], rc = "succeeded", 0
|
||||
except (OSError, ValueError, TypeError):
|
||||
summary["status"] = "invalid_result"
|
||||
except (OSError, RuntimeError) as exc:
|
||||
summary["status"] = "runner_error"
|
||||
summary["error_type"] = type(exc).__name__
|
||||
finally:
|
||||
if proc is not None:
|
||||
try:
|
||||
cleanup(proc)
|
||||
summary["cleanup_confirmed"] = True
|
||||
except (OSError, RuntimeError, subprocess.TimeoutExpired) as exc:
|
||||
summary["status"], rc = "cleanup_failed", 1
|
||||
summary["cleanup_confirmed"] = False
|
||||
summary["error_type"] = type(exc).__name__
|
||||
summary["worker_pid"] = proc.pid
|
||||
summary["final_exit_code"] = proc.returncode
|
||||
child_file = folder / "child.pid"
|
||||
if child_file.exists():
|
||||
summary["child_pid"] = int(child_file.read_text(encoding="utf-8"))
|
||||
summary["finished_at"] = now()
|
||||
write_json(folder / "summary.json", summary)
|
||||
print("XAS_ACCEPTANCE_RESULT " + json.dumps(summary, ensure_ascii=False), flush=True)
|
||||
return rc
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Reference in New Issue
Block a user