Harden RF node control and agent updates
This commit is contained in:
133
tools/rf001_capture_runner.py
Normal file
133
tools/rf001_capture_runner.py
Normal file
@@ -0,0 +1,133 @@
|
||||
"""Run one finite RF-001 RX child and persist start/final evidence."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
from datetime import datetime, timezone
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
from typing import Mapping
|
||||
|
||||
|
||||
def utc_now() -> str:
|
||||
return datetime.now(timezone.utc).isoformat()
|
||||
|
||||
|
||||
def atomic_json(path: Path, value: Mapping[str, object]) -> None:
|
||||
temporary = path.with_suffix(path.suffix + ".tmp")
|
||||
temporary.write_text(
|
||||
json.dumps(value, ensure_ascii=False, indent=2, sort_keys=True) + "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
os.replace(temporary, path)
|
||||
|
||||
|
||||
def sha256_file(path: Path) -> str:
|
||||
digest = hashlib.sha256()
|
||||
with path.open("rb") as stream:
|
||||
while block := stream.read(1024 * 1024):
|
||||
digest.update(block)
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
def run_capture(
|
||||
executable: Path,
|
||||
arguments_path: Path,
|
||||
capture_path: Path,
|
||||
stdout_path: Path,
|
||||
stderr_path: Path,
|
||||
started_path: Path,
|
||||
result_path: Path,
|
||||
) -> int:
|
||||
arguments_value = json.loads(arguments_path.read_text(encoding="utf-8-sig"))
|
||||
if not isinstance(arguments_value, list) or not all(
|
||||
isinstance(item, str) for item in arguments_value
|
||||
):
|
||||
raise ValueError("arguments JSON must be an array of strings")
|
||||
started_at = utc_now()
|
||||
child: subprocess.Popen[bytes] | None = None
|
||||
exit_code: int | None = None
|
||||
runner_error: str | None = None
|
||||
try:
|
||||
with stdout_path.open("xb") as stdout_stream, stderr_path.open(
|
||||
"xb"
|
||||
) as stderr_stream:
|
||||
child = subprocess.Popen(
|
||||
[str(executable), *arguments_value],
|
||||
stdin=subprocess.DEVNULL,
|
||||
stdout=stdout_stream,
|
||||
stderr=stderr_stream,
|
||||
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0),
|
||||
close_fds=True,
|
||||
)
|
||||
atomic_json(
|
||||
started_path,
|
||||
{
|
||||
"state": "RUNNING",
|
||||
"pid": int(child.pid),
|
||||
"started_at": started_at,
|
||||
"capture_path": str(capture_path),
|
||||
"rf_tx": 0,
|
||||
},
|
||||
)
|
||||
exit_code = int(child.wait())
|
||||
except Exception as error: # persisted at the process boundary
|
||||
runner_error = f"{type(error).__name__}: {error}"
|
||||
finished_at = utc_now()
|
||||
capture_size = capture_path.stat().st_size if capture_path.is_file() else 0
|
||||
capture_hash: str | None = None
|
||||
if capture_path.is_file():
|
||||
try:
|
||||
capture_hash = sha256_file(capture_path)
|
||||
except OSError as error:
|
||||
detail = f"{type(error).__name__}: {error}"
|
||||
runner_error = detail if runner_error is None else f"{runner_error}; {detail}"
|
||||
duration = (
|
||||
datetime.fromisoformat(finished_at) - datetime.fromisoformat(started_at)
|
||||
).total_seconds()
|
||||
atomic_json(
|
||||
result_path,
|
||||
{
|
||||
"pid": int(child.pid) if child is not None else None,
|
||||
"started_at": started_at,
|
||||
"finished_at": finished_at,
|
||||
"duration": duration,
|
||||
"exit_code": exit_code,
|
||||
"capture_path": str(capture_path),
|
||||
"capture_size": capture_size,
|
||||
"sha256": capture_hash,
|
||||
"runner_error": runner_error,
|
||||
"rf_tx": 0,
|
||||
},
|
||||
)
|
||||
return 255 if exit_code is None else exit_code
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--executable", required=True, type=Path)
|
||||
parser.add_argument("--arguments-json", required=True, type=Path)
|
||||
parser.add_argument("--capture-path", required=True, type=Path)
|
||||
parser.add_argument("--stdout-path", required=True, type=Path)
|
||||
parser.add_argument("--stderr-path", required=True, type=Path)
|
||||
parser.add_argument("--started-status", required=True, type=Path)
|
||||
parser.add_argument("--result-status", required=True, type=Path)
|
||||
arguments = parser.parse_args()
|
||||
raise SystemExit(
|
||||
run_capture(
|
||||
arguments.executable,
|
||||
arguments.arguments_json,
|
||||
arguments.capture_path,
|
||||
arguments.stdout_path,
|
||||
arguments.stderr_path,
|
||||
arguments.started_status,
|
||||
arguments.result_status,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user