qontract-reconcile 0.10.2.dev395__py3-none-any.whl → 0.10.2.dev408__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
reconcile/utils/oc.py CHANGED
@@ -68,7 +68,8 @@ if TYPE_CHECKING:
68
68
  urllib3.disable_warnings()
69
69
 
70
70
  GET_REPLICASET_MAX_ATTEMPTS = 20
71
-
71
+ DEFAULT_GROUP = ""
72
+ PROJECT_KIND = "Project.project.openshift.io"
72
73
 
73
74
  oc_run_execution_counter = Counter(
74
75
  name="oc_run_execution_counter",
@@ -145,6 +146,14 @@ class RequestEntityTooLargeError(Exception):
145
146
  pass
146
147
 
147
148
 
149
+ class KindNotFoundError(Exception):
150
+ pass
151
+
152
+
153
+ class AmbiguousResourceTypeError(Exception):
154
+ pass
155
+
156
+
148
157
  class OCDecorators:
149
158
  @classmethod
150
159
  def process_reconcile_time(cls, function: Callable) -> Callable:
@@ -380,10 +389,7 @@ class OCCli:
380
389
 
381
390
  self.init_projects = init_projects
382
391
  if self.init_projects:
383
- if self.is_kind_supported("Project"):
384
- kind = "Project.project.openshift.io"
385
- else:
386
- kind = "Namespace"
392
+ kind = PROJECT_KIND if self.is_kind_supported(PROJECT_KIND) else "Namespace"
387
393
  self.projects = {p["metadata"]["name"] for p in self.get_all(kind)["items"]}
388
394
 
389
395
  self.slow_oc_reconcile_threshold = float(
@@ -453,10 +459,7 @@ class OCCli:
453
459
 
454
460
  self.init_projects = init_projects
455
461
  if self.init_projects:
456
- if self.is_kind_supported("Project"):
457
- kind = "Project.project.openshift.io"
458
- else:
459
- kind = "Namespace"
462
+ kind = PROJECT_KIND if self.is_kind_supported(PROJECT_KIND) else "Namespace"
460
463
  self.projects = {p["metadata"]["name"] for p in self.get_all(kind)["items"]}
461
464
 
462
465
  self.slow_oc_reconcile_threshold = float(
@@ -637,11 +640,9 @@ class OCCli:
637
640
  def project_exists(self, name: str) -> bool:
638
641
  if name in self.projects:
639
642
  return True
643
+ kind = PROJECT_KIND if self.is_kind_supported(PROJECT_KIND) else "Namespace"
640
644
  try:
641
- if self.is_kind_supported("Project"):
642
- self.get(None, "Project.project.openshift.io", name)
643
- else:
644
- self.get(None, "Namespace", name)
645
+ self.get(None, kind, name)
645
646
  except StatusCodeError as e:
646
647
  if "NotFound" in str(e):
647
648
  return False
@@ -650,7 +651,7 @@ class OCCli:
650
651
 
651
652
  @OCDecorators.process_reconcile_time
652
653
  def new_project(self, namespace: str) -> OCProcessReconcileTimeDecoratorMsg:
653
- if self.is_kind_supported("Project"):
654
+ if self.is_kind_supported(PROJECT_KIND):
654
655
  cmd = ["new-project", namespace]
655
656
  else:
656
657
  cmd = ["create", "namespace", namespace]
@@ -666,7 +667,7 @@ class OCCli:
666
667
 
667
668
  @OCDecorators.process_reconcile_time
668
669
  def delete_project(self, namespace: str) -> OCProcessReconcileTimeDecoratorMsg:
669
- if self.is_kind_supported("Project"):
670
+ if self.is_kind_supported(PROJECT_KIND):
670
671
  cmd = ["delete", "project", namespace]
671
672
  else:
672
673
  cmd = ["delete", "namespace", namespace]
@@ -717,7 +718,7 @@ class OCCli:
717
718
  cmd = ["sa", "-n", namespace, "get-token", name]
718
719
  return self._run(cmd)
719
720
 
720
- def get_api_resources(self) -> dict[str, Any]:
721
+ def get_api_resources(self) -> dict[str, list[OCCliApiResource]]:
721
722
  with self.api_resources_lock:
722
723
  if not self.api_resources:
723
724
  cmd = ["api-resources", "--no-headers"]
@@ -1196,76 +1197,90 @@ class OCCli:
1196
1197
 
1197
1198
  return out_json
1198
1199
 
1199
- def _parse_kind(self, kind_name: str) -> tuple[str, str]:
1200
- # This is a provisional solution while we work in redefining
1201
- # the api resources initialization.
1202
- if not self.api_resources:
1203
- self.get_api_resources()
1200
+ def parse_kind(self, kind: str) -> tuple[str, str, str]:
1201
+ """Parse a Kubernetes kind string into its components.
1204
1202
 
1205
- kind_group = kind_name.split(".", 1)
1206
- kind = kind_group[0]
1207
- if kind in self.api_resources:
1208
- group_version = self.api_resources[kind][0].group_version
1209
- else:
1210
- raise StatusCodeError(f"{self.server}: {kind} does not exist")
1211
-
1212
- # if a kind_group has more than 1 entry than the kind_name is in
1213
- # the format kind.apigroup. Find the apigroup/version that matches
1214
- # the apigroup passed with the kind_name
1215
- if len(kind_group) > 1:
1216
- apigroup_override = kind_group[1]
1217
- find = False
1218
- for gv in self.api_resources[kind]:
1219
- if apigroup_override == gv.group:
1220
- if not gv.group:
1221
- group_version = gv.api_version
1222
- else:
1223
- group_version = f"{gv.group}/{gv.api_version}"
1224
- find = True
1225
- break
1203
+ Supports three formats:
1204
+ - kind
1205
+ - kind.group.whatever
1206
+ - kind.group.whatever/version
1226
1207
 
1227
- if not find:
1228
- raise StatusCodeError(
1229
- f"{self.server}: {apigroup_override} does not have kind {kind}"
1230
- )
1231
- return (kind, group_version)
1208
+ Args:
1209
+ kind: A Kubernetes kind string in one of the supported formats
1210
+
1211
+ Returns:
1212
+ Tuple of (kind, group, version) where missing parts are empty strings
1213
+
1214
+ Raises:
1215
+ ValueError: If the kind string format is invalid
1216
+
1217
+ Examples:
1218
+ >>> parse_kind_string("Deployment")
1219
+ ('Deployment', '', '')
1220
+ >>> parse_kind_string("ClusterRoleBinding.rbac.authorization.k8s.io")
1221
+ ('ClusterRoleBinding', 'rbac.authorization.k8s.io', '')
1222
+ >>> parse_kind_string("CustomResource.mygroup.example.com/v1")
1223
+ ('CustomResource', 'mygroup.example.com', 'v1')
1224
+ """
1225
+ pattern = r"^(?P<kind>[^./]+)(?:\.(?P<group>[^/]+))?(?:/(?P<version>.+))?$"
1226
+ match = re.match(pattern, kind)
1227
+ if not match:
1228
+ raise ValueError(f"Invalid kind string: {kind}")
1229
+
1230
+ kind = match.group("kind") or ""
1231
+ group = match.group("group") or DEFAULT_GROUP
1232
+ version = match.group("version") or ""
1233
+
1234
+ return kind, group, version
1232
1235
 
1233
1236
  def is_kind_supported(self, kind: str) -> bool:
1234
- # This is a provisional solution while we work in redefining
1235
- # the api resources initialization.
1236
- if not self.api_resources:
1237
- self.get_api_resources()
1237
+ """Returns True if the given kind is supported by the cluster, False otherwise.
1238
1238
 
1239
- if "." in kind:
1240
- try:
1241
- self._parse_kind(kind)
1242
- return True
1243
- except StatusCodeError:
1244
- return False
1245
- else:
1246
- return kind in self.api_resources
1239
+ Kind can be either kind, kind.group or kind.group/version."""
1240
+ try:
1241
+ self.get_api_resource(kind)
1242
+ return True
1243
+ except KindNotFoundError:
1244
+ return False
1247
1245
 
1248
1246
  def is_kind_namespaced(self, kind: str) -> bool:
1249
- # This is a provisional solution while we work in redefining
1250
- # the api resources initialization.
1247
+ """Returns True if the given kind is namespaced, False if it's cluster scoped.
1248
+
1249
+ Kind can be either kind, kind.group or kind.group/version."""
1250
+ return self.get_api_resource(kind).namespaced
1251
+
1252
+ def get_api_resource(self, kind: str) -> OCCliApiResource:
1253
+ """Return the OCCliApiResource for the given resource type.
1254
+
1255
+ Resource type can be either kind, kind.group or kind.group/version.
1256
+ If kind is not unique, group must be specified."""
1257
+
1251
1258
  if not self.api_resources:
1252
- self.get_api_resources()
1259
+ raise RuntimeError("API resources not initialized")
1253
1260
 
1254
- kg = kind.split(".", 1)
1255
- kind = kg[0]
1261
+ kind, group, _ = self.parse_kind(kind)
1256
1262
 
1257
- # Same Kinds might exist in different api groups
1258
- kind_resources = self.api_resources.get(kind)
1259
- if not kind_resources:
1260
- raise StatusCodeError(f"Kind {kind} does not exist in the ApiServer")
1263
+ if not (resources := self.api_resources.get(kind)):
1264
+ # the kind not found at all
1265
+ raise KindNotFoundError(f"Unsupported resource type: {kind}")
1261
1266
 
1262
- if len(kg) > 1:
1263
- group = kg[1]
1264
- for r in kind_resources:
1265
- if group == r.group:
1266
- return r.namespaced
1267
- raise StatusCodeError(f"Kind: {kind} does nod exist in the ApiServer")
1268
- return kind_resources[0].namespaced
1267
+ if len(resources) == 1 and group == DEFAULT_GROUP:
1268
+ return resources[0]
1269
+
1270
+ # get the resource with the specified group
1271
+ if resource := next((r for r in resources if r.group == group), None):
1272
+ return resource
1273
+
1274
+ # no resource with the specified group found
1275
+ if group == DEFAULT_GROUP:
1276
+ message = (
1277
+ f"Ambiguous resource type: {kind}. "
1278
+ "Please fully qualify it with its API group. E.g., ClusterRoleBinding -> ClusterRoleBinding.rbac.authorization.k8s.io"
1279
+ )
1280
+ raise AmbiguousResourceTypeError(message)
1281
+
1282
+ # group was specified but no matching resource found
1283
+ raise KindNotFoundError(f"Unsupported resource type: {kind}")
1269
1284
 
1270
1285
 
1271
1286
  REQUEST_TIMEOUT = 60
@@ -1305,20 +1320,16 @@ class OCNative(OCCli):
1305
1320
 
1306
1321
  server = connection_parameters.server_url
1307
1322
 
1308
- if server:
1309
- self.client = self._get_client(server, token)
1310
- self.api_resources = self.get_api_resources()
1323
+ if not server:
1324
+ raise Exception("Server name is required!")
1311
1325
 
1312
- else:
1313
- raise Exception("A method relies on client/api_kind_version to be set")
1326
+ self.client = self._get_client(server, token)
1327
+ self.api_resources = self.get_api_resources()
1314
1328
 
1315
1329
  self.projects = set()
1316
1330
  self.init_projects = init_projects
1317
1331
  if self.init_projects:
1318
- if self.is_kind_supported("Project"):
1319
- kind = "Project.project.openshift.io"
1320
- else:
1321
- kind = "Namespace"
1332
+ kind = PROJECT_KIND if self.is_kind_supported(PROJECT_KIND) else "Namespace"
1322
1333
  self.projects = {p["metadata"]["name"] for p in self.get_all(kind)["items"]}
1323
1334
 
1324
1335
  def __enter__(self) -> OCNative:
@@ -1368,8 +1379,10 @@ class OCNative(OCCli):
1368
1379
 
1369
1380
  @retry(max_attempts=5, exceptions=(ServerTimeoutError))
1370
1381
  def get_items(self, kind: str, **kwargs: Any) -> list[dict[str, Any]]:
1371
- k, group_version = self._parse_kind(kind)
1372
- obj_client = self._get_obj_client(group_version=group_version, kind=k)
1382
+ resource = self.get_api_resource(kind)
1383
+ obj_client = self._get_obj_client(
1384
+ group_version=resource.group_version, kind=resource.kind
1385
+ )
1373
1386
 
1374
1387
  namespace = ""
1375
1388
  if "namespace" in kwargs:
@@ -1421,8 +1434,10 @@ class OCNative(OCCli):
1421
1434
  name: str | None = None,
1422
1435
  allow_not_found: bool = False,
1423
1436
  ) -> dict[str, Any]:
1424
- k, group_version = self._parse_kind(kind)
1425
- obj_client = self._get_obj_client(group_version=group_version, kind=k)
1437
+ resource = self.get_api_resource(kind)
1438
+ obj_client = self._get_obj_client(
1439
+ group_version=resource.group_version, kind=resource.kind
1440
+ )
1426
1441
  try:
1427
1442
  obj = obj_client.get(
1428
1443
  name=name,
@@ -1436,8 +1451,10 @@ class OCNative(OCCli):
1436
1451
  raise StatusCodeError(f"[{self.server}]: {e}") from None
1437
1452
 
1438
1453
  def get_all(self, kind: str, all_namespaces: bool = False) -> dict[str, Any]:
1439
- k, group_version = self._parse_kind(kind)
1440
- obj_client = self._get_obj_client(group_version=group_version, kind=k)
1454
+ resource = self.get_api_resource(kind)
1455
+ obj_client = self._get_obj_client(
1456
+ group_version=resource.group_version, kind=resource.kind
1457
+ )
1441
1458
  try:
1442
1459
  return obj_client.get(_request_timeout=REQUEST_TIMEOUT).to_dict()
1443
1460
  except NotFoundError as e:
@@ -178,7 +178,7 @@ class OCMProductOsd(OCMProduct):
178
178
  ],
179
179
  provision_shard_id=provision_shard_id,
180
180
  hypershift=cluster["hypershift"]["enabled"],
181
- fips=cluster.get("fips"),
181
+ fips=cluster.get("fips") or False,
182
182
  )
183
183
 
184
184
  if not cluster["ccs"]["enabled"]:
@@ -259,7 +259,7 @@ class OCMProductOsd(OCMProduct):
259
259
  if (duwm := cluster.spec.disable_user_workload_monitoring) is not None
260
260
  else True
261
261
  ),
