Skip to content

Commit 0e442f7

Browse files
committed
fix: stabilize large topology and device-down lifecycle
1 parent eeedf03 commit 0e442f7

21 files changed

Lines changed: 499 additions & 76 deletions

‎.github/workflows/test.yml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -84,7 +84,7 @@ jobs:
8484
python -m venv /tmp/netopsbench-wheel-smoke
8585
/tmp/netopsbench-wheel-smoke/bin/pip install dist/netopsbench-*.whl
8686
cd /tmp
87-
/tmp/netopsbench-wheel-smoke/bin/python -c "from netopsbench.sdk import NetOpsBench; from netopsbench.platform.incident import DiagnosticSession, IncidentEngine"
87+
/tmp/netopsbench-wheel-smoke/bin/python -c "from netopsbench.sdk import DiagnosticSession, IncidentEngine, NetOpsBench"
8888
/tmp/netopsbench-wheel-smoke/bin/netopsbench --help >/dev/null
8989
9090
lint:

‎netopsbench/models/scale_profiles.yaml‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -21,9 +21,9 @@ profiles:
2121
- name: large
2222
topology: {family: clos, spines: 4, leafs: 16, clients_per_leaf: 4}
2323
management: {prefix: 24, subnet_base: 160}
24-
pingmesh: {destination_batch_size: null, rtt_port_pool_size: 16, rtt_ports_per_cycle: 4, cycle_interval_seconds: 1}
25-
traffic: {max_pps_per_client: 150}
26-
runtime: {deploy_timeout_seconds: 2700, worker_deploy_parallelism: 1, health_timeout_seconds: 180}
24+
pingmesh: {destination_batch_size: 16, rtt_port_pool_size: 16, rtt_ports_per_cycle: 4, cycle_interval_seconds: 3}
25+
traffic: {max_pps_per_client: 100}
26+
runtime: {deploy_timeout_seconds: 2700, worker_deploy_parallelism: 1, health_timeout_seconds: 180, containerlab_max_workers: 16}
2727
- name: xlarge
2828
topology: {family: clos, spines: 16, leafs: 128, clients_per_leaf: 1}
2929
management: {prefix: 23, subnet_base: 180}

‎netopsbench/platform/faults/handlers/system.py‎

Lines changed: 143 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,10 @@
1818
class SystemHandler:
1919
"""Handles device-level (system) fault injection and recovery."""
2020

21-
_CONTAINERLAB_TIMEOUT = "20s"
21+
_CONTAINERLAB_TIMEOUT = "120s"
22+
_CONTAINERLAB_COMMAND_TIMEOUT_SECONDS = 150
23+
_STOP_SETTLE_TIMEOUT_SECONDS = 30
24+
_STOP_SETTLE_POLL_SECONDS = 1
2225
_ACTIVATION_MAX_TRIES = 36
2326
_BGP_MAX_TRIES = 20
2427

@@ -47,11 +50,108 @@ def _containerlab_node_command(self, operation: str, device: str) -> list[str]:
4750
self._CONTAINERLAB_TIMEOUT,
4851
]
4952

