114 lines
6.3 KiB
Python
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))
|