176 lines
6.4 KiB
Python
176 lines
6.4 KiB
Python
#!/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())
|