50-
def _start_and_wait(self, device: str, container: str) -> tuple[bool, str]:
51-
start = self._cmd.run_cmd(self._containerlab_node_command("start", device), timeout=60)
53+
@staticmethod
54+
def _parking_namespace(container: str) -> str:
55+
return f"clab-park-{container}"
56+
57+
def _parking_namespace_exists(self, container: str) -> bool | None:
58+
# Listing named namespaces is read-only and does not require root.
59+
# Using ``sudo -n`` here made a healthy parking namespace unreadable on
60+
# hosts whose sudoers policy grants Containerlab but not arbitrary
61+
# ``ip`` commands.
62+
result = self._cmd.run_cmd(["ip", "netns", "list"], timeout=15)
63+
if result.returncode != 0:
64+
return None
65+
expected = self._parking_namespace(container)
66+
return any(line.split(maxsplit=1)[0] == expected for line in (result.stdout or "").splitlines() if line.strip())
67+
68+
def _settled_stop_state(self, container: str) -> tuple[bool | None, bool | None]:
69+
"""Wait for Docker's state and Containerlab's parking state to settle."""
70+
deadline = time.monotonic() + self._STOP_SETTLE_TIMEOUT_SECONDS
71+
state: bool | None = None
72+
parking: bool | None = None
73+
responsive_polls = 0
74+
while True:
75+
state = self._cmd.container_is_running(container)
76+
parking = self._parking_namespace_exists(container)
77+
if state is False:
78+
return state, parking
79+
if state is True and parking is False:
80+
responsive = self._cmd.docker_exec(container, ["/bin/true"], timeout=10)
81+
if responsive.returncode == 0:
82+
responsive_polls += 1
83+
if responsive_polls >= 3:
84+
return state, parking
85+
else:
86+
responsive_polls = 0
87+
else:
88+
responsive_polls = 0
89+
if time.monotonic() >= deadline:
90+
return state, parking
91+
time.sleep(self._STOP_SETTLE_POLL_SECONDS)
92+
93+
def _expected_dataplane_interface_count(self, device: str) -> int:
94+
return sum(
95+
1 for link in self._ctx.manifest.links if any(endpoint.device == device for endpoint in link.endpoints)
96+
)
97+
98+
def _observed_dataplane_interface_count(self, container: str) -> int | None:
99+
result = self._cmd.docker_exec(
100+
container,
101+
[
102+
"bash",
103+
"-lc",
104+
"count=0; for path in /sys/class/net/eth*; do "
105+
'[ -e "$path" ] || continue; [ "${path##*/}" = eth0 ] && continue; '
106+
"count=$((count + 1)); done; "
107+
"printf '%s\\n' \"$count\"",
108+
],
109+
timeout=15,
110+
)
111+
if result.returncode != 0:
112+
return None
113+
try:
114+
return int((result.stdout or "").strip())
115+
except ValueError:
116+
return None
117+
118+
def _start_and_wait(self, device: str, container: str) -> tuple[bool, str, bool]:
119+
running_before = self._cmd.container_is_running(container)
120+
parking_before = self._parking_namespace_exists(container)
121+
if running_before is False and parking_before is False:
122+
return False, "device container is stopped but its parking namespace is missing", False
123+
if parking_before is None:
124+
return False, "unable to inspect the device parking namespace", True
125+
if running_before is None:
126+
return False, "unable to inspect the device container state", True
127+
if running_before is True:
128+
return False, "device container is already running while the device-down fault is active", False
129+
130+
start = self._cmd.run_cmd(
131+
self._containerlab_node_command("start", device),
132+
timeout=self._CONTAINERLAB_COMMAND_TIMEOUT_SECONDS,
133+
)
52134
running_state = self._cmd.container_is_running(container)
53-
if start.returncode != 0 and running_state is not True:
54-
return False, (start.stderr or start.stdout or "").strip() or "containerlab node start failed"
135+
parking_after = self._parking_namespace_exists(container)
136+
if running_state is not True or parking_after is not False:
137+
detail = (start.stderr or start.stdout or "").strip() or "containerlab node start failed"
138+
retryable = running_state is False and parking_after is True
139+
if parking_after is False and running_state is not True:
140+
retryable = False
141+
detail = f"{detail}; parking namespace was lost before the container recovered"
142+
elif parking_after is True and running_state is True:
143+
retryable = False
144+
detail = f"{detail}; container is running while dataplane interfaces remain parked"
145+
return False, detail, retryable
146+
147+
expected_interfaces = self._expected_dataplane_interface_count(device)
148+
observed_interfaces = self._observed_dataplane_interface_count(container)
149+
if observed_interfaces != expected_interfaces:
150+
return (
151+
False,
152+
f"restored dataplane interface count is {observed_interfaces}, expected {expected_interfaces}",
153+
False,
154+
)
55155

56156
if not self._sonic.supervisord_ready(container):
57157
supervisor = self._cmd.run_cmd(
@@ -66,11 +166,15 @@ def _start_and_wait(self, device: str, container: str) -> tuple[bool, str]:
66166
timeout=30,
67167
)
68168
if supervisor.returncode != 0:
69-
return False, (supervisor.stderr or supervisor.stdout or "").strip() or "supervisord start failed"
169+
return (
170+
False,
171+
(supervisor.stderr or supervisor.stdout or "").strip() or "supervisord start failed",
172+
True,
173+
)
70174

71175
manifest_device = self._ctx.manifest.device(device)
72176
if manifest_device is None:
73-
return False, f"device {device!r} is missing from topology manifest"
177+
return False, f"device {device!r} is missing from topology manifest", False
74178
ecmp_hash_policy = self._ctx.manifest.routing.ecmp_hash_policy_by_role[manifest_device.role]
75179
activated, activation_error = activate_device(
76180
device,
@@ -80,68 +184,73 @@ def _start_and_wait(self, device: str, container: str) -> tuple[bool, str]:
80184
readiness_max_tries=self._ACTIVATION_MAX_TRIES,
81185
)
82186
if not activated:
83-
return False, activation_error
187+
return False, activation_error, True
84188

