#!/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())