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

1import re 

2 

3from kubernetes.dynamic.exceptions import ConflictError 

4 

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 

14 

15IPV4_STR = "ipv4" 

16IPV6_STR = "ipv6" 

17 

18 

19class NodeNetworkConfigurationPolicy(Resource): 

20 api_group = Resource.ApiGroup.NMSTATE_IO 

21 

22 class Conditions: 

23 class Type: 

24 DEGRADED = "Degraded" 

25 AVAILABLE = "Available" 

26 

27 class Reason: 

28 CONFIGURATION_PROGRESSING = "ConfigurationProgressing" 

29 SUCCESSFULLY_CONFIGURED = "SuccessfullyConfigured" 

30 FAILED_TO_CONFIGURE = "FailedToConfigure" 

31 NO_MATCHING_NODE = "NoMatchingNode" 

32 

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 

109 

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

120 

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"] 

130 

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", {}) 

136 

137 if self.node_selector_spec: 

138 self.res.setdefault("spec", {}).setdefault("nodeSelector", self.node_selector_spec) 

139 

140 if self.capture: 

141 self.res["spec"]["capture"] = self.capture 

142 

143 if self.dns_resolver: 

144 self.res["spec"]["desiredState"]["dns-resolver"] = self.dns_resolver 

145 

146 if self.routes: 

147 self.res["spec"]["desiredState"]["routes"] = self.routes 

148 

149 if self.max_unavailable: 

150 self.res.setdefault("spec", {}).setdefault("maxUnavailable", self.max_unavailable) 

151 

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 ) 

174 

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

196 

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 

207 

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 

216 

217 if set_ipv6: 

218 if isinstance(set_ipv6, str): 

219 iface[IPV6_STR] = set_ipv6 

220 

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 

230 

231 self.set_interface(interface=iface) 

232 return self.res 

233 

234 def _get_port_from_nns(self, port_name): 

235 if not self.nodes: 

236 return None 

237 

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 

241 

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}'") 

252 

253 def ipv4_ports_backup(self): 

254 self._ports_backup(ip_family=IPV4_STR) 

255 

256 def ipv6_ports_backup(self): 

257 self._ports_backup(ip_family=IPV6_STR) 

258 

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 

273 

274 if ipv6_backup: 

275 iface[IPV6_STR] = ipv6_backup 

276 

277 self.set_interface(interface=iface) 

278 

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 

293 

294 def deploy(self, wait=False): 

295 self.ipv4_ports_backup() 

296 self.ipv6_ports_backup() 

297 

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 

306 

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) 

314 

315 return super().clean_up() 

316 

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) 

321 

322 if self.ports: 

323 self.add_ports() 

324 

325 ResourceEditor( 

326 patches={self: {"spec": {"desiredState": {"interfaces": self.desired_state["interfaces"]}}}} 

327 ).update() 

328 

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"] 

334 

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 

344 

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 

360 

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 

370 

371 raise NNCPConfigurationFailed(f"Reason: {failed_condition_reason}\n{last_err_msg}") 

372 

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 

376 

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}") 

396 

397 elif sample.get("reason") == failed_condition_reason: 

398 self._process_failed_status(failed_condition_reason=failed_condition_reason) 

399 

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 

406 

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 

414 

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 

418 

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 

426 

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] 

432 

433 libnmstate_err = re.findall(r"libnmstate.error.*", nnce_msg) 

434 if libnmstate_err: 

435 err_msg += f"{nnce_prefix}: {libnmstate_err[0]}" 

436 

437 return err_msg 

438 

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 

446 

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