#!/usr/bin/env python3 """Run two already-built public lifecycle controls in isolated, offline Linux containers.""" import argparse from datetime import datetime, timezone import hashlib import json import os from pathlib import Path import secrets import subprocess import time import uuid def latest_sample(path): # A concurrently-written final line may be incomplete. All complete rows remain in the source log. for row in reversed(path.read_text().splitlines()): try: value = json.loads(row) except json.JSONDecodeError: continue if value.get("kind") == "sample": return value return None def run(options): options.output = options.output.resolve() options.output.mkdir(parents=True, exist_ok=False) processes = [] environment = os.environ.copy() environment["OPCUA_MEMORY_PASSWORD"] = secrets.token_urlsafe(32) # Both receive the same environment. SDK1 ignores the SDK2 diagnostic opt-in. environment["OPCUA_CERTIFICATE_LEAK_TRACKING"] = "1" started = time.monotonic() try: for label, directory in [("baseline", options.baseline), ("candidate", options.candidate)]: directory = directory.resolve(strict=True) for required in ["control.dll", "control.deps.json", "control.runtimeconfig.json"]: if not (directory / required).is_file(): raise FileNotFoundError("Use a complete, canonically-built control output: missing " + required) output = options.output / label output.mkdir() container = "opcua-memory-" + label + "-" + uuid.uuid4().hex[:10] shell = ('dotnet /app/control.dll /task/private-fixture ' + str(options.seconds) + ' > /task/samples.jsonl 2>&1 & child=$!; echo "$child" > /task/dotnet.pid; ' 'wait "$child"; status=$?; echo "$status" > /task/exit-status.txt; exit "$status"') command = ["docker", "run", "--rm", "--name", container, "--network", "none", "--env", "OPCUA_MEMORY_PASSWORD", "--env", "OPCUA_CERTIFICATE_LEAK_TRACKING", "-v", str(directory) + ":/app:ro", "-v", str(output) + ":/task", options.image, "bash", "-c", shell] manifest = {"argv": command, "fullGate": False, "seconds": options.seconds, "environmentNamesOnly": ["OPCUA_MEMORY_PASSWORD", "OPCUA_CERTIFICATE_LEAK_TRACKING"], "startedUtc": datetime.now(timezone.utc).isoformat(), "runtimeFiles": {p.name: hashlib.sha256(p.read_bytes()).hexdigest() for p in sorted(directory.iterdir()) if p.is_file()}} (output / "command.json").write_text(json.dumps(manifest, indent=2) + "\n") stream = (output / "container.log").open("w") process = subprocess.Popen(command, env=environment, stdout=stream, stderr=subprocess.STDOUT) processes.append((label, container, output, stream, process)) next_sample = started + 60 while any(process.poll() is None for _, _, _, _, process in processes): if time.monotonic() - started > options.seconds + 240: raise TimeoutError("Diagnostic controls exceeded bounded shutdown allowance") if time.monotonic() >= next_sample: for label, container, output, _, process in processes: pid_file = output / "dotnet.pid" if process.poll() is not None or not pid_file.exists(): continue pid = pid_file.read_text().strip() if not pid.isdecimal(): raise RuntimeError("Unexpected task-owned process id") stamp = str(int(time.time())) latest = latest_sample(output / "samples.jsonl") with (output / "observations.jsonl").open("a") as observed: observed.write(json.dumps({"utc": datetime.now(timezone.utc).isoformat(), "seconds": time.monotonic() - started, "sample": latest}) + "\n") for name in ["smaps", "smaps_rollup", "status"]: with (output / (stamp + "-" + name + ".txt")).open("w") as destination: result = subprocess.run(["docker", "exec", container, "cat", "/proc/" + pid + "/" + name], stdout=destination, stderr=subprocess.STDOUT, timeout=15) if result.returncode and process.poll() is None: raise RuntimeError(label + " live process diagnostic read failed") next_sample += 60 time.sleep(.5) statuses = [] for label, _, output, _, process in processes: status = process.wait() (output / "docker-exit-status.txt").write_text(str(status) + "\n") print(label + " exit " + str(status), flush=True) statuses.append(status) return 0 if all(status == 0 for status in statuses) else 1 finally: for _, container, _, stream, process in processes: if process.poll() is None: subprocess.run(["docker", "stop", "--time", "10", container], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=20) process.wait(timeout=20) stream.close() if __name__ == "__main__": parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--baseline", type=Path, required=True, help="Already-built control output directory") parser.add_argument("--candidate", type=Path, required=True, help="Already-built control output directory") parser.add_argument("--output", type=Path, required=True, help="New local diagnostic directory") parser.add_argument("--image", required=True, help="Immutable Linux .NET10 image digest") parser.add_argument("--seconds", type=int, default=1800) options = parser.parse_args() if not 1 <= options.seconds <= 1800 or not options.image.startswith("sha256:"): parser.error("Use an immutable image digest and duration 1..1800; never a full-soak gate") raise SystemExit(run(options))