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

135 lines
7.1 KiB
Python

#!/usr/bin/env python3
"""Build opt-in controls against existing published DLLs; never rebuild product code."""
import argparse
import hashlib
import json
import os
from pathlib import Path
import secrets
import subprocess
import sys
import time
import xml.etree.ElementTree as ET
def digest(path):
return hashlib.sha256(path.read_bytes()).hexdigest()
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("kind", choices=["lifecycle", "certificate", "telemetry"])
parser.add_argument("--references", type=Path, action="append", required=True,
help="Existing published DLL directory; first directory wins duplicate names")
parser.add_argument("--output", type=Path, required=True, help="New local artifact directory")
parser.add_argument("--sdk2", action="store_true")
parser.add_argument("--seconds", type=int, default=600)
parser.add_argument("--mode", help="Certificate: public/pfx/shared-copy/sdk-copy; telemetry: dispose/undisposed")
parser.add_argument("--build-only", action="store_true")
parser.add_argument("--certificate-tracking", action="store_true", help="SDK2 opt-in allocation-stack diagnostics")
options = parser.parse_args()
if options.kind != "lifecycle" and not options.sdk2:
parser.error("certificate and telemetry controls require --sdk2")
if not 1 <= options.seconds <= 1800:
parser.error("Diagnostic duration must be 1..1800 seconds; this is never a full soak")
options.output = options.output.resolve()
options.output.mkdir(parents=True, exist_ok=False)
names = {"lifecycle": "LifecycleControl.cs", "certificate": "CertificateControl.cs", "telemetry": "TelemetryControl.cs"}
source = Path(__file__).resolve().parent / names[options.kind]
references = {}
for directory in options.references:
directory = directory.resolve(strict=True)
for dll in sorted(directory.glob("*.dll")):
references.setdefault(dll.name, dll)
if "Opc.Ua.Core.dll" not in references:
parser.error("References must contain actual OPC UA managed assemblies")
project = ET.Element("Project", Sdk="Microsoft.NET.Sdk")
properties = ET.SubElement(project, "PropertyGroup")
for key, value in {"OutputType": "Exe", "TargetFramework": "net10.0", "ImplicitUsings": "enable",
"Nullable": "enable", "EnableDefaultCompileItems": "false",
"DefineConstants": "SDK2" if options.sdk2 else ""}.items():
ET.SubElement(properties, key).text = value
items = ET.SubElement(project, "ItemGroup")
ET.SubElement(items, "FrameworkReference", Include="Microsoft.AspNetCore.App")
ET.SubElement(items, "Compile", Include=str(source))
for name, path in references.items():
reference = ET.SubElement(items, "Reference", Include=path.stem)
ET.SubElement(reference, "HintPath").text = str(path)
project_path = options.output / "control.csproj"
ET.indent(project)
ET.ElementTree(project).write(project_path, encoding="unicode")
manifest = {"kind": options.kind, "sdk2": options.sdk2, "seconds": options.seconds,
"fullSoakGate": False, "source": {source.name: digest(source)},
"referencePrecedence": "first directory wins duplicate names",
"references": {name: digest(path) for name, path in references.items()}}
(options.output / "provenance.json").write_text(json.dumps(manifest, indent=2) + "\n")
with (options.output / "build.log").open("w") as log:
result = subprocess.run(["dotnet", "build", str(project_path), "-c", "Release", "-m:2"],
stdout=log, stderr=subprocess.STDOUT)
if result.returncode:
return result.returncode
if options.build_only:
return 0
executable = options.output / "bin/Release/net10.0/control.dll"
environment = os.environ.copy()
environment["OPCUA_MEMORY_PASSWORD"] = secrets.token_urlsafe(32)
if options.certificate_tracking:
environment["OPCUA_CERTIFICATE_LEAK_TRACKING"] = "1"
if options.kind == "lifecycle":
command = ["dotnet", str(executable), str(options.output / "private-fixture"), str(options.seconds)]
with (options.output / "samples.jsonl").open("w") as log:
result = subprocess.run(command, env=environment, stdout=log, stderr=subprocess.STDOUT,
timeout=options.seconds + 180)
status = result.returncode
elif options.kind == "telemetry":
if options.mode not in ["dispose", "undisposed"]:
parser.error("Telemetry mode must be dispose or undisposed")
with (options.output / "samples.jsonl").open("w") as log:
result = subprocess.run(["dotnet", str(executable), options.mode], env=environment,
stdout=log, stderr=subprocess.STDOUT, timeout=180)
status = result.returncode
else:
if sys.platform != "darwin":
parser.error("Native certificate control currently requires macOS heap/vmmap")
if options.mode not in ["public", "pfx", "shared-copy", "sdk-copy"]:
parser.error("Certificate mode must be public, pfx, shared-copy or sdk-copy")
status = certificate_control(executable, options.output, options.mode, environment)
(options.output / "exit-status.txt").write_text(str(status) + "\n")
return status
def certificate_control(executable, output, mode, environment):
# Native snapshots contain process metadata; retain locally, do not publish them unredacted.
with (output / "samples.jsonl").open("w") as log:
process = subprocess.Popen(["dotnet", str(executable), mode, str(output)], env=environment,
stdout=log, stderr=subprocess.STDOUT)
try:
deadline = time.monotonic() + 180
while not (output / "samples.jsonl").stat().st_size:
if process.poll() is not None or time.monotonic() > deadline:
raise RuntimeError("Certificate control failed before ready")
time.sleep(.05)
for label in ["before", "after"]:
if label == "after":
(output / "go").touch()
while True:
rows = (output / "samples.jsonl").read_text().splitlines()
if rows and json.loads(rows[-1]).get("kind") == "done":
break
if process.poll() is not None or time.monotonic() > deadline:
raise RuntimeError("Certificate control did not finish")
time.sleep(.1)
with (output / ("heap-" + label + ".txt")).open("w") as log_heap:
subprocess.run(["/usr/bin/heap", "--noContent", "-s", str(process.pid)],
stdout=log_heap, stderr=subprocess.STDOUT, timeout=20, check=True)
(output / "stop").touch()
return process.wait(timeout=10)
finally:
if process.poll() is None:
process.terminate()
process.wait(timeout=10)
if __name__ == "__main__":
raise SystemExit(main())