Files
lmxopcua/scripts/validation/opcua-lifecycle-memory/linux_pair.py
T

114 lines
6.3 KiB
Python

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