85189
last_error = ""
86190
for _attempt in range(self._BGP_MAX_TRIES):
87191
running = self._cmd.container_is_running(container)
88192
supervisor_ready = running is True and self._sonic.supervisord_ready(container)
89193
if supervisor_ready and self._sonic.bgp_neighbors_established(device):
90-
return True, ""
194+
return True, "", False
91195
if running is True:
92196
bgp_result = self._sonic.vtysh(device, ["show ip bgp summary"])
93197
last_error = (bgp_result.stderr or bgp_result.stdout or "").strip() or last_error
94198
elif running is None:
95199
last_error = "unable to read container running state"
96200
time.sleep(5)
97-
return False, last_error or "device did not recover after containerlab node start"
201+
return False, last_error or "device did not recover after containerlab node start", True
98202

99203
def inject_device_down(self, device: str) -> dict[str, Any]:
100204
container = self._ctx.container_names.get(device)
101205
if not container:
102206
raise ValueError(f"Unknown device: {device}")
103207

104-
result = self._cmd.run_cmd(self._containerlab_node_command("stop", device), timeout=60)
105-
running_state = self._cmd.container_is_running(container)
106-
success = result.returncode == 0 and running_state is False
208+
result = self._cmd.run_cmd(
209+
self._containerlab_node_command("stop", device),
210+
timeout=self._CONTAINERLAB_COMMAND_TIMEOUT_SECONDS,
211+
)
212+
running_state, parking_exists = self._settled_stop_state(container)
213+
success = running_state is False and parking_exists is True
214+
parking_namespace = self._parking_namespace(container)
215+
if success:
216+
error = None
217+
else:
218+
detail = (result.stderr or result.stdout or "").strip()
219+
state_detail = (
220+
f"container_running={running_state!r}, "
221+
f"parking_namespace={parking_namespace!r}, parking_exists={parking_exists!r}"
222+
)
223+
error = "; ".join(filter(None, [detail, state_detail]))
107224
fault_info = {
108225
"type": "device_down",
109226
"device": device,
110227
"container": container,
111228
"mode": "containerlab_node_stop",
112229
"success": success,
113-
"error": (
114-
None
115-
if success
116-
else (result.stderr or result.stdout or "").strip()
117-
or "container remained running after containerlab node stop"
118-
),
230+
"parking_namespace": parking_namespace,
231+
"container_running": running_state,
232+
"parking_exists": parking_exists,
233+
"management_unavailable": running_state is False,
234+
"control_plane_unavailable": running_state is False,
235+
"data_plane_unavailable": parking_exists is True,
236+
"error": error,
119237
}
120238
if success:
121239
self._tracker.track(fault_info)
122240
return fault_info
123241

124-
compensated, compensation_error = self._start_and_wait(device, container)
125-
if not compensated:
126-
error = "; ".join(
127-
filter(
128-
None,
129-
[
130-
str(fault_info.get("error") or ""),
131-
compensation_error or "device-down compensation failed",
132-
],
133-
)
134-
)
135-
fault_info["error"] = error
136-
self._tracker.track_residual(fault_info, error)
242+
clean_failure = running_state is True and parking_exists is False
243+
if not clean_failure:
244+
fault_info["retryable"] = False
245+
self._tracker.track_residual(fault_info, str(error or "device-down state is inconsistent"))
137246
return fault_info
138247

139248
def recover_device_down(self, device: str) -> dict[str, Any]:
140249
container = self._ctx.container_names.get(device)
141250
if not container:
142251
raise ValueError(f"Unknown device: {device}")
143252

144-
ready, last_error = self._start_and_wait(device, container)
253+
ready, last_error, retryable = self._start_and_wait(device, container)
145254

146255
if ready:
147256
self._tracker.remove_faults(lambda fault: fault["type"] == "device_down" and fault["device"] == device)
@@ -152,5 +261,6 @@ def recover_device_down(self, device: str) -> dict[str, Any]:
152261
"recovered": ready,
153262
"container_running": self._cmd.container_is_running(container),
154263
"sonic_ready": ready,
264+
"retryable": retryable,
155265
"error": None if ready else last_error,
156266
}

‎netopsbench/platform/runtime/deployment.py‎

