Files
ESP32_Serial_Swiss_Army_Knife/tools/validate_phase9.py
T
Commander1024 cdc4d4a8df Add Phase 9 validation and advisory review
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.
2026-09-16 16:26:46 +02:00

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())