262
- "fips": bool(cluster.spec.fips),
262
+ "fips": cluster.spec.fips,
263
263
  }
264
264
 
265
265
  # Workaround to enable type checks.
@@ -429,7 +429,7 @@ class OCMProductRosa(OCMProduct):
429
429
  subnet_ids=cluster["aws"].get("subnet_ids"),
430
430
  availability_zones=cluster["nodes"].get("availability_zones"),
431
431
  oidc_endpoint_url=oidc_endpoint_url,
432
- fips=cluster.get("fips"),
432
+ fips=cluster.get("fips") or False,
433
433
  )
434
434
 
435
435
  machine_pools = [
@@ -517,7 +517,7 @@ class OCMProductRosa(OCMProduct):
517
517
  if (duwm := cluster.spec.disable_user_workload_monitoring) is not None
518
518
  else True
519
519
  ),
520
- "fips": bool(cluster.spec.fips),
520
+ "fips": cluster.spec.fips,
521
521
  }
522
522
 
523
523
  provision_shard_id = cluster.spec.provision_shard_id
@@ -706,7 +706,7 @@ class OCMProductHypershift(OCMProduct):
706
706
  availability_zones=cluster["nodes"].get("availability_zones"),
707
707
  hypershift=cluster["hypershift"]["enabled"],