Lines changed: 68 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
import ipaddress
77
import json
88
import os
9+
import shlex
910
import signal
1011
import tempfile
1112
import time
@@ -16,8 +17,9 @@
1617
from netopsbench.models.profiles import ScaleProfile, ScaleRegistry, get_scale_profile
1718
from netopsbench.models.runtime import RuntimeIdentity
1819
from netopsbench.platform.runtime.apply_configs import apply_configs
20+
from netopsbench.platform.topology.config import SONIC_PID1_COMMAND
1921
from netopsbench.platform.topology.generator import generate_topology
20-
from netopsbench.platform.topology.topology_utils import load_topology_manifest
22+
from netopsbench.platform.topology.topology_utils import clab_container_name, load_topology_manifest
2123
from netopsbench.platform.utils.proc import docker_prefix, safe_run, sudo_prefix
2224

2325
APPLY_CONFIG_PARALLELISM = 32
@@ -27,6 +29,10 @@
2729
logger = get_logger(__name__)
2830

2931

32+
def _read_process_comm(pid: int) -> str:
33+
return Path(f"/proc/{pid}/comm").read_text(encoding="utf-8").strip()
34+
35+
3036
def management_subnet_stride(scale: str, registry: ScaleRegistry | None = None) -> int:
3137
prefix = get_scale_profile(scale, registry).management_prefix
3238
return 1 if prefix >= 24 else 2 ** (24 - prefix)
@@ -148,11 +154,72 @@ def deploy_worker_lab(worker: RuntimeIdentity, scale: str, registry: ScaleRegist
148154
details = (deploy_result.stderr or deploy_result.stdout or "no diagnostic output").strip()
149155
raise RuntimeError(f"Containerlab deploy failed ({deploy_result.returncode}): {details[-4000:]}")
150156

157+
_verify_sonic_pid1_contract(worker)
151158
result = apply_configs(str(topology_dir), APPLY_CONFIG_PARALLELISM, worker.lab_name)
152159
if result.failed:
153160
raise RuntimeError(f"SONiC activation failed for: {', '.join(result.failed)}")
154161

155162

163+
def _verify_sonic_pid1_contract(worker: RuntimeIdentity) -> None:
164+
"""Require the generated, SIGTERM-responsive PID 1 on every SONiC node."""
165+
manifest = load_topology_manifest(worker.topology_dir)
166+
containers = [clab_container_name(worker.lab_name, device.name) for device in manifest.routing_devices()]
167+
if not containers:
168+
raise RuntimeError(f"Topology {worker.lab_name!r} has no SONiC routing devices")
169+
170+
inspected = safe_run(
171+
[
172+
*docker_prefix(),
173+
"docker",
174+
"inspect",
175+
"--format",
176+
"{{.Name}}\t{{.State.Running}}\t{{.State.Pid}}\t{{json .Config.Cmd}}",
177+
*containers,
178+
],
179+
capture_output=True,
180+
text=True,
181+
check=False,
182+
timeout=120,
183+
)
184+
if inspected.returncode != 0:
185+
details = (inspected.stderr or inspected.stdout or "no diagnostic output").strip()
186+
raise RuntimeError(f"Unable to verify SONiC PID 1 contract: {details[-2000:]}")
187+
188+
failures: list[str] = []
189+
seen: set[str] = set()
190+
for line in inspected.stdout.splitlines():
191+
parts = line.split("\t", 3)
192+
if len(parts) != 4:
193+
failures.append(f"malformed docker inspect output: {line!r}")
194+
continue
195+
raw_name, running, raw_pid, command = parts
196+
name = raw_name.lstrip("/")
197+
seen.add(name)
198+
if running.lower() != "true":
199+
failures.append(f"{name}: container is not running")
200+
continue
201+
try:
202+
parsed_command = json.loads(command)
203+
except json.JSONDecodeError:
204+
parsed_command = None
205+
if parsed_command != shlex.split(SONIC_PID1_COMMAND):
206+
failures.append(f"{name}: unexpected command {command}")
207+
continue
208+
try:
209+
pid = int(raw_pid)
210+
pid1 = _read_process_comm(pid)
211+
except (OSError, ValueError) as exc:
212+
failures.append(f"{name}: unable to inspect PID 1: {exc}")
213+
continue
214+
if pid1 != "bash":
215+
failures.append(f"{name}: PID 1 is {pid1!r}, expected the signal-handling 'bash' wrapper")
216+
217+
missing = sorted(set(containers) - seen)
218+
failures.extend(f"{name}: missing docker inspect result" for name in missing)
219+
if failures:
220+
raise RuntimeError("SONiC PID 1 contract failed: " + "; ".join(failures[:12]))
221+
222+
156223
def assert_worker_slot_available(worker: RuntimeIdentity) -> None:
157224
"""Reject globally conflicting Containerlab resources before deployment."""
158225
docker = docker_prefix()

0 commit comments

Comments
 (0)