#!/usr/bin/env python3 """Build and run isolated actual production Shared/driver/browser lifecycle loops.""" import argparse import hashlib import json import os from pathlib import Path import secrets import shutil import subprocess import time import uuid HERE = Path(__file__).resolve().parent ROOT = HERE.parents[2] def write(path, value): path.write_text(json.dumps(value, indent=2) + '\n') def certs(root): return {str(p.relative_to(root)): hashlib.sha256(p.read_bytes()).hexdigest() for p in sorted(root.rglob('*.der')) if '/own/certs/' in str(p)} def main(): parser = argparse.ArgumentParser(description=__doc__) parser.add_argument('--output', required=True, type=Path, help='Fresh nonexistent task evidence root, includes private PKI; never commit entire output') parser.add_argument('--duration', type=int, default=90) parser.add_argument('--full', action='store_true', help='Requires >=7200 seconds of real completed client runtime') parser.add_argument('--image', default='mcr.microsoft.com/dotnet/sdk:10.0.103-noble') args = parser.parse_args() if args.duration <= 0 or (args.full and args.duration < 7200): parser.error('--full requires duration >=7200; diagnostic duration must be positive') output = args.output.resolve() output.mkdir(parents=True, exist_ok=False) os.chmod(output, 0o700) build = output / 'app' task = output / 'task' task.mkdir() image = json.loads(subprocess.check_output(['docker', 'image', 'inspect', args.image, '--format', '{{json .}}'], text=True)) if image['Architecture'] != 'arm64' or image['Os'] != 'linux': raise RuntimeError('Expected cached official Linux arm64 SDK image') write(output / 'source.json', {'commit': subprocess.check_output(['git', '-C', str(ROOT), 'rev-parse', 'HEAD'], text=True).strip(), 'root': str(ROOT), 'durationSeconds': args.duration, 'mode': 'full' if args.full else 'diagnostic', 'image': {k: image.get(k) for k in ('Id', 'RepoDigests', 'Architecture', 'Os')}, 'network': 'none', 'startedUtc': time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime()), 'scope': 'actual unchanged production Shared/OpcUaClientDriver/AdminUI browser in a repeated same-process loop; SDK 2.0.1 only'}) with (output / 'build.log').open('w') as log: subprocess.run(['dotnet', 'publish', str(HERE / 'Lifecycle.csproj'), '-c', 'Release', '-m:1', '--nologo', '-o', str(build)], stdout=log, stderr=subprocess.STDOUT, check=True, timeout=300) shutil.copy2(HERE / 'container.sh', task / 'container.sh') name = 'otopcua-client-lifecycle-' + uuid.uuid4().hex[:12] env = dict(os.environ, OPCUA_MATRIX_PASSWORD='otua_lifecycle_' + secrets.token_urlsafe(32), LIFECYCLE_SECONDS=str(args.duration), LIFECYCLE_MODE='full' if args.full else 'diagnostic') command = ['docker', 'run', '--rm', '--name', name, '--network', 'none', '-e', 'OPCUA_MATRIX_PASSWORD', '-e', 'LIFECYCLE_SECONDS', '-e', 'LIFECYCLE_MODE', '-v', str(build) + ':/app:ro', '-v', str(task) + ':/task', args.image, 'bash', '/task/container.sh'] write(output / 'command.json', {'argv': command, 'timeoutSeconds': args.duration + 300}) started = time.monotonic() try: with (output / 'container.log').open('w') as log: result = subprocess.run(command, stdout=log, stderr=subprocess.STDOUT, env=env, timeout=args.duration + 300) except BaseException: subprocess.run(['docker', 'rm', '-f', name], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=20) raise summary = json.loads((task / 'summary.json').read_text()) if (task / 'summary.json').exists() else None write(output / 'result.json', {'exitCode': result.returncode, 'containerElapsedSeconds': time.monotonic() - started, 'summary': summary, 'applicationCertificates': certs(task)}) if result.returncode or not summary or (args.full and not summary['fullTwoHourLifecycle']): raise RuntimeError('Lifecycle run failed; inspect task/failure.txt, client.log and server.log: ' + str(output)) print(json.dumps({'output': str(output), 'summary': summary}, indent=2)) if __name__ == '__main__': main()