708
708
  oidc_endpoint_url=oidc_endpoint_url,
709
- fips=cluster.get("fips"),
709
+ fips=cluster.get("fips") or False,
710
710
  )
711
711
 
712
712
  network = OCMClusterNetwork(
@@ -5,6 +5,7 @@ import base64
5
5
  import contextlib
6
6
  import copy
7
7
  import hashlib
8
+ import logging
8
9
  import re
9
10
  from threading import Lock
10
11
  from typing import TYPE_CHECKING, Any
@@ -601,6 +602,10 @@ class ResourceInventory:
601
602
  resource: OpenshiftResource,
602
603
  privileged: bool = False,
603
604
  ) -> None:
605
+ if cluster not in self._clusters:
606
+ logging.error(f"Cluster {cluster} not initialized in ResourceInventory")
607
+ return
608
+
604
609
  if resource.kind_and_group in self._clusters[cluster][namespace]:
605
610
  kind = resource.kind_and_group
606
611
  else:
@@ -2210,6 +2210,43 @@ class TerrascriptClient:
2210
2210
  letters_and_digits = string.ascii_letters + string.digits
2211
2211
  return "".join(random.choice(letters_and_digits) for i in range(string_length))
2212
2212
 
2213
+ @staticmethod
2214
+ def _build_tf_resource_s3_lifecycle_rules(
2215
+ versioning: bool,
2216
+ common_values: Mapping[str, Any],
2217
+ ) -> list[dict]:
2218
+ lifecycle_rules = common_values.get("lifecycle_rules") or []
2219
+ if versioning and not any(
2220
+ "noncurrent_version_expiration" in lr for lr in lifecycle_rules
2221
+ ):
2222
+ # Add a default noncurrent object expiration rule
2223
+ # if one isn't already set
2224
+ rule = {
2225
+ "id": "expire_noncurrent_versions",
2226
+ "enabled": True,
2227
+ "noncurrent_version_expiration": {"days": 30},
2228
+ "expiration": {"expired_object_delete_marker": True},
2229
+ "abort_incomplete_multipart_upload_days": 3,
2230
+ }
2231
+ lifecycle_rules.append(rule)
2232
+
2233
+ if storage_class := common_values.get("storage_class"):
2234
+ sc = storage_class.upper()
2235
+ days = "1"
2236
+ if sc.endswith("_IA"):
2237
+ # Infrequent Access storage class has minimum 30 days
2238
+ # before transition
2239
+ days = "30"
2240
+ rule = {
2241
+ "id": sc + "_storage_class",
2242
+ "enabled": True,
2243
+ "transition": {"days": days, "storage_class": sc},
2244
+ "noncurrent_version_transition": {"days": days, "storage_class": sc},
2245
+ }
2246
+ lifecycle_rules.append(rule)
2247
+
2248
+ return lifecycle_rules
2249
+
2213
2250
  def populate_tf_resource_s3(self, spec: ExternalResourceSpec) -> aws_s3_bucket:
