"""Real CLI regression for the forced-TOML validate and daemon contracts.""" import json from pathlib import Path import signal import socket import subprocess import sys import tempfile import time def read(path): return json.loads(path.read_text()) def wait_for(check, label, timeout=30): start = time.monotonic() while time.monotonic() - start < timeout: if check(): return time.sleep(0.05) raise AssertionError(f"timed out waiting for {label}") def state_is(root, state): path = root / "daemon-status.json" return path.exists() and read(path)["state"] == state def validate_config(path, fixture, out, state, mode="auto", ccr=None, timeout=2, progress=10): ccr = ccr or (out / "result.ccr") path.write_text( f'''config-version = 1 mode = "validate" out = {str(out)!r} rrdp-state-dir = {str(state)!r} rrdp-sync-mode = "{mode}" ccr-out = {str(ccr)!r} http-timeout-secs = {timeout} http-root-cert = [{str(fixture / "certs/rrdp-ca.pem")!r}] parallel-max-repo-sync-workers-global = 2 parallel-phase2-object-workers = 2 log-level = "info" log-format = "json" log-progress-interval-secs = {progress} [[anchors]] id = "custom" tal = {str(fixture / "tal/custom.tal")!r} ta = {str(fixture / "ta/custom-ta.cer")!r} ''', encoding="utf-8", ) def daemon_config(path, fixture, root, max_runs=None, interval=2, retain=3, timeout=0, grace=30, tal=None, auto=False, progress=10): tal = tal or (fixture / "tal/custom.tal") ta_line = "" if auto else f'ta = {str(fixture / "ta/custom-ta.cer")!r}\n' max_line = "" if max_runs is None else f"max-runs = {max_runs}\n" path.write_text( f'''config-version = 1 mode = "daemon" state-root = {str(root)!r} interval-secs = {interval} {max_line}retain-runs = {retain} run-timeout-secs = {timeout} shutdown-grace-secs = {grace} http-timeout-secs = 2 http-root-cert = [{str(fixture / "certs/rrdp-ca.pem")!r}] parallel-max-repo-sync-workers-global = 2 parallel-phase2-object-workers = 2 log-level = "info" log-format = "json" log-progress-interval-secs = {progress} [[anchors]] id = "custom" tal = {str(tal)!r} {ta_line}''', encoding="utf-8", ) def run(binary): support = Path(__file__).resolve().parent with tempfile.TemporaryDirectory() as tmp: base = Path(tmp) fixture = base / "fixture" with socket.socket() as sock: sock.bind(("127.0.0.1", 0)) port = sock.getsockname()[1] subprocess.run( [sys.executable, str(support / "generate_repository.py"), "--rrdp-host", "127.0.0.1", "--rrdp-port", str(port), "--output", str(fixture)], check=True, timeout=120) active = fixture / "active" active.symlink_to("cases/baseline-v1/http") server = subprocess.Popen( [sys.executable, str(support / "serve_repository.py"), str(active), str(port), str(fixture / "certs/rrdp-server.pem"), str(fixture / "certs/rrdp-server.key")], stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, text=True) processes = [] logs = [] try: assert server.stdout.readline().strip() == "ready" def launch(root, *, max_runs=None, interval=2, retain=3, timeout=0, grace=30, tal=None, auto=False, progress=10): config = base / f"daemon-{len(logs)}.toml" daemon_config(config, fixture, root, max_runs=max_runs, interval=interval, retain=retain, timeout=timeout, grace=grace, tal=tal, auto=auto, progress=progress) log = (base / f"controller-{len(logs)}.log").open("w") logs.append(log) proc = subprocess.Popen( [binary, "daemon", "--config", str(config)], stdout=log, stderr=log) processes.append(proc) return proc root = base / "normal" proc = launch(root, max_runs=2, interval=2, retain=2) wait_for(lambda: state_is(root, "sleeping"), "snapshot completion") first = read(root / "runs/run_000001/run-summary.json") assert first["status"] == "success" and first["sync_mode"] == "snapshot", first assert first["summary"]["vrps"] == 1, first rival = launch(root, max_runs=1) assert rival.wait(timeout=10) == 2 active.unlink() active.symlink_to("cases/baseline-v2/http") assert proc.wait(timeout=30) == 0 second = read(root / "runs/run_000002/run-summary.json") assert second["status"] == "success" and second["sync_mode"] == "delta", second assert second["summary"]["vrps"] == 2, second timing = read(root / "runs/run_000002/stage-timing.json") assert timing["analysis_counts"]["rrdp_delta_ops_applied_total"] > 0 assert (root / "latest").is_symlink() for seq in (1, 2): out = root / f"runs/run_{seq:06}" assert (out / "validator-config.toml").is_file() assert (out / "result-complete.json").is_file() records = [json.loads(line) for line in (out / "validator.log").read_text().splitlines()] assert records[-1]["event"] == "validation_completed", records[-1] assert all(record["fields"]["run_seq"] == seq for record in records) assert len((out / "vrps.csv").read_text().splitlines()) - 1 == seq subprocess.run(["openssl", "asn1parse", "-inform", "DER", "-in", str(out / "result.ccr"), "-noout"], check=True) (root / "runs/operator-notes").mkdir() (root / "runs/run_000003").mkdir() restart = launch(root, max_runs=1, interval=0, retain=1) assert restart.wait(timeout=30) == 0 fourth = read(root / "runs/run_000004/run-summary.json") assert fourth["sync_mode"] == "auto" and fourth["summary"]["vrps"] == 2 assert not (root / "runs/run_000001").exists() assert (root / "runs/operator-notes").is_dir() assert (root / "runs/run_000003").is_dir() # A malformed output destination is rejected during read-only config validation. bad_config = base / "bad.toml" validate_config(bad_config, fixture, base / "bad-output", base / "bad-state", ccr=base) output_failure = subprocess.run( [binary, "validate", "--config", str(bad_config)], capture_output=True, text=True, timeout=30) assert output_failure.returncode == 2 and not output_failure.stdout assert "ccr-out must be a file" in output_failure.stderr # A valid TAL with an unreachable automatic TA URI creates failed daemon runs. bad_tal = base / "unreachable.tal" tal_text = (fixture / "tal/custom.tal").read_text() bad_tal.write_text( tal_text .replace("rpki-local-rsync:873", "127.0.0.1:1") .replace(f"https://127.0.0.1:{port}", "https://127.0.0.1:1") ) failed = base / "failed" failure = launch(failed, max_runs=2, interval=0, tal=bad_tal, auto=True) assert failure.wait(timeout=30) == 2 for seq in (1, 2): assert read(failed / f"runs/run_{seq:06}/run-summary.json")["status"] == "failed" # Whole-run timeout and SIGTERM are exercised while the RRDP server is stopped. server.send_signal(signal.SIGSTOP) timeout_root = base / "timeout" timeout_proc = launch(timeout_root, max_runs=1, timeout=2, progress=0) assert timeout_proc.wait(timeout=15) == 2 timeout_run = read(timeout_root / "runs/run_000001/run-summary.json") assert timeout_run["error"] == "timeout", timeout_run interrupted = base / "interrupted" active_proc = launch(interrupted, grace=0) wait_for(lambda: state_is(interrupted, "running"), "active validator") active_proc.send_signal(signal.SIGTERM) assert active_proc.wait(timeout=15) == 2 assert read(interrupted / "runs/run_000001/run-summary.json")["error"] == "interrupted" server.send_signal(signal.SIGCONT) print("Real daemon E2E passed: TOML config/snapshot/delta/restart/retention/lock/timeout/SIGTERM") except BaseException: for log in logs: log.flush() print(Path(log.name).read_text(), file=sys.stderr) raise finally: server.send_signal(signal.SIGCONT) for proc in processes: if proc.poll() is None: proc.terminate() try: proc.wait(timeout=35) except subprocess.TimeoutExpired: proc.kill() proc.wait() server.terminate() server.wait(timeout=5) for log in logs: log.close() if __name__ == "__main__": run(str(Path(sys.argv[1]).resolve()))