#!/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())