2214
2251
  account = spec.provisioner_name
2215
2252
  identifier = spec.identifier
@@ -2249,47 +2286,11 @@ class TerrascriptClient:
2249
2286
  request_payer = common_values.get("request_payer")
2250
2287
  if request_payer:
2251
2288
  values["request_payer"] = request_payer
2252
- lifecycle_rules = common_values.get("lifecycle_rules")
2253
- if lifecycle_rules:
2254
- # common_values['lifecycle_rules'] is a list of lifecycle_rules
2289
+ if lifecycle_rules := self._build_tf_resource_s3_lifecycle_rules(
2290
+ versioning=versioning,
2291
+ common_values=common_values,
2292
+ ):
2255
2293
  values["lifecycle_rule"] = lifecycle_rules
2256
- if versioning:
2257
- lrs = values.get("lifecycle_rule", [])
2258
- expiration_rule = False
2259
- for lr in lrs:
2260
- if "noncurrent_version_expiration" in lr:
2261
- expiration_rule = True
2262
- break
2263
- if not expiration_rule:
2264
- # Add a default noncurrent object expiration rule if
2265
- # if one isn't already set
2266
- rule = {
2267
- "id": "expire_noncurrent_versions",
2268
- "enabled": "true",
2269
- "noncurrent_version_expiration": {"days": 30},
2270
- }
2271
- if len(lrs) > 0:
2272
- lrs.append(rule)
2273
- else:
2274
- lrs = rule
2275
- sc = common_values.get("storage_class")
2276
- if sc:
2277
- sc = sc.upper()
2278
- days = "1"
2279
- if sc.endswith("_IA"):
2280
- # Infrequent Access storage class has minimum 30 days
2281
- # before transition
2282
- days = "30"
2283
- rule = {
2284
- "id": sc + "_storage_class",
2285
- "enabled": "true",
2286
- "transition": {"days": days, "storage_class": sc},
2287
- "noncurrent_version_transition": {"days": days, "storage_class": sc},
2288
- }
2289
- if values.get("lifecycle_rule"):
2290
- values["lifecycle_rule"].append(rule)
2291
- else:
2292
- values["lifecycle_rule"] = rule
2293
2294
  cors_rules = common_values.get("cors_rules")
2294
2295
  if cors_rules:
2295
2296
  # common_values['cors_rules'] is a list of cors_rules