Vendor MsQuic with build and install support, add DeviceManager status to configurable heartbeats, and report only enabled devices. Add the local QUIC gateway, protocol coverage, real MsQuic E2E tests, process smoke tests, and updated integration documentation.
342 lines
12 KiB
Python
342 lines
12 KiB
Python
#!/usr/bin/env python3
|
|
"""Run a no-hardware cmvr_es process against the local MsQuic test gateway."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import json
|
|
import signal
|
|
import subprocess
|
|
import tempfile
|
|
import time
|
|
from pathlib import Path
|
|
|
|
|
|
def parse_args() -> argparse.Namespace:
|
|
parser = argparse.ArgumentParser()
|
|
parser.add_argument("--gateway", type=Path, required=True)
|
|
parser.add_argument("--cmvr-es", type=Path, required=True)
|
|
parser.add_argument("--cert", type=Path, required=True)
|
|
parser.add_argument("--key", type=Path, required=True)
|
|
parser.add_argument("--timeout-seconds", type=float, default=15.0)
|
|
return parser.parse_args()
|
|
|
|
|
|
def wait_for_json(
|
|
path: Path, process: subprocess.Popen[str], deadline: float
|
|
) -> dict[str, object]:
|
|
last_error: Exception | None = None
|
|
while time.monotonic() < deadline:
|
|
if process.poll() is not None:
|
|
raise RuntimeError(
|
|
f"gateway exited before publishing {path.name}: "
|
|
f"returncode={process.returncode}"
|
|
)
|
|
if path.is_file():
|
|
try:
|
|
return json.loads(path.read_text(encoding="utf-8"))
|
|
except (OSError, json.JSONDecodeError) as error:
|
|
last_error = error
|
|
time.sleep(0.02)
|
|
raise TimeoutError(
|
|
f"timed out waiting for {path}"
|
|
+ (f": {last_error}" if last_error else "")
|
|
)
|
|
|
|
|
|
def stop_process(process: subprocess.Popen[str] | None) -> int | None:
|
|
if process is None:
|
|
return None
|
|
if process.poll() is not None:
|
|
return process.returncode
|
|
process.send_signal(signal.SIGTERM)
|
|
try:
|
|
process.wait(timeout=3.0)
|
|
except subprocess.TimeoutExpired:
|
|
process.kill()
|
|
process.wait(timeout=3.0)
|
|
return process.returncode
|
|
|
|
|
|
def write_runtime_config(config_dir: Path, port: int) -> Path:
|
|
config_dir.mkdir(parents=True, exist_ok=True)
|
|
(config_dir / "cmvr_es.pb.txt").write_text(
|
|
"""
|
|
cmvr_es {
|
|
logger_config_file: "logger.pb.txt"
|
|
device_manager_config_file: "device_manager.pb.txt"
|
|
task_manager_config_file: "task_manager.pb.txt"
|
|
}
|
|
""".lstrip(),
|
|
encoding="utf-8",
|
|
)
|
|
(config_dir / "logger.pb.txt").write_text(
|
|
"""
|
|
logger {
|
|
minimum_level: LOG_LEVEL_INFO
|
|
routes { level: LOG_LEVEL_INFO terminal: true }
|
|
routes { level: LOG_LEVEL_WARNING terminal: true }
|
|
routes { level: LOG_LEVEL_ERROR terminal: true }
|
|
routes { level: LOG_LEVEL_FATAL terminal: true }
|
|
max_file_size_mb: 1
|
|
flush_interval_seconds: 1
|
|
format {
|
|
show_time: false
|
|
show_level: true
|
|
show_thread_id: false
|
|
show_source_location: true
|
|
}
|
|
}
|
|
""".lstrip(),
|
|
encoding="utf-8",
|
|
)
|
|
(config_dir / "device_manager.pb.txt").write_text(
|
|
"""
|
|
device_manager {
|
|
name: "cmvr-quic-process-smoke"
|
|
version: "test"
|
|
description: "no-hardware QUIC process smoke test"
|
|
devices {
|
|
id: "disabled-smoke-camera"
|
|
type: DEVICE_TYPE_CAMERA
|
|
enable: false
|
|
}
|
|
devices {
|
|
id: "disabled-smoke-microphone"
|
|
type: DEVICE_TYPE_MICROPHONE
|
|
enable: false
|
|
}
|
|
devices {
|
|
id: "enabled-missing-camera"
|
|
type: DEVICE_TYPE_CAMERA
|
|
enable: true
|
|
}
|
|
}
|
|
""".lstrip(),
|
|
encoding="utf-8",
|
|
)
|
|
(config_dir / "task_manager.pb.txt").write_text(
|
|
"""
|
|
task_manager {
|
|
tasks {
|
|
id: "quic_edge"
|
|
type: TASK_TYPE_QUIC_EDGE
|
|
run_mode: TASK_RUN_MODE_BLOCKING_SERVICE
|
|
config_file: "quic_edge.pb.txt"
|
|
enable: true
|
|
}
|
|
}
|
|
""".lstrip(),
|
|
encoding="utf-8",
|
|
)
|
|
(config_dir / "quic_edge.pb.txt").write_text(
|
|
f"""
|
|
quic_edge {{
|
|
id: "quic_edge"
|
|
enable: true
|
|
server_host: "127.0.0.1"
|
|
server_port: {port}
|
|
alpn: "cmvr-quic-edge/1"
|
|
node_id: "cmvr-process-smoke"
|
|
software_version: "test"
|
|
grpc_endpoint_host: "127.0.0.1"
|
|
grpc_endpoint_port: 50052
|
|
grpc_endpoint_tls: false
|
|
include_loopback_interfaces: true
|
|
heartbeat_interval_ms: 250
|
|
control_response_timeout_ms: 1000
|
|
tls {{ allow_insecure: true }}
|
|
reconnect {{
|
|
initial_delay_ms: 50
|
|
maximum_delay_ms: 250
|
|
multiplier: 2.0
|
|
jitter_percent: 0
|
|
connect_timeout_ms: 2000
|
|
}}
|
|
maximum_datagram_bytes: 1200
|
|
maximum_control_frame_bytes: 1048576
|
|
maximum_frame_bytes: 16384
|
|
datagram_send_queue_depth: 64
|
|
media_poll_interval_ms: 2
|
|
}}
|
|
""".lstrip(),
|
|
encoding="utf-8",
|
|
)
|
|
return config_dir / "cmvr_es.pb.txt"
|
|
|
|
|
|
def require_count(summary: dict[str, object], key: str, minimum: int) -> None:
|
|
value = summary.get(key)
|
|
if not isinstance(value, int) or value < minimum:
|
|
raise RuntimeError(
|
|
f"gateway summary {key}={value!r}, expected at least {minimum}"
|
|
)
|
|
|
|
|
|
def run() -> int:
|
|
args = parse_args()
|
|
for path in (args.gateway, args.cmvr_es, args.cert, args.key):
|
|
if not path.is_file():
|
|
raise FileNotFoundError(path)
|
|
|
|
deadline = time.monotonic() + args.timeout_seconds
|
|
gateway_process: subprocess.Popen[str] | None = None
|
|
edge_process: subprocess.Popen[str] | None = None
|
|
|
|
with tempfile.TemporaryDirectory(prefix="cmvr-es-quic-smoke-") as temp:
|
|
temp_dir = Path(temp)
|
|
ready_file = temp_dir / "ready.json"
|
|
summary_file = temp_dir / "summary.json"
|
|
gateway_log = temp_dir / "gateway.log"
|
|
edge_log = temp_dir / "cmvr_es.log"
|
|
|
|
try:
|
|
with gateway_log.open("w", encoding="utf-8") as gateway_output:
|
|
gateway_process = subprocess.Popen(
|
|
[
|
|
str(args.gateway),
|
|
"--bind",
|
|
"127.0.0.1",
|
|
"--port",
|
|
"0",
|
|
"--cert",
|
|
str(args.cert),
|
|
"--key",
|
|
str(args.key),
|
|
"--heartbeat-interval-ms",
|
|
# A zero response keeps the edge-side value below,
|
|
# proving heartbeat_interval_ms is configurable.
|
|
"0",
|
|
"--ready-file",
|
|
str(ready_file),
|
|
"--summary-file",
|
|
str(summary_file),
|
|
"--exit-after-heartbeats",
|
|
# The production client keeps only one heartbeat
|
|
# outstanding. Receiving heartbeat 3 therefore proves
|
|
# that ACKs 1 and 2 were processed by cmvr_es.
|
|
"3",
|
|
],
|
|
stdout=gateway_output,
|
|
stderr=subprocess.STDOUT,
|
|
text=True,
|
|
)
|
|
ready = wait_for_json(ready_file, gateway_process, deadline)
|
|
port = ready.get("port")
|
|
if not isinstance(port, int) or not 0 < port <= 65535:
|
|
raise RuntimeError(f"invalid gateway ready payload: {ready}")
|
|
|
|
root_config = write_runtime_config(temp_dir / "config", port)
|
|
with edge_log.open("w", encoding="utf-8") as edge_output:
|
|
edge_process = subprocess.Popen(
|
|
[str(args.cmvr_es), str(root_config)],
|
|
stdout=edge_output,
|
|
stderr=subprocess.STDOUT,
|
|
text=True,
|
|
)
|
|
remaining = max(0.1, deadline - time.monotonic())
|
|
gateway_returncode = gateway_process.wait(timeout=remaining)
|
|
if gateway_returncode != 0:
|
|
raise RuntimeError(
|
|
f"gateway exited with {gateway_returncode}"
|
|
)
|
|
|
|
edge_returncode = stop_process(edge_process)
|
|
edge_process = None
|
|
if edge_returncode != 0:
|
|
raise RuntimeError(
|
|
f"cmvr_es exited with {edge_returncode}"
|
|
)
|
|
|
|
summary = json.loads(summary_file.read_text(encoding="utf-8"))
|
|
if summary.get("runtime_failed") is not False:
|
|
raise RuntimeError(f"gateway runtime failure: {summary}")
|
|
require_count(summary, "registrations_accepted", 1)
|
|
require_count(summary, "heartbeats_received", 3)
|
|
require_count(summary, "heartbeat_acks_sent", 3)
|
|
require_count(summary, "registration_interface_count", 1)
|
|
require_count(summary, "heartbeat_interface_count", 1)
|
|
if summary.get("heartbeat_has_device_manager") is not True:
|
|
raise RuntimeError(
|
|
f"DeviceManager snapshot was not received: {summary}"
|
|
)
|
|
if (
|
|
summary.get("heartbeat_device_manager_name")
|
|
!= "cmvr-quic-process-smoke"
|
|
or summary.get("heartbeat_device_manager_version") != "test"
|
|
or summary.get("heartbeat_device_count") != 1
|
|
or summary.get("heartbeat_enabled_device_count") != 1
|
|
or summary.get("heartbeat_disabled_device_count") != 0
|
|
or summary.get("heartbeat_error_device_count") != 1
|
|
or summary.get("heartbeat_unknown_health_device_count") != 1
|
|
):
|
|
raise RuntimeError(
|
|
f"unexpected DeviceManager heartbeat snapshot: {summary}"
|
|
)
|
|
devices = summary.get("heartbeat_devices")
|
|
if not isinstance(devices, list) or len(devices) != 1:
|
|
raise RuntimeError(
|
|
f"heartbeat device rows are missing: {summary}"
|
|
)
|
|
row = devices[0]
|
|
if (
|
|
not isinstance(row, dict)
|
|
or row.get("device_id") != "enabled-missing-camera"
|
|
or row.get("kind") != 5
|
|
or row.get("type_name") != "Camera"
|
|
or row.get("enabled") is not True
|
|
or row.get("manager_state") != 7
|
|
or row.get("health") != 0
|
|
or row.get("has_error") is not True
|
|
or row.get("error_message") != "device creation failed"
|
|
or not isinstance(
|
|
row.get("status_updated_at_unix_ms"), int
|
|
)
|
|
or row["status_updated_at_unix_ms"] <= 0
|
|
):
|
|
raise RuntimeError(
|
|
f"unexpected heartbeat device row: {row!r}"
|
|
)
|
|
if summary.get("protocol_violations") != 0:
|
|
raise RuntimeError(f"protocol violation: {summary}")
|
|
if summary.get("last_node_id") != "cmvr-process-smoke":
|
|
raise RuntimeError(f"unexpected registered node: {summary}")
|
|
if (
|
|
summary.get("last_grpc_endpoint_host") != "127.0.0.1"
|
|
or summary.get("last_grpc_endpoint_port") != 50052
|
|
or summary.get("last_observed_source_ip") != "127.0.0.1"
|
|
):
|
|
raise RuntimeError(
|
|
f"IP/gRPC endpoint report was not observed: {summary}"
|
|
)
|
|
|
|
edge_output_text = edge_log.read_text(encoding="utf-8")
|
|
if "[QuicEdgeTask] Started" not in edge_output_text:
|
|
raise RuntimeError(
|
|
"cmvr_es did not start the QUIC task:\n" + edge_output_text
|
|
)
|
|
if "CMVR_HAS_MSQUIC is not compiled" in edge_output_text:
|
|
raise RuntimeError(
|
|
"cmvr_es used the unavailable QUIC stub:\n"
|
|
+ edge_output_text
|
|
)
|
|
|
|
print(
|
|
"cmvr_es_quic_process_smoke_test: PASS "
|
|
+ json.dumps(summary, sort_keys=True)
|
|
)
|
|
return 0
|
|
except Exception:
|
|
if gateway_log.is_file():
|
|
print("gateway log:\n" + gateway_log.read_text(encoding="utf-8"))
|
|
if edge_log.is_file():
|
|
print("cmvr_es log:\n" + edge_log.read_text(encoding="utf-8"))
|
|
raise
|
|
finally:
|
|
stop_process(edge_process)
|
|
stop_process(gateway_process)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(run())
|