1818class 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 }
0 commit comments