"""M16 bounded chaos harness. One harness, one shape, every scenario: preflight -> baseline -> inject -> observe -> assert -> recover -> verify invariants -> cleanup Faults are injected from *outside* the product, through the container runtime and through seams that already exist for rehearsals (the M12 lifecycle pause, the M15 restore interruption). ModelForge itself gains no chaos endpoint and no way to run a shell: a control plane that can be told to break itself is a control plane an attacker can tell to break itself. Every scenario is bounded in time, reverses its own fault, and re-runs the full invariant set afterwards. A scenario that cannot restore the platform reports RECOVERY_FAILED rather than leaving the environment dirty and calling itself done. python scripts/m16_chaos.py --list python scripts/m16_chaos.py --scenario redis-outage --seed 1234 python scripts/m16_chaos.py --all --report out.json """ from __future__ import annotations import argparse import json import os import random import subprocess # noqa: S404 - fixed argv container-runtime control, never a shell import sys import time import urllib.error import urllib.request import uuid from collections.abc import Callable from dataclasses import dataclass, field from datetime import UTC, datetime from pathlib import Path from typing import Any from sqlalchemy import create_engine, text from sqlalchemy.orm import Session sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "backend" / "src")) from modelforge_api.services.invariants import ( # noqa: E402 InvariantStatus, check_invariants, ) DEFAULT_BASE_URL = "http://127.0.0.1:8000" DEFAULT_DATABASE_URL = os.getenv("MODELFORGE_RUNTIME_DATABASE_URL") API = "modelforge-api-1" REDIS = "modelforge-redis-1" POSTGRES = "modelforge-postgres-1" NODE_AGENT = "modelforge-node-agent-1" NETWORK = "modelforge_default" class ChaosError(RuntimeError): """A harness failure: the fault could not be injected, or the platform could not be restored.""" # --------------------------------------------------------------------- runtime control def docker(*args: str, check: bool = True, timeout: int = 120) -> str: completed = subprocess.run( # noqa: S603 - fixed argv, no shell ["docker", *args], # noqa: S607 - docker resolves from PATH capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=timeout, check=False, ) if check and completed.returncode != 0: raise ChaosError(f"docker {' '.join(args)} failed: {completed.stderr.strip()[:400]}") return (completed.stdout or "").strip() def container_running(name: str) -> bool: return docker("inspect", "--format", "{{.State.Running}}", name, check=False) == "true" def container_started_at(name: str) -> str: return docker("inspect", "--format", "{{.State.StartedAt}}", name, check=False) # --------------------------------------------------------------------- probes @dataclass class Probe: ok: bool status: int latency_ms: float body: str = "" def http( url: str, *, token: str | None = None, method: str = "GET", timeout: float = 5.0 ) -> Probe: request = urllib.request.Request(url, method=method) # noqa: S310 - fixed http scheme if token: request.add_header("X-ModelForge-Admin-Token", token) started = time.perf_counter() try: with urllib.request.urlopen(request, timeout=timeout) as response: # noqa: S310 body = response.read(4096).decode("utf-8", "replace") return Probe(True, response.status, (time.perf_counter() - started) * 1000, body) except urllib.error.HTTPError as error: return Probe(False, error.code, (time.perf_counter() - started) * 1000, "") except (urllib.error.URLError, TimeoutError, OSError): return Probe(False, 0, (time.perf_counter() - started) * 1000, "") def wait_for(predicate: Callable[[], bool], *, timeout: float, interval: float = 1.0) -> bool: deadline = time.monotonic() + timeout while time.monotonic() < deadline: if predicate(): return True time.sleep(interval) return predicate() # --------------------------------------------------------------------- scenario contract @dataclass class ScenarioContext: base_url: str database_url: str token: str seed: int rng: random.Random observations: dict[str, Any] = field(default_factory=dict) def note(self, key: str, value: Any) -> None: self.observations[key] = value def session(self) -> Session: engine = create_engine(self.database_url, pool_pre_ping=True) return Session(engine) @dataclass class Scenario: key: str title: str failure_class: str subsystem: str expected: str run: Callable[[ScenarioContext], None] recover: Callable[[ScenarioContext], None] | None = None cleanup: Callable[[ScenarioContext], None] | None = None requires_docker: bool = True SCENARIOS: dict[str, Scenario] = {} def scenario( key: str, title: str, failure_class: str, subsystem: str, expected: str, *, recover: Callable[[ScenarioContext], None] | None = None, cleanup: Callable[[ScenarioContext], None] | None = None, requires_docker: bool = True, ) -> Callable[[Callable[[ScenarioContext], None]], Callable[[ScenarioContext], None]]: def decorate(function: Callable[[ScenarioContext], None]) -> Callable[[ScenarioContext], None]: SCENARIOS[key] = Scenario( key=key, title=title, failure_class=failure_class, subsystem=subsystem, expected=expected, run=function, recover=recover, cleanup=cleanup, requires_docker=requires_docker, ) return function return decorate # --------------------------------------------------------------------- scenarios def _api_healthy(context: ScenarioContext) -> bool: return http(f"{context.base_url}/api/v1/health/live").ok def _restore_api(context: ScenarioContext) -> None: if not container_running(API): docker("start", API) if not wait_for(lambda: _api_healthy(context), timeout=120): raise ChaosError("the control plane did not become healthy again") @scenario( "redis-outage", "Redis becomes unreachable and returns", failure_class="DEPENDENCY", subsystem="Redis", expected="the control plane stays up, reports the dependency honestly, and replays nothing", recover=_restore_api, ) def redis_outage(context: ScenarioContext) -> None: """Redis holds queues and transient payloads only; losing it must not corrupt authoritative state.""" before = http(f"{context.base_url}/api/v1/health/live") context.note("api_healthy_before", before.ok) docker("stop", REDIS) context.note("redis_stopped", True) time.sleep(5) during_live = http(f"{context.base_url}/api/v1/health/live") during_ready = http(f"{context.base_url}/api/v1/health/ready") during_models = http(f"{context.base_url}/api/v1/models") context.note("during_live_status", during_live.status) context.note("during_ready_status", during_ready.status) context.note("during_registry_status", during_models.status) with context.session() as session: queued = session.execute( text("select count(*) from serving_jobs where status in ('queued','leased','running')") ).scalar_one() context.note("serving_jobs_in_flight_during_outage", int(queued)) docker("start", REDIS) if not wait_for(lambda: container_running(REDIS), timeout=60): raise ChaosError("redis did not restart") time.sleep(5) after = http(f"{context.base_url}/api/v1/health/live") context.note("api_healthy_after", after.ok) with context.session() as session: duplicates = session.execute( text( "select count(*) from (select idempotency_key from serving_jobs " "group by idempotency_key having count(*) > 1) s" ) ).scalar_one() context.note("duplicate_serving_idempotency_keys", int(duplicates)) if int(duplicates) != 0: raise ChaosError("a Redis outage produced duplicate serving jobs") if not after.ok: raise ChaosError("the control plane did not recover after Redis returned") @scenario( "postgres-restart", "PostgreSQL restarts under the running control plane", failure_class="DEPENDENCY", subsystem="PostgreSQL", expected="the API fails safely while the database is gone and recovers its pool without duplicate work", recover=_restore_api, ) def postgres_restart(context: ScenarioContext) -> None: """The control plane may not invent state while its authoritative store is unavailable.""" with context.session() as session: audit_before = session.execute(text("select count(*) from audit_events")).scalar_one() context.note("audit_events_before", int(audit_before)) docker("stop", POSTGRES) time.sleep(3) during_live = http(f"{context.base_url}/api/v1/health/live") during_models = http(f"{context.base_url}/api/v1/models", timeout=10.0) context.note("during_live_status", during_live.status) context.note("during_registry_status", during_models.status) if during_models.ok: raise ChaosError("the registry served data while PostgreSQL was stopped") docker("start", POSTGRES) if not wait_for( lambda: docker( "exec", POSTGRES, "pg_isready", "-U", "modelforge", "-d", "modelforge", check=False ).endswith("accepting connections"), timeout=120, ): raise ChaosError("PostgreSQL did not accept connections again") recovered = wait_for(lambda: http(f"{context.base_url}/api/v1/models", timeout=10.0).ok, timeout=120) context.note("registry_recovered_without_restart", recovered) with context.session() as session: audit_after = session.execute(text("select count(*) from audit_events")).scalar_one() duplicates = session.execute( text( "select count(*) from (select sequence from audit_events " "group by sequence having count(*) > 1) s" ) ).scalar_one() context.note("audit_events_after", int(audit_after)) context.note("duplicate_audit_sequences", int(duplicates)) if int(duplicates) != 0: raise ChaosError("a PostgreSQL restart produced duplicate audit sequences") if not recovered: raise ChaosError("the control plane never recovered its database pool") RECOVERY_AGENT = "modelforge-node-recovery-agent-1" RECOVERY_COMPOSE = "docker-compose.node-recovery.yml" def _recovery_agent_node(context: ScenarioContext) -> tuple[Any, Any] | None: with context.session() as session: row = session.execute( text( "select id, last_heartbeat_at from compute_nodes " "where hostname = 'modelforge-chaos-rehearsal' and enabled " "order by first_seen_at desc limit 1" ) ).first() return (row[0], row[1]) if row else None def _stop_recovery_agent(context: ScenarioContext) -> None: docker("network", "connect", NETWORK, RECOVERY_AGENT, check=False) subprocess.run( # noqa: S603 - fixed argv, no shell [ "docker", # noqa: S607 "compose", "-p", "modelforge", "-f", RECOVERY_COMPOSE, "down", "-v", ], capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=180, check=False, ) with context.session() as session: session.execute( text( "update compute_nodes set enabled = false, status = 'unavailable', " "status_reason = 'M16 disposable chaos node; environment torn down' " "where hostname = 'modelforge-chaos-rehearsal'" ) ) session.execute( text( "update node_credentials set revoked_at = now() where revoked_at is null " "and compute_node_id in " "(select id from compute_nodes where hostname = 'modelforge-chaos-rehearsal')" ) ) session.commit() @scenario( "node-agent-disconnect", "A Node Agent loses its network and reconnects", failure_class="DEPENDENCY", subsystem="Node Agent", expected="heartbeat stops, liveness degrades honestly, and reconnect keeps one identity", cleanup=_stop_recovery_agent, ) def node_agent_disconnect(context: ScenarioContext) -> None: """Losing a node must degrade liveness honestly rather than invent readiness. A disposable agent is used deliberately. GPU Node reaches the control plane over the LAN rather than the Compose network, so disconnecting a container would not interrupt it — and revoking or isolating the production node to prove a point is exactly what M16 forbids. """ _stop_recovery_agent(context) token = _issue_enrollment(context) env = { "MODELFORGE_RECOVERY_AGENT_TOKEN": token, "MODELFORGE_RECOVERY_AGENT_URL": "http://api:8000", "MODELFORGE_RECOVERY_AGENT_HOSTNAME": "modelforge-chaos-rehearsal", "MODELFORGE_RECOVERY_AGENT_DISPLAY_NAME": "M16 chaos rehearsal node", } _compose_up(env) if not wait_for(lambda: _recovery_agent_node(context) is not None, timeout=120, interval=3.0): raise ChaosError("the disposable agent never enrolled") node = _recovery_agent_node(context) assert node is not None node_id, _ = node context.note("disposable_node_id", str(node_id)) if not wait_for( lambda: _heartbeat_moved(context, node_id, None), timeout=90, interval=3.0 ): raise ChaosError("the disposable agent never published a heartbeat") with context.session() as session: before = session.execute( text("select liveness_state, last_heartbeat_at from compute_nodes where id = :i"), {"i": node_id}, ).first() context.note("liveness_before", before[0]) heartbeat_before = before[1] docker("network", "disconnect", NETWORK, RECOVERY_AGENT) context.note("disconnected", True) try: # Longer than the configured offline threshold so liveness has to move, not just age. time.sleep(100) with context.session() as session: during = session.execute( text("select liveness_state, last_heartbeat_at from compute_nodes where id = :i"), {"i": node_id}, ).first() context.note("liveness_during", during[0]) advanced = during[1] != heartbeat_before context.note("heartbeat_advanced_during_outage", advanced) if advanced: raise ChaosError("the heartbeat advanced while the agent was disconnected") if during[0] == "online": raise ChaosError("liveness still reported online after the agent went away") finally: docker("network", "connect", NETWORK, RECOVERY_AGENT, check=False) reconnected = wait_for( lambda: _heartbeat_moved(context, node_id, heartbeat_before), timeout=150, interval=3.0 ) context.note("reconnected", reconnected) with context.session() as session: after = session.execute( text("select liveness_state from compute_nodes where id = :i"), {"i": node_id} ).scalar_one() identities = session.execute( text( "select count(*) from compute_nodes " "where hostname = 'modelforge-chaos-rehearsal' and enabled" ) ).scalar_one() context.note("liveness_after", after) context.note("enabled_identities_after", int(identities)) if int(identities) != 1: raise ChaosError("the disconnect produced a duplicate node identity") if not reconnected: raise ChaosError("the Node Agent never resumed publishing") def _issue_enrollment(context: ScenarioContext) -> str: payload = json.dumps( { "expires_in_seconds": 3600, "display_name": "M16 chaos rehearsal node", "role": "rehearsal", "labels": {"rehearsal": "m16-chaos"}, "production_eligible": False, "lab_eligible": False, "benchmark_eligible": False, } ).encode() request = urllib.request.Request( # noqa: S310 - fixed http scheme f"{context.base_url}/api/v1/admin/node-enrollments", data=payload, method="POST" ) request.add_header("Content-Type", "application/json") request.add_header("X-ModelForge-Admin-Token", context.token) with urllib.request.urlopen(request, timeout=60) as response: # noqa: S310 return str(json.loads(response.read().decode())["enrollment_token"]) def _compose_up(extra_env: dict[str, str]) -> None: import os environment = {**os.environ, **extra_env} completed = subprocess.run( # noqa: S603 - fixed argv, no shell [ "docker", # noqa: S607 "compose", "-p", "modelforge", "-f", RECOVERY_COMPOSE, "up", "-d", "--build", "node-recovery-agent", ], capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=900, env=environment, check=False, ) if completed.returncode != 0: raise ChaosError(f"the disposable agent did not start: {completed.stderr.strip()[:400]}") def _heartbeat_moved(context: ScenarioContext, node_id: Any, before: Any) -> bool: with context.session() as session: current = session.execute( text("select last_heartbeat_at from compute_nodes where id = :i"), {"i": node_id} ).scalar() return current is not None and current != before @scenario( "control-plane-restart-storm", "The control plane restarts repeatedly under load", failure_class="TRANSIENT", subsystem="Control Plane", expected="each start reconciles idempotently and no duplicate authoritative object appears", recover=_restore_api, ) def control_plane_restart_storm(context: ScenarioContext) -> None: """Restart reconciliation must be idempotent, not merely survivable.""" with context.session() as session: before = _authoritative_counts(session) context.note("counts_before", before) restarts = 4 statuses: list[int] = [] for index in range(restarts): docker("restart", API) healthy = wait_for(lambda: _api_healthy(context), timeout=120) if not healthy: raise ChaosError(f"the control plane did not return after restart {index + 1}") statuses.append(http(f"{context.base_url}/api/v1/models").status) context.note("restarts", restarts) context.note("registry_status_after_each_restart", statuses) with context.session() as session: after = _authoritative_counts(session) context.note("counts_after", after) drifted = { key: (before[key], after[key]) for key in before if key not in {"audit_events"} and after[key] != before[key] } context.note("authoritative_drift", drifted) if drifted: raise ChaosError(f"restart storm changed authoritative state: {drifted}") def _authoritative_counts(session: Session) -> dict[str, int]: tables = ( "models", "model_revisions", "model_artifacts", "artifact_sets", "projects", "project_bindings", "capability_deployments", "lifecycle_operations", "migration_plans", "migration_cutover_operations", "compute_nodes", "node_credentials", "service_clients", "backup_sets", "audit_events", ) counts: dict[str, int] = {} for table in tables: counts[table] = int( session.execute(text(f"select count(*) from {table}")).scalar_one() # noqa: S608 ) return counts @scenario( "invalid-auth-burst", "A burst of invalid operator credentials", failure_class="AUTH", subsystem="Control Plane", expected="every attempt is refused identically, with no amplification and no enumeration signal", ) def invalid_auth_burst(context: ScenarioContext) -> None: """A wrong credential must not reveal whether it was close to right.""" attempts = 60 tokens = [uuid.UUID(int=context.rng.getrandbits(128)).hex for _ in range(attempts)] tokens[0] = "" tokens[1] = "x" tokens[2] = context.token[:-1] + ("a" if context.token[-1] != "a" else "b") statuses: list[int] = [] bodies: set[str] = set() latencies: list[float] = [] for token in tokens: probe = http( f"{context.base_url}/api/v1/admin/recovery/dashboard", token=token or None, timeout=10.0, ) statuses.append(probe.status) bodies.add(probe.body[:0]) latencies.append(probe.latency_ms) context.note("attempts", attempts) context.note("distinct_statuses", sorted(set(statuses))) context.note("max_latency_ms", round(max(latencies), 2)) context.note("mean_latency_ms", round(sum(latencies) / len(latencies), 2)) if set(statuses) != {401}: raise ChaosError(f"invalid credentials produced varying statuses: {sorted(set(statuses))}") good = http(f"{context.base_url}/api/v1/admin/recovery/dashboard", token=context.token) context.note("valid_credential_still_works", good.ok) if not good.ok: raise ChaosError("a burst of invalid credentials locked out the valid one") # The backup root is a named volume, not a path inside the API container. Injecting this fault used # to be `docker exec -u 0 modelforge-api-1 chmod ...`, which stopped working the moment M16 hardened # the API container: with all capabilities dropped and no-new-privileges set, even uid 0 inside that # container cannot change a mode. That is the hardening behaving correctly, so the fault moves # outside the product instead of the hardening being weakened to keep a test convenient. # # A disposable Alpine container mounts the same volume and changes the mode there. It touches only # the isolated backup volume, never a host path, and is removed on exit. BACKUP_VOLUME = "modelforge_modelforge-backups" def _set_backup_root_mode(mode: str, *, check: bool = True) -> None: docker( "run", "--rm", "-v", f"{BACKUP_VOLUME}:/data/backups", "alpine:3.20", "chmod", mode, "/data/backups", check=check, ) @scenario( "backup-destination-unavailable", "The backup destination cannot be written", failure_class="CAPACITY", subsystem="Backup storage", expected="the backup fails closed and serving is unaffected", ) def backup_destination_unavailable(context: ScenarioContext) -> None: """A backup that cannot be written must fail, not silently produce a partial recovery point.""" backup_id = f"chaos-unwritable-{context.seed:08d}" _set_backup_root_mode("0555") try: payload = json.dumps( { "backup_id": backup_id, "reason": "M16 chaos scenario: the backup root is not writable", } ).encode() request = urllib.request.Request( # noqa: S310 - fixed http scheme f"{context.base_url}/api/v1/admin/recovery/backups", data=payload, method="POST" ) request.add_header("Content-Type", "application/json") request.add_header("X-ModelForge-Admin-Token", context.token) status = 0 try: with urllib.request.urlopen(request, timeout=120) as response: # noqa: S310 status = response.status body = json.loads(response.read().decode()) except urllib.error.HTTPError as error: status = error.code body = {} context.note("backup_http_status", status) context.note("backup_state", body.get("state")) context.note("backup_failure_code", body.get("failure_code")) finally: _set_backup_root_mode("0755", check=False) serving = http(f"{context.base_url}/api/v1/health/ready") context.note("serving_unaffected", serving.ok) with context.session() as session: state = session.execute( text("select state from backup_sets where backup_id = :b"), {"b": backup_id} ).scalar() context.note("persisted_backup_state", state) if state == "VERIFIED": raise ChaosError("an unwritable destination still produced a verified backup") if not serving.ok: raise ChaosError("a failed backup degraded serving") def _cleanup_chaos_backups(context: ScenarioContext) -> None: with context.session() as session: session.execute( text("delete from backup_manifest_entries where backup_set_id in " "(select id from backup_sets where backup_id like 'chaos-%')") ) session.execute(text("delete from backup_sets where backup_id like 'chaos-%'")) session.commit() SCENARIOS["backup-destination-unavailable"].cleanup = _cleanup_chaos_backups # --------------------------------------------------------------------- runner def run_scenario(scenario_obj: Scenario, context: ScenarioContext) -> dict[str, Any]: started = datetime.now(UTC) record: dict[str, Any] = { "scenario": scenario_obj.key, "title": scenario_obj.title, "failure_class": scenario_obj.failure_class, "subsystem": scenario_obj.subsystem, "expected": scenario_obj.expected, "seed": context.seed, "started_at": started.isoformat(), } with context.session() as session: baseline = check_invariants(session) record["invariants_before"] = { "checked": baseline.checked, "violated": baseline.violated, "violations": [ item.key for item in baseline.results if item.status is InvariantStatus.VIOLATED ], } if baseline.violated: record["outcome"] = "PREFLIGHT_FAILED" record["detail"] = "invariants were already violated before injection" record["finished_at"] = datetime.now(UTC).isoformat() return record outcome = "PASSED" detail = "" try: scenario_obj.run(context) except ChaosError as error: outcome = "FAILED" detail = str(error) except Exception as error: # noqa: BLE001 - the harness must report, not crash the run outcome = "HARNESS_ERROR" detail = f"{type(error).__name__}: {error}" if scenario_obj.recover is not None: try: scenario_obj.recover(context) except ChaosError as error: outcome = "RECOVERY_FAILED" detail = f"{detail} | recovery: {error}".strip(" |") if scenario_obj.cleanup is not None: try: scenario_obj.cleanup(context) record["cleanup"] = "DONE" except Exception as error: # noqa: BLE001 record["cleanup"] = f"FAILED: {type(error).__name__}: {error}" outcome = "CLEANUP_FAILED" if outcome == "PASSED" else outcome else: record["cleanup"] = "NOT_REQUIRED" with context.session() as session: after = check_invariants(session) record["invariants_after"] = { "checked": after.checked, "violated": after.violated, "violations": [ {"key": item.key, "examples": item.violations} for item in after.results if item.status is InvariantStatus.VIOLATED ], } if after.violated and outcome == "PASSED": outcome = "INVARIANT_VIOLATED" detail = "the platform survived the fault but broke an invariant" record["observations"] = context.observations record["outcome"] = outcome record["detail"] = detail record["finished_at"] = datetime.now(UTC).isoformat() record["duration_seconds"] = round( (datetime.fromisoformat(record["finished_at"]) - started).total_seconds(), 3 ) return record def main(argv: list[str] | None = None) -> int: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--base-url", default=DEFAULT_BASE_URL) parser.add_argument("--database-url", default=DEFAULT_DATABASE_URL) parser.add_argument("--token", default=None, help="operator API key") parser.add_argument("--scenario", action="append", default=[]) parser.add_argument("--all", action="store_true") parser.add_argument("--list", action="store_true") parser.add_argument("--seed", type=int, default=None) parser.add_argument("--report", default=None) args = parser.parse_args(argv) if args.list: for key, item in sorted(SCENARIOS.items()): print(f"{key:32} {item.failure_class:12} {item.subsystem:16} {item.title}") return 0 if not args.database_url: parser.error( "--database-url or MODELFORGE_RUNTIME_DATABASE_URL is required " "(use the non-owner runtime role)" ) keys = list(SCENARIOS) if args.all else args.scenario if not keys: parser.error("choose --scenario, --all or --list") unknown = [key for key in keys if key not in SCENARIOS] if unknown: parser.error(f"unknown scenarios: {unknown}") token = args.token if not token: env = Path(__file__).resolve().parents[1] / ".env" if env.is_file(): for line in env.read_text("utf-8").splitlines(): if line.startswith("MODELFORGE_OPERATOR_API_KEY="): token = line.split("=", 1)[1].strip() if not token: parser.error("an operator API key is required") seed = args.seed if args.seed is not None else random.randrange(1, 10**8) records = [] for key in keys: context = ScenarioContext( base_url=args.base_url, database_url=args.database_url, token=token, seed=seed, rng=random.Random(seed), ) print(f"--- {key} (seed {seed}) ---", flush=True) record = run_scenario(SCENARIOS[key], context) records.append(record) print(f" {record['outcome']} {record.get('detail', '')}", flush=True) for name, value in record["observations"].items(): print(f" {name}: {value}", flush=True) report = { "generated_at": datetime.now(UTC).isoformat(), "seed": seed, "scenarios": records, "passed": sum(item["outcome"] == "PASSED" for item in records), "total": len(records), } if args.report: Path(args.report).write_text(json.dumps(report, indent=2), encoding="utf-8") print(f"report written to {args.report}") return 0 if report["passed"] == report["total"] else 1 if __name__ == "__main__": raise SystemExit(main())