Coverage for ocp_resources/node_network_configuration_policy.py: 0%
244 statements
« prev ^ index » next coverage.py v7.6.10, created at 2025-01-30 10:48 +0200
« prev ^ index » next coverage.py v7.6.10, created at 2025-01-30 10:48 +0200
1import re
3from kubernetes.dynamic.exceptions import ConflictError
5from ocp_resources.utils.constants import TIMEOUT_4MINUTES
6from ocp_resources.exceptions import NNCPConfigurationFailed
7from ocp_resources.node import Node
8from ocp_resources.node_network_configuration_enactment import (
9 NodeNetworkConfigurationEnactment,
10)
11from ocp_resources.node_network_state import NodeNetworkState
12from ocp_resources.resource import Resource, ResourceEditor
13from timeout_sampler import TimeoutExpiredError, TimeoutSampler, TimeoutWatch
15IPV4_STR = "ipv4"
16IPV6_STR = "ipv6"
19class NodeNetworkConfigurationPolicy(Resource):
20 api_group = Resource.ApiGroup.NMSTATE_IO
22 class Conditions:
23 class Type:
24 DEGRADED = "Degraded"
25 AVAILABLE = "Available"
27 class Reason:
28 CONFIGURATION_PROGRESSING = "ConfigurationProgressing"
29 SUCCESSFULLY_CONFIGURED = "SuccessfullyConfigured"
30 FAILED_TO_CONFIGURE = "FailedToConfigure"
31 NO_MATCHING_NODE = "NoMatchingNode"
33 def __init__(
34 self,
35 name=None,
36 client=None,
37 capture=None,
38 node_selector=None,
39 node_selector_labels=None,
40 teardown_absent_ifaces=True,
41 teardown=True,
42 mtu=None,
43 ports=None,
44 ipv4_enable=False,
45 ipv4_dhcp=False,
46 ipv4_auto_dns=True,
47 ipv4_addresses=None,
48 ipv6_enable=False,
49 ipv6_dhcp=False,
50 ipv6_auto_dns=True,
51 ipv6_addresses=None,
52 dns_resolver=None,
53 routes=None,
54 yaml_file=None,
55 set_ipv4=True,
56 set_ipv6=True,
57 max_unavailable=None,
58 state=None,
59 success_timeout=480,
60 delete_timeout=TIMEOUT_4MINUTES,
61 **kwargs,
62 ):
63 """
64 ipv4_addresses should be sent in this format:
65 [{"ip": <ip1-string>, "prefix-length": <prefix-len1-int>},
66 {"ip": <ip2-string>, "prefix-length": <prefix-len2-int>}, ...]
67 For example:
68 [{"ip": "10.1.2.3", "prefix-length": 24},
69 {"ip": "10.4.5.6", "prefix-length": 24},
70 {"ip": "10.7.8.9", "prefix-length": 23}]
71 """
72 super().__init__(
73 name=name,
74 client=client,
75 teardown=teardown,
76 yaml_file=yaml_file,
77 delete_timeout=delete_timeout,
78 node_selector=node_selector,
79 node_selector_labels=node_selector_labels,
80 **kwargs,
81 )
82 self.desired_state = {"interfaces": []}
83 self.mtu = mtu
84 self.capture = capture
85 self.mtu_dict = {}
86 self.ports = ports or []
87 self.iface = None
88 self.ipv4_enable = ipv4_enable
89 self.ipv4_dhcp = ipv4_dhcp
90 self.ipv4_auto_dns = ipv4_auto_dns
91 self.ipv4_addresses = ipv4_addresses or []
92 self.ipv4_iface_state = {}
93 self.ipv6_enable = ipv6_enable
94 self.ipv6_dhcp = ipv6_dhcp
95 self.ipv6_autoconf = self.ipv6_dhcp
96 self.ipv6_auto_dns = ipv6_auto_dns
97 self.ipv6_addresses = ipv6_addresses
98 self.dns_resolver = dns_resolver
99 self.routes = routes
100 self.state = state or self.Interface.State.UP
101 self.set_ipv4 = set_ipv4
102 self.set_ipv6 = set_ipv6
103 self.success_timeout = success_timeout
104 self.max_unavailable = max_unavailable
105 self.ipv4_ports_backup_dict = {}
106 self.ipv6_ports_backup_dict = {}
107 self.nodes = self._nodes()
108 self.teardown_absent_ifaces = teardown_absent_ifaces
110 def _nodes(self):
111 if self.node_selector:
112 return list(
113 Node.get(dyn_client=self.client, name=self.node_selector[f"{self.ApiGroup.KUBERNETES_IO}/hostname"])
114 )
115 if self.node_selector_labels:
116 node_labels = ",".join([
117 f"{label_key}={label_value}" for label_key, label_value in self.node_selector_labels.items()
118 ])
119 return list(Node.get(dyn_client=self.client, label_selector=node_labels))
121 def set_interface(self, interface):
122 if not self.res:
123 super().to_dict()
124 # First drop the interface if it's already in the list
125 interfaces = [iface for iface in self.desired_state["interfaces"] if iface["name"] != interface["name"]]
126 # Add the interface
127 interfaces.append(interface)
128 self.desired_state["interfaces"] = interfaces
129 self.res.setdefault("spec", {}).setdefault("desiredState", {})["interfaces"] = self.desired_state["interfaces"]
131 def to_dict(self) -> None:
132 super().to_dict()
133 if not self.kind_dict and not self.yaml_file:
134 if self.dns_resolver or self.routes or self.iface:
135 self.res.setdefault("spec", {}).setdefault("desiredState", {})
137 if self.node_selector_spec:
138 self.res.setdefault("spec", {}).setdefault("nodeSelector", self.node_selector_spec)
140 if self.capture:
141 self.res["spec"]["capture"] = self.capture
143 if self.dns_resolver:
144 self.res["spec"]["desiredState"]["dns-resolver"] = self.dns_resolver
146 if self.routes:
147 self.res["spec"]["desiredState"]["routes"] = self.routes
149 if self.max_unavailable:
150 self.res.setdefault("spec", {}).setdefault("maxUnavailable", self.max_unavailable)
152 if self.iface:
153 """
154 It's the responsibility of the caller to verify the desired configuration they send.
155 For example: "ipv4.dhcp.enabled: false" without specifying any static IP address
156 is a valid desired state and therefore not blocked in the code, but nmstate would
157 reject it. Such configuration might be used for negative tests.
158 """
159 self.res = self.add_interface(
160 iface=self.iface,
161 state=self.state,
162 set_ipv4=self.set_ipv4,
163 ipv4_enable=self.ipv4_enable,
164 ipv4_dhcp=self.ipv4_dhcp,
165 ipv4_auto_dns=self.ipv4_auto_dns,
166 ipv4_addresses=self.ipv4_addresses,
167 set_ipv6=self.set_ipv6,
168 ipv6_enable=self.ipv6_enable,
169 ipv6_dhcp=self.ipv6_dhcp,
170 ipv6_auto_dns=self.ipv6_auto_dns,
171 ipv6_addresses=self.ipv6_addresses,
172 ipv6_autoconf=self.ipv6_autoconf,
173 )
175 def add_interface(
176 self,
177 iface=None,
178 name=None,
179 type_=None,
180 state=None,
181 set_ipv4=True,
182 ipv4_enable=False,
183 ipv4_dhcp=False,
184 ipv4_auto_dns=True,
185 ipv4_addresses=None,
186 set_ipv6=True,
187 ipv6_enable=False,
188 ipv6_dhcp=False,
189 ipv6_auto_dns=True,
190 ipv6_addresses=None,
191 ipv6_autoconf=False,
192 ):
193 # If self.res is already defined (from to_dict()), don't call it again.
194 if not self.res:
195 self.to_dict()
197 self.res.setdefault("spec", {}).setdefault("desiredState", {})
198 if not iface:
199 iface = {
200 "name": name,
201 "type": type_,
202 "state": state,
203 }
204 if set_ipv4:
205 if isinstance(set_ipv4, str):
206 iface[IPV4_STR] = set_ipv4
208 else:
209 iface[IPV4_STR] = {
210 "enabled": ipv4_enable,
211 "dhcp": ipv4_dhcp,
212 "auto-dns": ipv4_auto_dns,
213 }
214 if ipv4_addresses:
215 iface[IPV4_STR]["address"] = ipv4_addresses
217 if set_ipv6:
218 if isinstance(set_ipv6, str):
219 iface[IPV6_STR] = set_ipv6
221 else:
222 iface[IPV6_STR] = {
223 "enabled": ipv6_enable,
224 "dhcp": ipv6_dhcp,
225 "auto-dns": ipv6_auto_dns,
226 "autoconf": ipv6_autoconf,
227 }
228 if ipv6_addresses:
229 iface[IPV6_STR]["address"] = ipv6_addresses
231 self.set_interface(interface=iface)
232 return self.res
234 def _get_port_from_nns(self, port_name):
235 if not self.nodes:
236 return None
238 nns = NodeNetworkState(name=self.nodes[0].name)
239 _port = [_iface for _iface in nns.interfaces if _iface["name"] == port_name]
240 return _port[0] if _port else None
242 def _ports_backup(self, ip_family):
243 for port in self.ports:
244 _port = self._get_port_from_nns(port_name=port)
245 if _port:
246 if ip_family == IPV4_STR:
247 self.ipv4_ports_backup_dict[port] = _port[ip_family]
248 elif ip_family == IPV6_STR:
249 self.ipv6_ports_backup_dict[port] = _port[ip_family]
250 else:
251 raise ValueError(f"'ip_family' must be either '{IPV4_STR}' or '{IPV6_STR}'")
253 def ipv4_ports_backup(self):
254 self._ports_backup(ip_family=IPV4_STR)
256 def ipv6_ports_backup(self):
257 self._ports_backup(ip_family=IPV6_STR)
259 def add_ports(self):
260 for port in self.ports:
261 _port = self._get_port_from_nns(port_name=port)
262 if _port:
263 ipv4_backup = self.ipv4_ports_backup_dict.get(port)
264 ipv6_backup = self.ipv6_ports_backup_dict.get(port)
265 if ipv4_backup or ipv6_backup:
266 iface = {
267 "name": port,
268 "type": _port["type"],
269 "state": _port["state"],
270 }
271 if ipv4_backup:
272 iface[IPV4_STR] = ipv4_backup
274 if ipv6_backup:
275 iface[IPV6_STR] = ipv6_backup
277 self.set_interface(interface=iface)
279 def apply(self, resource=None):
280 if not resource:
281 super().to_dict()
282 resource = self.res
283 samples = TimeoutSampler(
284 wait_timeout=3,
285 sleep=1,
286 exceptions_dict={ConflictError: []},
287 func=self.update,
288 resource_dict=resource,
289 )
290 self.logger.info(f"Applying {resource}")
291 for _ in samples:
292 return
294 def deploy(self, wait=False):
295 self.ipv4_ports_backup()
296 self.ipv6_ports_backup()
298 self.create(wait=wait)
299 try:
300 self.wait_for_status_success()
301 return self
302 except Exception as exp:
303 self.logger.error(exp)
304 super().__exit__()
305 raise
307 def clean_up(self):
308 if self.teardown_absent_ifaces:
309 try:
310 self._absent_interface()
311 self.wait_for_status_success()
312 except Exception as exp:
313 self.logger.error(exp)
315 return super().clean_up()
317 def _absent_interface(self):
318 for _iface in self.desired_state["interfaces"]:
319 _iface["state"] = self.Interface.State.ABSENT
320 self.set_interface(interface=_iface)
322 if self.ports:
323 self.add_ports()
325 ResourceEditor(
326 patches={self: {"spec": {"desiredState": {"interfaces": self.desired_state["interfaces"]}}}}
327 ).update()
329 @property
330 def status(self):
331 for condition in self.instance.status.conditions:
332 if condition["type"] == self.Conditions.Type.AVAILABLE:
333 return condition["reason"]
335 def wait_for_configuration_conditions_unknown_or_progressing(self, wait_timeout=30):
336 timeout_watcher = TimeoutWatch(timeout=wait_timeout)
337 for sample in TimeoutSampler(
338 wait_timeout=wait_timeout,
339 sleep=1,
340 func=lambda: self.exists,
341 ):
342 if sample:
343 break
345 samples = TimeoutSampler(
346 wait_timeout=timeout_watcher.remaining_time(),
347 sleep=1,
348 func=lambda: self.instance.status.conditions,
349 )
350 for sample in samples:
351 if (
352 sample
353 and sample[0]["type"] == self.Conditions.Type.AVAILABLE
354 and (
355 sample[0]["status"] == self.Condition.Status.UNKNOWN
356 or sample[0]["reason"] == self.Conditions.Reason.CONFIGURATION_PROGRESSING
357 )
358 ):
359 return sample
361 def _process_failed_status(self, failed_condition_reason):
362 last_err_msg = None
363 for failed_nnce in self._get_failed_nnce():
364 nnce_name = failed_nnce.instance.metadata.name
365 nnce_dict = failed_nnce.instance.to_dict()
366 for cond in nnce_dict["status"]["conditions"]:
367 err_msg = self._get_nnce_error_msg(nnce_name=nnce_name, nnce_condition=cond)
368 if err_msg:
369 last_err_msg = err_msg
371 raise NNCPConfigurationFailed(f"Reason: {failed_condition_reason}\n{last_err_msg}")
373 def wait_for_status_success(self):
374 failed_condition_reason = self.Conditions.Reason.FAILED_TO_CONFIGURE
375 no_match_node_condition_reason = self.Conditions.Reason.NO_MATCHING_NODE
377 try:
378 for sample in TimeoutSampler(
379 wait_timeout=self.success_timeout,
380 sleep=5,
381 func=lambda: next(
382 (
383 condition
384 for condition in self.instance.get("status", {}).get("conditions", [])
385 if condition and condition["type"] == self.Conditions.Type.AVAILABLE
386 ),
387 {},
388 ),
389 ):
390 if sample:
391 if sample["status"] == self.Condition.Status.TRUE:
392 self.logger.info(f"NNCP {self.name} configured Successfully")
393 return sample
394 elif sample.get("reason") == no_match_node_condition_reason:
395 raise NNCPConfigurationFailed(f"{self.name}. Reason: {no_match_node_condition_reason}")
397 elif sample.get("reason") == failed_condition_reason:
398 self._process_failed_status(failed_condition_reason=failed_condition_reason)
400 except (TimeoutExpiredError, NNCPConfigurationFailed):
401 self.logger.error(
402 f"Unable to configure NNCP {self.name} "
403 f"{f'nodes: {[node.name for node in self.nodes]}' if self.nodes else ''}"
404 )
405 raise
407 @property
408 def nnces(self):
409 nnces = []
410 for nnce in NodeNetworkConfigurationEnactment.get(dyn_client=self.client):
411 if nnce.name.endswith(f".{self.name}"):
412 nnces.append(nnce)
413 return nnces
415 def node_nnce(self, node_name):
416 nnce = [nnce for nnce in self.nnces if nnce.labels["nmstate.io/node"] == node_name]
417 return nnce[0] if nnce else None
419 @staticmethod
420 def _get_nnce_error_msg(nnce_name, nnce_condition):
421 err_msg = ""
422 nnce_prefix = f"NNCE {nnce_name}"
423 nnce_msg = nnce_condition.get("message")
424 if not nnce_msg:
425 return err_msg
427 errors = nnce_msg.split("->")
428 if errors:
429 err_msg += f"{nnce_prefix}: {errors[0]}"
430 if len(errors) > 1:
431 err_msg += errors[-1]
433 libnmstate_err = re.findall(r"libnmstate.error.*", nnce_msg)
434 if libnmstate_err:
435 err_msg += f"{nnce_prefix}: {libnmstate_err[0]}"
437 return err_msg
439 def _get_failed_nnce(self):
440 for nnce in self.nnces:
441 try:
442 nnce.wait_for_conditions()
443 except TimeoutExpiredError:
444 self.logger.error(f"Failed to get NNCE {nnce.name} status")
445 continue
447 for nnce_cond in nnce.instance.status.conditions:
448 if nnce_cond.type == "Failing" and nnce_cond.status == Resource.Condition.Status.TRUE:
449 yield nnce