Files
panda-rpki-oss/tests/support/test_public_config.py
yuyr af693f5e6b
Some checks failed
ci / rust (push) Has been cancelled
ci / docker (push) Has been cancelled
ci / audit (push) Has been cancelled
20260922 对齐 OSS 验证配置并补充 public TOML E2E
2026-09-22 22:27:10 +08:00

277 lines
8.9 KiB
Python

"""End-to-end coverage for the #170 public TOML validation settings."""
from __future__ import annotations
import json
from pathlib import Path
import socket
import subprocess
import sys
import tempfile
from urllib.parse import urlparse
def free_port() -> int:
with socket.socket() as sock:
sock.bind(("127.0.0.1", 0))
return int(sock.getsockname()[1])
def toml_string(path: Path) -> str:
return json.dumps(str(path))
def run_checked(args: list[str], *, timeout: int = 120) -> subprocess.CompletedProcess[str]:
result = subprocess.run(args, capture_output=True, text=True, timeout=timeout)
if result.returncode != 0:
raise AssertionError(
f"command failed ({result.returncode}): {' '.join(args)}\n"
f"stdout:\n{result.stdout}\nstderr:\n{result.stderr}"
)
return result
def wait_for_server(server: subprocess.Popen[str]) -> None:
assert server.stdout is not None
line = server.stdout.readline().strip()
if line != "ready":
raise AssertionError(f"RRDP fixture server did not become ready: {line!r}")
def stop_process(process: subprocess.Popen[str] | None) -> None:
if process is None:
return
if process.poll() is None:
process.terminate()
try:
process.wait(timeout=5)
except subprocess.TimeoutExpired:
process.kill()
process.wait()
def write_config(
path: Path,
*,
fixture: Path,
output: Path,
state: Path,
ccr: Path,
wrapper: Path,
mirror: Path,
sync_preference: str,
rrdp_mode: str,
validation_time: str,
resource_mode: str,
scope: str,
) -> None:
path.write_text(
f'''config-version = 1
mode = "validate"
out = {toml_string(output)}
rrdp-state-dir = {toml_string(state)}
rrdp-sync-mode = "{rrdp_mode}"
ccr-out = {toml_string(ccr)}
http-timeout-secs = 2
http-root-cert = [{toml_string(fixture / "certs/rrdp-ca.pem")}]
require-complete = true
parallel-max-repo-sync-workers-global = 2
parallel-phase2-object-workers = 2
log-level = "info"
log-format = "json"
resource-validation-mode = "{resource_mode}"
validation-time = "{validation_time}"
sync-preference = "{sync_preference}"
rsync-timeout-secs = 47
rsync-command = {toml_string(wrapper)}
rsync-mirror-root = {toml_string(mirror)}
rsync-scope = "{scope}"
[strict]
name = true
cms-der = true
signed-attrs = true
[[anchors]]
id = "custom"
tal = {toml_string(fixture / "tal/custom.tal")}
ta = {toml_string(fixture / "ta/custom-ta.cer")}
''',
encoding="utf-8",
)
def make_rsync_wrapper(path: Path, fixture: Path, log: Path) -> None:
repository = fixture / "cases" / "baseline-v1" / "repository"
path.write_text(
f'''#!/usr/bin/env python3
import json
import shutil
import sys
from pathlib import Path
from urllib.parse import urlparse
repository = Path({str(repository)!r})
log = Path({str(log)!r})
args = sys.argv[1:]
with log.open("a", encoding="utf-8") as stream:
stream.write(json.dumps(args) + "\\n")
if len(args) < 2:
raise SystemExit("rsync wrapper expected source and destination")
source_uri, destination_text = args[-2:]
destination = Path(destination_text)
parts = [part for part in urlparse(source_uri).path.split("/") if part]
if not parts or parts[0] != "custom":
raise SystemExit(f"unexpected fixture rsync source: {{source_uri}}")
relative = Path(*parts[1:]) if len(parts) > 1 else Path()
source = repository / relative
if not source.exists():
raise SystemExit(f"fixture rsync source does not exist: {{source}}")
destination.mkdir(parents=True, exist_ok=True)
if source.is_file():
shutil.copy2(source, destination / source.name)
else:
for item in source.iterdir():
target = destination / item.name
if item.is_dir():
shutil.copytree(item, target, dirs_exist_ok=True)
else:
shutil.copy2(item, target)
''',
encoding="utf-8",
)
path.chmod(0o755)
def read_json(path: Path) -> dict:
return json.loads(path.read_text(encoding="utf-8"))
def assert_successful_output(output: Path, expected_validation_time: str) -> None:
summary = read_json(output / "summary.json")
assert summary["resultStatus"] == "success", summary
assert summary["vrps"] == 1, summary
assert (output / "result-complete.json").is_file()
assert len((output / "vrps.csv").read_text(encoding="utf-8").splitlines()) == 2
timing = read_json(output / "analysis" / "timing.json")
assert timing["meta"]["validation_time_utc_rfc3339"] == expected_validation_time, timing
def run(binary: str) -> None:
support = Path(__file__).resolve().parent
with tempfile.TemporaryDirectory(prefix="panda-rpki-public-config-") as tmp:
base = Path(tmp)
fixture = base / "fixture"
port = free_port()
run_checked(
[
sys.executable,
str(support / "generate_repository.py"),
"--rrdp-host",
"127.0.0.1",
"--rrdp-port",
str(port),
"--rsync-host",
"127.0.0.1",
"--strict-names",
"--output",
str(fixture),
]
)
active = fixture / "active"
active.symlink_to("cases/baseline-v1/http")
wrapper_log = base / "rsync-wrapper.log"
wrapper = base / "rsync-wrapper.py"
make_rsync_wrapper(wrapper, fixture, wrapper_log)
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.PIPE,
text=True,
)
try:
wait_for_server(server)
fixed_input = "2026-06-01T08:00:00.250+08:00"
fixed_effective = "2026-06-01T00:00:00Z"
rrdp_output = base / "rrdp-output"
rrdp_state = base / "rrdp-state"
rrdp_config = base / "rrdp.toml"
write_config(
rrdp_config,
fixture=fixture,
output=rrdp_output,
state=rrdp_state,
ccr=rrdp_output / "result.ccr",
wrapper=wrapper,
mirror=base / "rrdp-mirror",
sync_preference="rrdp-then-rsync",
rrdp_mode="snapshot",
validation_time=fixed_input,
resource_mode="validation-update-03",
scope="publication-point",
)
run_checked([binary, "validate", "--config", str(rrdp_config)])
assert_successful_output(rrdp_output, fixed_effective)
assert not wrapper_log.exists() or not wrapper_log.read_text(encoding="utf-8").strip(), (
"RRDP success path unexpectedly invoked the rsync command"
)
finally:
stop_process(server)
rsync_output = base / "rsync-output"
rsync_state = base / "rsync-state"
rsync_mirror = base / "rsync-mirror"
rsync_config = base / "rsync.toml"
write_config(
rsync_config,
fixture=fixture,
output=rsync_output,
state=rsync_state,
ccr=rsync_output / "result.ccr",
wrapper=wrapper,
mirror=rsync_mirror,
sync_preference="rsync-only",
rrdp_mode="auto",
validation_time="2026-06-01T00:00:00Z",
resource_mode="rfc6487",
scope="publication-point",
)
# The RRDP server is stopped. A successful run therefore proves that
# sync-preference came from public TOML and reached the scheduler.
run_checked([binary, "validate", "--config", str(rsync_config)])
assert_successful_output(rsync_output, "2026-06-01T00:00:00Z")
calls = [
json.loads(line)
for line in wrapper_log.read_text(encoding="utf-8").splitlines()
if line.strip()
]
assert calls, "rsync-only validation did not invoke configured rsync command"
assert all("--timeout" in call and call[call.index("--timeout") + 1] == "47" for call in calls), calls
sources = [call[-2] for call in calls]
assert any("/custom/root/" in source for source in sources), sources
assert any("/custom/child/" in source for source in sources), sources
assert all("/custom/" in source and source.rstrip("/") != "rsync://127.0.0.1:873/custom" for source in sources), sources
mirror_files = [path for path in rsync_mirror.rglob("*") if path.is_file()]
assert mirror_files, f"configured rsync mirror remained empty: {rsync_mirror}"
print(
"Public TOML E2E passed: strict/resource/time + RRDP path + "
"rsync-only command/timeout/mirror/scope"
)
if __name__ == "__main__":
run(str(Path(sys.argv[1]).resolve()))