test: add isolated cross-version application compatibility harness
This commit is contained in:
@@ -0,0 +1,194 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Separate-process production application compatibility gate (no mixed SDK process)."""
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import secrets
|
||||
import shutil
|
||||
import subprocess
|
||||
import time
|
||||
import uuid
|
||||
import xml.etree.ElementTree as ET
|
||||
|
||||
HERE = Path(__file__).resolve().parent
|
||||
HOST = 'src/Server/ZB.MOM.WW.OtOpcUa.Host/ZB.MOM.WW.OtOpcUa.Host.csproj'
|
||||
CLIENT = 'src/Client/ZB.MOM.WW.OtOpcUa.Client.Shared/ZB.MOM.WW.OtOpcUa.Client.Shared.csproj'
|
||||
DRIVER = 'src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.csproj'
|
||||
CLI = 'src/Client/ZB.MOM.WW.OtOpcUa.Client.CLI/ZB.MOM.WW.OtOpcUa.Client.CLI.csproj'
|
||||
|
||||
|
||||
def write_json(path, value):
|
||||
path.write_text(json.dumps(value, indent=2) + '\n')
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument('--baseline-root', required=True, type=Path)
|
||||
parser.add_argument('--baseline-artifacts', required=True, type=Path, help='Existing immutable host/ and client/ publishes')
|
||||
parser.add_argument('--candidate-root', required=True, type=Path, help='Dedicated candidate checkout; this script builds it')
|
||||
parser.add_argument('--container-image', default='mcr.microsoft.com/dotnet/sdk:10.0.103-noble', help='Disposable local driver/CLI isolation; must supply .NET 10 and bash')
|
||||
parser.add_argument('--output', required=True, type=Path, help='Fresh nonexistent evidence directory')
|
||||
args = parser.parse_args()
|
||||
args.baseline_root = args.baseline_root.resolve()
|
||||
args.candidate_root = args.candidate_root.resolve()
|
||||
args.baseline_artifacts = args.baseline_artifacts.resolve()
|
||||
args.output = args.output.resolve()
|
||||
args.output.mkdir(parents=True, exist_ok=False)
|
||||
source_info = {}
|
||||
for name, root in [('baseline', args.baseline_root), ('candidate', args.candidate_root)]:
|
||||
source_info[name] = {'root': str(root), 'commit': subprocess.check_output(['git', '-C', str(root), 'rev-parse', 'HEAD'], text=True).strip(),
|
||||
'sdkPackages': str(root / 'Directory.Packages.props')}
|
||||
source_info['scope'] = 'Loopback application contract and 3 warmed 50-operation rounds per scalar/Int32[3] read/write kind; shared development host, no full load/reconnect/failover/backend/soak performance claim.'
|
||||
source_info['dateUtc'] = time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime())
|
||||
write_json(args.output / 'source-info.json', source_info)
|
||||
commands = []
|
||||
matrix = []
|
||||
env = dict(os.environ, OPCUA_MATRIX_PASSWORD='otua_matrix_' + secrets.token_urlsafe(32))
|
||||
env.pop('OPCUA_MATRIX_CLIENT_PKI', None)
|
||||
env.pop('OPCUA_MATRIX_SERVER_SECURITY', None)
|
||||
|
||||
def run(command, log, process_env=env, timeout=300):
|
||||
commands.append({'argv': ['<ephemeral-key>' if x == env['OPCUA_MATRIX_PASSWORD'] else str(x) for x in command], 'log': str(log)})
|
||||
with log.open('w') as stream:
|
||||
result = subprocess.run([str(x) for x in command], stdout=stream, stderr=subprocess.STDOUT, env=process_env, timeout=timeout)
|
||||
return result.returncode
|
||||
|
||||
def build(version):
|
||||
folder = args.output / ('build-' + version)
|
||||
folder.mkdir()
|
||||
project = ET.Element('Project', Sdk='Microsoft.NET.Sdk')
|
||||
props = ET.SubElement(project, 'PropertyGroup')
|
||||
for k, v in {'OutputType': 'Exe', 'TargetFramework': 'net10.0', 'Nullable': 'enable', 'ImplicitUsings': 'enable', 'LangVersion': 'latest', 'DefineConstants': 'SDK2' if version == 'candidate' else ''}.items():
|
||||
ET.SubElement(props, k).text = v
|
||||
items = ET.SubElement(project, 'ItemGroup')
|
||||
ET.SubElement(items, 'FrameworkReference', Include='Microsoft.AspNetCore.App')
|
||||
if version == 'candidate':
|
||||
for path in (HOST, CLIENT, DRIVER):
|
||||
ET.SubElement(items, 'ProjectReference', Include=str(args.candidate_root / path))
|
||||
else:
|
||||
dlls = {}
|
||||
for path in ('host', 'client'):
|
||||
for dll in (args.baseline_artifacts / path).glob('*.dll'):
|
||||
dlls[dll.name] = dll
|
||||
for name, dll in sorted(dlls.items()):
|
||||
ref = ET.SubElement(items, 'Reference', Include=name[:-4])
|
||||
ET.SubElement(ref, 'HintPath').text = str(dll)
|
||||
ET.SubElement(ref, 'Private').text = 'true'
|
||||
ET.SubElement(items, 'Compile', Include=str(HERE / 'ProcessHost.cs'))
|
||||
csproj = folder / 'ProcessHost.csproj'
|
||||
ET.ElementTree(project).write(csproj, encoding='unicode')
|
||||
status = run(['dotnet', 'build', csproj, '-c', 'Release', '-m:1', '--nologo'], folder / 'build.log')
|
||||
if status:
|
||||
raise RuntimeError(f'{version} harness build failed ({status}); see {folder / "build.log"}')
|
||||
return folder / 'bin/Release/net10.0/ProcessHost.dll'
|
||||
|
||||
def certificates(source, target):
|
||||
target.mkdir(parents=True, exist_ok=True)
|
||||
files = list(source.glob('*'))
|
||||
if not files:
|
||||
raise RuntimeError('No public certificates at ' + str(source))
|
||||
for file in files:
|
||||
shutil.copy2(file, target / file.name)
|
||||
|
||||
def receipt(paths):
|
||||
return [{'path': str(p), 'sha256': hashlib.sha256(p.read_bytes()).hexdigest(), 'bytes': p.stat().st_size} for p in sorted(set(paths)) if p.is_file()]
|
||||
|
||||
baseline = candidate = candidate_cli = None
|
||||
try:
|
||||
# Baseline builds only the portable harness against the exact published production DLLs.
|
||||
baseline = build('baseline')
|
||||
candidate = build('candidate')
|
||||
candidate_cli = args.output / 'candidate-cli'
|
||||
status = run(['dotnet', 'publish', args.candidate_root / CLI, '-c', 'Release', '-o', candidate_cli, '-m:1', '--nologo'], args.output / 'candidate-cli-build.log')
|
||||
if status:
|
||||
raise RuntimeError('Candidate CLI publish failed')
|
||||
for name, client, server in [('baseline-client-to-baseline-server', baseline, baseline), ('baseline-client-to-candidate-server', baseline, candidate), ('candidate-client-to-baseline-server', candidate, baseline), ('candidate-client-to-candidate-server', candidate, candidate)]:
|
||||
leg = args.output / name
|
||||
leg.mkdir()
|
||||
server_root = leg / 'server'
|
||||
client_root = leg / 'shared'
|
||||
server_root.mkdir()
|
||||
client_root.mkdir()
|
||||
status = run(['dotnet', client, 'prepare', client_root], leg / 'prepare.log')
|
||||
if status:
|
||||
matrix.append({'name': name, 'status': 'failed', 'stage': 'client-certificate-preparation'})
|
||||
continue
|
||||
certificates(client_root / 'client-pki/own/certs', server_root / 'server-pki/trusted/certs')
|
||||
server_log = (leg / 'server.log').open('w')
|
||||
command = ['dotnet', str(server), 'server', str(server_root)]
|
||||
commands.append({'argv': command, 'log': str(leg / 'server.log')})
|
||||
proc = subprocess.Popen(command, stdout=server_log, stderr=subprocess.STDOUT, env=env)
|
||||
try:
|
||||
deadline = time.monotonic() + 60
|
||||
while not (server_root / 'ready.json').exists():
|
||||
if proc.poll() is not None or time.monotonic() > deadline:
|
||||
raise RuntimeError(f'{name}: server did not become ready; see server.log')
|
||||
time.sleep(.1)
|
||||
ready = json.loads((server_root / 'ready.json').read_text())
|
||||
certificates(server_root / 'server-pki/own/certs', client_root / 'client-pki/trusted/certs')
|
||||
status = run(['dotnet', client, 'client', client_root, ready['endpoint']], leg / 'shared.log')
|
||||
result = json.loads((client_root / 'result.json').read_text())
|
||||
history = [json.loads(x) for x in (server_root / 'history-requests.jsonl').read_text().splitlines()] if (server_root / 'history-requests.jsonl').exists() else []
|
||||
result.update({'name': name, 'server': ready, 'status': 'passed' if status == 0 else 'failed', 'historyBackendRequests': history, 'certificateValidation': 'explicit trusted peers; server and Shared client autoaccept disabled', 'authentication': 'ephemeral API-key-shaped credential, exact fixture authenticator; real authority/ACL/method gates'})
|
||||
write_json(leg / 'leg.json', result)
|
||||
matrix.append(result)
|
||||
except Exception as exc:
|
||||
matrix.append({'name': name, 'status': 'failed', 'error': str(exc)})
|
||||
finally:
|
||||
(server_root / 'stop').touch()
|
||||
try:
|
||||
proc.wait(timeout=15)
|
||||
except subprocess.TimeoutExpired:
|
||||
proc.terminate()
|
||||
proc.wait(timeout=10)
|
||||
server_log.close()
|
||||
image_info = subprocess.check_output(['docker', 'image', 'inspect', args.container_image, '--format', '{{json .}}'], text=True)
|
||||
image_data = json.loads(image_info)
|
||||
write_json(args.output / 'container-image.json', {k: image_data.get(k) for k in ('Id', 'RepoDigests', 'Architecture', 'Os')})
|
||||
for server_name, server in [('baseline', baseline), ('candidate', candidate)]:
|
||||
name = 'candidate-driver-cli-to-' + server_name + '-server'
|
||||
leg = args.output / name
|
||||
leg.mkdir()
|
||||
shutil.copy2(HERE / 'container-leg.sh', leg / 'run.sh')
|
||||
container_name = 'otopcua-sdk2-matrix-' + uuid.uuid4().hex[:12]
|
||||
command = ['docker', 'run', '--rm', '--name', container_name, '--network', 'none', '-e', 'OPCUA_MATRIX_PASSWORD',
|
||||
'-v', str(candidate.parent) + ':/candidate:ro', '-v', str(server.parent) + ':/server:ro',
|
||||
'-v', str(candidate_cli) + ':/cli:ro', '-v', str(leg) + ':/task', args.container_image, 'bash', '/task/run.sh']
|
||||
try:
|
||||
status = run(command, leg / 'container.log')
|
||||
finally:
|
||||
subprocess.run(['docker', 'rm', '-f', container_name], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||||
if (leg / 'driver/result.json').exists():
|
||||
result = json.loads((leg / 'driver/result.json').read_text())
|
||||
else:
|
||||
result = {'cases': [], 'error': 'Driver result missing; inspect preparation/container/server logs'}
|
||||
for operation, expected in [('read', 'Value: 92'), ('raw-history', '25 values returned.'), ('event-history', '25 events returned.')]:
|
||||
log = leg / ('cli-' + operation + '.log')
|
||||
passed = log.exists() and expected in log.read_text()
|
||||
result['cases'].append({'name': 'production-CLI-' + operation, 'status': 'passed' if passed else 'failed', 'log': str(log), 'expectedOutput': expected})
|
||||
result.update({'name': name, 'status': 'passed' if status == 0 and all(x['status'] == 'passed' for x in result['cases']) else 'failed',
|
||||
'container': container_name, 'network': 'none; all peers in task container, separate SDK processes',
|
||||
'driverCertificateValidation': 'explicit trusted peers; autoaccept disabled', 'cliCertificateValidation': 'explicit exchanged peers; CLI always configures AutoAcceptCertificates=true (CommandBase.CreateConnectionSettings)',
|
||||
'server': json.loads((leg / 'server/ready.json').read_text()) if (leg / 'server/ready.json').exists() else None})
|
||||
write_json(leg / 'leg.json', result)
|
||||
matrix.append(result)
|
||||
|
||||
finally:
|
||||
paths = [p for p in (baseline, candidate, args.baseline_root / 'Directory.Packages.props', args.candidate_root / 'Directory.Packages.props', HERE / 'ProcessHost.cs', HERE / 'run.py', HERE / 'container-leg.sh') if p is not None]
|
||||
for folder in (args.baseline_artifacts / 'host', args.baseline_artifacts / 'client', candidate.parent if candidate else None, candidate_cli):
|
||||
if folder is not None:
|
||||
paths.extend(folder.glob('Opc.Ua*.dll'))
|
||||
paths.extend(folder.glob('*.deps.json'))
|
||||
paths.extend(folder.glob('ZB.MOM.WW.OtOpcUa*.dll'))
|
||||
paths.extend(folder.glob('OtOpcUa.Host.dll'))
|
||||
paths.extend(folder.glob('*.runtimeconfig.json'))
|
||||
write_json(args.output / 'artifact-hashes.json', receipt(paths))
|
||||
write_json(args.output / 'commands.json', commands)
|
||||
write_json(args.output / 'matrix.json', matrix)
|
||||
return 1 if not matrix or any(x['status'] != 'passed' for x in matrix) else 0
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
raise SystemExit(main())
|
||||
Reference in New Issue
Block a user