227 lines
9.2 KiB
Python
227 lines
9.2 KiB
Python
"""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()))
|