Record the finite dependency search, Wi-Fi maintenance blocker, and pinned icon provenance. Add bounded host orchestration and fixture coverage, and update release documentation with current evidence.
151 lines
6.0 KiB
Python
151 lines
6.0 KiB
Python
#!/usr/bin/env python3
|
|
"""Bounded Phase 9 host validation; build and local interop are explicit opt-ins."""
|
|
import argparse
|
|
from dataclasses import dataclass
|
|
import math
|
|
import os
|
|
from pathlib import Path
|
|
import signal
|
|
import subprocess
|
|
import sys
|
|
import time
|
|
|
|
ROOT = Path(__file__).resolve().parents[1]
|
|
BUILD = Path('.pio/build/esp32-s3-devkitc-1-n16r8')
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class Command:
|
|
name: str
|
|
argv: tuple
|
|
timeout: float
|
|
required: tuple = ()
|
|
|
|
|
|
def positive_seconds(value):
|
|
seconds = float(value)
|
|
if not math.isfinite(seconds) or not 0 < seconds <= 3600:
|
|
raise argparse.ArgumentTypeError('timeout must be finite and in (0, 3600] seconds')
|
|
return seconds
|
|
|
|
|
|
def parser():
|
|
cli = argparse.ArgumentParser(description=__doc__)
|
|
cli.add_argument('--build', action='store_true', help='run pio run before host checks (may fetch dependencies)')
|
|
cli.add_argument('--interop', action='store_true', help='enable ordering OpenSSH local Unix-socket matrix')
|
|
cli.add_argument('--dry-run', action='store_true', help='print plan only; no execution or prerequisite validation')
|
|
cli.add_argument('--fail-fast', action='store_true', help='stop after first failure; default collects failures')
|
|
cli.add_argument('--timeout', type=positive_seconds, default=180, help='seconds per host command (default: 180, maximum: 3600)')
|
|
cli.add_argument('--build-timeout', type=positive_seconds, default=600, help='seconds for optional build (default: 600, maximum: 3600)')
|
|
return cli
|
|
|
|
|
|
def plan(options):
|
|
commands = []
|
|
if options.build:
|
|
commands.append(Command('build', ('pio', 'run'), options.build_timeout))
|
|
|
|
def suite(name, *args, required=(), label=None):
|
|
path = Path('tests') / name / 'run.py'
|
|
commands.append(Command(label or name, (sys.executable, '-B', str(path), *map(str, args)),
|
|
options.timeout, (path, *required)))
|
|
|
|
header = BUILD / 'config/sdkconfig.h'
|
|
database = BUILD / 'compile_commands.json'
|
|
suite('security_build_policy', '--sdkconfig-header', header, required=(header,))
|
|
for name in ('ssh_auth_policy', 'ssh_auth_transport', 'hidden_input', 'ssh_memory'):
|
|
suite(name)
|
|
suite('sdk_security_overrides', '--build-dir', BUILD, required=(database,))
|
|
for name in ('wolfssh_auth_contract', 'ssh_protocol_policy', 'wolf_crypto_policy'):
|
|
suite(name, '--compile-commands', database, required=(database,))
|
|
suite('wolfssh_parser_contract')
|
|
suite('wolfssh_order_contract', *(['--interop'] if options.interop else []))
|
|
suite('release_notices')
|
|
for name in ('ssh_management', 'admin_console_boundary', 'admin_ssh_policy',
|
|
'web_admin_transport', 'web_admin_tickets', 'web_httpd_idle'):
|
|
suite(name)
|
|
# The current runner has no --admission branch: its default covers admission.
|
|
for mode in ('base', 'admin', 'accounts', 'ssh', 'lifecycle'):
|
|
suite('web_cookie_auth', *([] if mode == 'base' else ['--' + mode]),
|
|
label='web_cookie_auth:' + mode)
|
|
return commands
|
|
|
|
|
|
def kill_group(process):
|
|
try:
|
|
os.killpg(process.pid, signal.SIGKILL)
|
|
except ProcessLookupError:
|
|
pass
|
|
process.wait(timeout=5)
|
|
|
|
|
|
def run_command(command, root, env):
|
|
missing = [str(path) for path in command.required if not (root / path).is_file()]
|
|
if missing:
|
|
return 'PREREQ', 'missing ' + ', '.join(missing)
|
|
try:
|
|
# Inherit stdout/stderr: no persistent captures or environment dumps.
|
|
process = subprocess.Popen(command.argv, cwd=root, env=env, stdin=subprocess.DEVNULL,
|
|
start_new_session=True)
|
|
except OSError as error:
|
|
return 'PREREQ', f'cannot launch command (errno {error.errno})'
|
|
try:
|
|
code = process.wait(timeout=command.timeout)
|
|
except subprocess.TimeoutExpired:
|
|
kill_group(process)
|
|
return 'TIMEOUT', f'exceeded {command.timeout:g}s; process group killed'
|
|
except KeyboardInterrupt:
|
|
kill_group(process)
|
|
raise
|
|
# Also retire any descendants left by a runner that exited early.
|
|
try:
|
|
os.killpg(process.pid, signal.SIGKILL)
|
|
except ProcessLookupError:
|
|
pass
|
|
return ('PASS', 'exit 0') if code == 0 else ('FAIL', f'exit {code}')
|
|
|
|
|
|
def execute(commands, root, *, dry_run=False, fail_fast=False):
|
|
if os.name != 'posix':
|
|
print('PREREQ: POSIX process groups required', flush=True)
|
|
return 1
|
|
env = os.environ.copy()
|
|
env['CCACHE_DISABLE'] = '1'
|
|
results = []
|
|
stopped = False
|
|
for command in commands:
|
|
print(f'COMMAND {command.name} timeout={command.timeout:g}s argv={list(command.argv)!r}', flush=True)
|
|
if dry_run:
|
|
status, detail = 'PLAN', 'not executed; prerequisites not checked'
|
|
elif stopped:
|
|
status, detail = 'SKIP', 'earlier failure (fail-fast or failed build)'
|
|
else:
|
|
started = time.monotonic()
|
|
try:
|
|
status, detail = run_command(command, root, env)
|
|
except KeyboardInterrupt:
|
|
print(f'INTERRUPTED {command.name}; process group killed', flush=True)
|
|
return 130
|
|
detail += f' ({time.monotonic() - started:.1f}s)'
|
|
if status != 'PASS' and (fail_fast or command.name == 'build'):
|
|
stopped = True
|
|
results.append((command.name, status, detail))
|
|
print(f'{status} {command.name}: {detail}', flush=True)
|
|
print('\nSUMMARY (command exits, not target validation):', flush=True)
|
|
for name, status, detail in results:
|
|
print(f'{status} {name}: {detail}', flush=True)
|
|
return int(any(status not in ('PASS', 'PLAN') for _, status, _ in results))
|
|
|
|
|
|
def main(argv=None):
|
|
options = parser().parse_args(argv)
|
|
if not options.build:
|
|
print('SKIP optional build: --build not requested; existing artifacts required', flush=True)
|
|
if not options.interop:
|
|
print('SKIP optional interop: --interop not requested', flush=True)
|
|
return execute(plan(options), ROOT, dry_run=options.dry_run, fail_fast=options.fail_fast)
|
|
|
|
|
|
if __name__ == '__main__':
|
|
sys.exit(main())
|