You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 
 

162 lines
7.6 KiB

#!/usr/bin/env python3
"""Запуск стенда: главный namespace используется только для чтения снимка."""
import argparse
import json
import os
from pathlib import Path
import shutil
import signal
import subprocess
import sys
import time
from network import Network
from probe import Probes
from scenario import Scenario
from topology import manifest
def host_snapshot():
result = {}
for item, arguments in (("addresses", ["ip", "-j", "addr"]), ("routes", ["ip", "-j", "route", "show", "table", "all"]),
("rules", ["ip", "-j", "rule"]), ("qdisc", ["tc", "-j", "qdisc"])):
result[item] = json.loads(subprocess.check_output(arguments, text=True))
return result
def stable_snapshot(value):
# DHCP lifetime и route cache age меняются от часов, без изменения конфигурации.
if isinstance(value, dict):
return {key: stable_snapshot(item) for key, item in value.items()
if key not in {"valid_life_time", "preferred_life_time", "expires", "used", "lastuse"}}
if isinstance(value, list):
return [stable_snapshot(item) for item in value]
return value
def inside(args):
def interrupted(signum, _frame):
raise KeyboardInterrupt(f"signal {signum}")
signal.signal(signal.SIGTERM, interrupted)
signal.signal(signal.SIGINT, interrupted)
directory = Path(args.output)
network = Network(directory, args.seed, args.host_netns)
result = {"pass": False}
probes = Probes(network)
scenario = None
try:
print("Creating 25 isolated network namespaces (16 nodes, 3 routers, 6 NAT gateways)", flush=True)
network.build()
network.snapshot("calibration-initial")
print("Calibrating WAN RTT and all 10 NAT clients", flush=True)
probes.start()
calibration = probes.calibrate()
(directory / "calibration.json").write_text(json.dumps(calibration, indent=2) + "\n")
probes.close()
print("Network calibration passed", flush=True)
if args.network_only:
result["pass"] = True
else:
# Пробы не оставляют открытых mappings/conntrack для настоящих процессов uTun.
network.snapshot("calibrated")
network.close()
print("Recreating network with empty NAT mappings and conntrack", flush=True)
network = Network(directory, args.seed, args.host_netns)
network.build()
network.snapshot("initial")
scenario = Scenario(network, args)
result = scenario.run()
except BaseException as error:
result["error"] = str(error)
print(f"FAIL: {type(error).__name__}: {error}", file=sys.stderr, flush=True)
finally:
probes.close()
if scenario:
failures = scenario.close()
if failures:
result["pass"] = False
result["agent_exit_errors"] = failures
try:
network.snapshot("final")
except Exception as error:
result["snapshot_error"] = str(error)
network.close()
(directory / "result.json").write_text(json.dumps(result, indent=2) + "\n")
return 0 if result["pass"] else 1
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--output", help="New directory for logs and results")
parser.add_argument("--seed", type=int, default=20261011)
parser.add_argument("--network-only", action="store_true", help="Only topology, WAN and NAT calibration")
parser.add_argument("--binary", default=str(Path(__file__).parent / "stream_lab_node"))
parser.add_argument("--publisher", choices=[f"n{i:02d}" for i in range(1, 17)], default="n01")
parser.add_argument("--rate", type=int, default=1000000, help="Application bitrate in bits/s, before FEC")
parser.add_argument("--duration", type=int, default=120, help="Baseline duration in real seconds")
parser.add_argument("--faults", action="store_true", help="Degrade WAN, recover and check unsubscribe")
parser.add_argument("--fault-duration", type=int, default=30)
transport = parser.add_mutually_exclusive_group()
transport.add_argument("--tcp", action="store_true", help="Also expose TCP sockets, permitting ETCP multilink")
transport.add_argument("--tcp-only", action="store_true", help="Expose only TCP, requiring the real STCP transport")
parser.add_argument("--inside", action="store_true", help=argparse.SUPPRESS)
parser.add_argument("--host-netns", help=argparse.SUPPRESS)
args = parser.parse_args()
if not args.network_only and (not Path(args.binary).is_file() or args.rate <= 0 or args.duration < 1 or args.fault_duration < 1):
parser.error("Build stream_lab_node first; rate and durations must be positive")
if args.inside:
return inside(args)
for program in ("ip", "tc", "nft", "ethtool", "sysctl", "unshare", "nsenter"):
if not shutil.which(program):
parser.error(f"Required tool is missing: {program}")
directory = Path(args.output or Path(__file__).parent / "runs" / time.strftime("%Y%m%d-%H%M%S")).resolve()
directory.mkdir(parents=True, exist_ok=False)
(directory / "topology.json").write_text(json.dumps(manifest(args.seed), indent=2) + "\n")
original_namespace = os.readlink("/proc/self/ns/net")
before = host_snapshot()
(directory / "host-before.json").write_text(json.dumps(before, indent=2) + "\n")
command = ["unshare", "--user", "--map-root-user", "--keep-caps", "--mount", "--net", "--pid", "--fork",
"--mount-proc", "--propagation", "private", "--kill-child", "--", sys.executable,
str(Path(__file__).resolve()), "--inside", "--host-netns", original_namespace,
"--output", str(directory), "--seed", str(args.seed), "--binary", str(Path(args.binary).resolve()),
"--publisher", args.publisher, "--rate", str(args.rate), "--duration", str(args.duration),
"--fault-duration", str(args.fault_duration)]
if args.network_only:
command.append("--network-only")
if args.faults:
command.append("--faults")
if args.tcp:
command.append("--tcp")
if args.tcp_only:
command.append("--tcp-only")
print(f"Artifacts: {directory}", flush=True)
child = subprocess.Popen(command)
try:
status = child.wait()
except KeyboardInterrupt:
child.terminate()
try:
status = child.wait(timeout=10)
except subprocess.TimeoutExpired:
child.kill()
status = child.wait()
after = host_snapshot()
(directory / "host-after.json").write_text(json.dumps(after, indent=2) + "\n")
unchanged = stable_snapshot(before) == stable_snapshot(after) and os.readlink("/proc/self/ns/net") == original_namespace
(directory / "host-check.json").write_text(json.dumps({"unchanged": unchanged}, indent=2) + "\n")
result_path = directory / "result.json"
result = json.loads(result_path.read_text()) if result_path.exists() else {"pass": False, "error": "Isolated runner did not complete"}
result.update(host_unchanged=unchanged, launcher_exit=status)
result["pass"] = result["pass"] and unchanged and status == 0
result_path.write_text(json.dumps(result, indent=2) + "\n")
if not unchanged:
print("FAIL: main network namespace snapshot changed during the run", file=sys.stderr)
return 1
print(f"Main network namespace unchanged; exit={status}", flush=True)
return status
if __name__ == "__main__":
sys.exit(main())