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
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())
|
|
|