qontract-reconcile 0.10.2.dev414__py3-none-any.whl → 0.10.2.dev456__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.

Potentially problematic release.


This version of qontract-reconcile might be problematic. Click here for more details.

Files changed (55) hide show
  1. {qontract_reconcile-0.10.2.dev414.dist-info → qontract_reconcile-0.10.2.dev456.dist-info}/METADATA +2 -2
  2. {qontract_reconcile-0.10.2.dev414.dist-info → qontract_reconcile-0.10.2.dev456.dist-info}/RECORD +55 -53
  3. {qontract_reconcile-0.10.2.dev414.dist-info → qontract_reconcile-0.10.2.dev456.dist-info}/WHEEL +1 -1
  4. reconcile/aus/advanced_upgrade_service.py +3 -0
  5. reconcile/aus/aus_sts_gate_handler.py +59 -0
  6. reconcile/aus/base.py +115 -8
  7. reconcile/aus/models.py +2 -0
  8. reconcile/aus/ocm_addons_upgrade_scheduler_org.py +1 -0
  9. reconcile/aus/ocm_upgrade_scheduler.py +8 -1
  10. reconcile/aus/ocm_upgrade_scheduler_org.py +20 -5
  11. reconcile/aus/version_gate_approver.py +1 -16
  12. reconcile/aus/version_gates/sts_version_gate_handler.py +5 -72
  13. reconcile/automated_actions/config/integration.py +1 -1
  14. reconcile/aws_ecr_image_pull_secrets.py +1 -1
  15. reconcile/change_owners/change_owners.py +100 -34
  16. reconcile/cli.py +63 -5
  17. reconcile/external_resources/manager.py +7 -18
  18. reconcile/external_resources/model.py +8 -8
  19. reconcile/external_resources/secrets_sync.py +2 -3
  20. reconcile/external_resources/state.py +1 -34
  21. reconcile/gql_definitions/common/aws_vpc_requests.py +3 -0
  22. reconcile/gql_definitions/common/clusters.py +2 -0
  23. reconcile/gql_definitions/external_resources/external_resources_namespaces.py +3 -1
  24. reconcile/gql_definitions/fragments/aws_vpc_request.py +5 -0
  25. reconcile/gql_definitions/introspection.json +48 -0
  26. reconcile/gql_definitions/rhcs/certs.py +20 -74
  27. reconcile/gql_definitions/rhcs/openshift_resource_rhcs_cert.py +43 -0
  28. reconcile/gql_definitions/terraform_resources/terraform_resources_namespaces.py +5 -1
  29. reconcile/gql_definitions/vpc_peerings_validator/vpc_peerings_validator.py +3 -0
  30. reconcile/gql_definitions/vpc_peerings_validator/vpc_peerings_validator_peered_cluster_fragment.py +1 -0
  31. reconcile/ocm_machine_pools.py +12 -6
  32. reconcile/openshift_base.py +60 -2
  33. reconcile/openshift_namespaces.py +3 -4
  34. reconcile/openshift_rhcs_certs.py +71 -34
  35. reconcile/rhidp/sso_client/base.py +15 -4
  36. reconcile/templates/rosa-classic-cluster-creation.sh.j2 +1 -1
  37. reconcile/templates/rosa-hcp-cluster-creation.sh.j2 +1 -1
  38. reconcile/terraform_vpc_resources/integration.py +10 -7
  39. reconcile/typed_queries/saas_files.py +9 -4
  40. reconcile/utils/binary.py +7 -12
  41. reconcile/utils/environ.py +5 -0
  42. reconcile/utils/gitlab_api.py +12 -0
  43. reconcile/utils/glitchtip/client.py +2 -2
  44. reconcile/utils/jjb_client.py +19 -3
  45. reconcile/utils/jobcontroller/controller.py +1 -1
  46. reconcile/utils/json.py +5 -1
  47. reconcile/utils/oc.py +144 -113
  48. reconcile/utils/rhcsv2_certs.py +87 -21
  49. reconcile/utils/rosa/session.py +16 -0
  50. reconcile/utils/saasherder/saasherder.py +20 -7
  51. reconcile/utils/terrascript_aws_client.py +140 -50
  52. reconcile/utils/vault.py +1 -1
  53. reconcile/vpc_peerings_validator.py +13 -0
  54. tools/cli_commands/erv2.py +1 -3
  55. {qontract_reconcile-0.10.2.dev414.dist-info → qontract_reconcile-0.10.2.dev456.dist-info}/entry_points.txt +0 -0
@@ -33,6 +33,10 @@ from reconcile.utils.vcs import GITHUB_BASE_URL
33
33
  JJB_INI = "[jenkins]\nurl = https://JENKINS_URL"
34
34
 
35
35
 
36
+ class MissingJobUrlError(Exception):
37
+ pass
38
+
39
+
36
40
  class JJB:
37
41
  """Wrapper around Jenkins Jobs"""
38
42
 
@@ -335,7 +339,7 @@ class JJB:
335
339
  job_name = job["name"]
336
340
  try:
337
341
  repos.add(self.get_repo_url(job))
338
- except KeyError:
342
+ except MissingJobUrlError:
339
343
  logging.debug(f"missing github url: {job_name}")
340
344
  return repos
341
345
 
@@ -355,7 +359,19 @@ class JJB:
355
359
 
356
360
  @staticmethod
357
361
  def get_repo_url(job: Mapping[str, Any]) -> str:
358
- repo_url_raw = job["properties"][0]["github"]["url"]
362
+ repo_url_raw = job.get("properties", [{}])[0].get("github", {}).get("url")
363
+
364
+ # we may be in a Github Branch Source type of job
365
+ if not repo_url_raw:
366
+ gh_org = job.get("scm", [{}])[0].get("github", {}).get("repo-owner")
367
+ gh_repo = job.get("scm", [{}])[0].get("github", {}).get("repo")
368
+ if gh_org and gh_repo:
369
+ repo_url_raw = f"https://github.com/{gh_org}/{gh_repo}/"
370
+ else:
371
+ raise MissingJobUrlError(
372
+ f"Cannot find job url for {job['display-name']}"
373
+ )
374
+
359
375
  return repo_url_raw.strip("/").replace(".git", "")
360
376
 
361
377
  @staticmethod
@@ -404,7 +420,7 @@ class JJB:
404
420
  try:
405
421
  if self.get_repo_url(job).lower() == repo_url.rstrip("/").lower():
406
422
  return job
407
- except KeyError:
423
+ except MissingJobUrlError:
408
424
  # something wrong here. ignore this job
409
425
  pass
410
426
  raise ValueError(f"job with {job_type=} and {repo_url=} not found")
@@ -3,7 +3,7 @@ import time
3
3
  from datetime import datetime
4
4
  from typing import Protocol, TextIO
5
5
 
6
- from kubernetes.client import ( # type: ignore[attr-defined]
6
+ from kubernetes.client import (
7
7
  ApiClient,
8
8
  V1Job,
9
9
  V1ObjectMeta,
reconcile/utils/json.py CHANGED
@@ -7,6 +7,7 @@ from enum import Enum
7
7
  from typing import Any, Literal
8
8
 
9
9
  from pydantic import BaseModel
10
+ from pydantic.main import IncEx
10
11
 
11
12
  JSON_COMPACT_SEPARATORS = (",", ":")
12
13
 
@@ -42,6 +43,7 @@ def json_dumps(
42
43
  # BaseModel dump parameters
43
44
  by_alias: bool = True,
44
45
  exclude_none: bool = False,
46
+ exclude: IncEx | None = None,
45
47
  mode: Literal["json", "python"] = "json",
46
48
  ) -> str:
47
49
  """
@@ -56,7 +58,9 @@ def json_dumps(
56
58
  A JSON formatted string.
57
59
  """
58
60
  if isinstance(data, BaseModel):
59
- data = data.model_dump(mode=mode, by_alias=by_alias, exclude_none=exclude_none)
61
+ data = data.model_dump(
62
+ mode=mode, by_alias=by_alias, exclude_none=exclude_none, exclude=exclude
63
+ )
60
64
  if mode == "python":
61
65
  defaults = pydantic_encoder
62
66
  separators = JSON_COMPACT_SEPARATORS if compact else None
reconcile/utils/oc.py CHANGED
@@ -10,6 +10,7 @@ import re
10
10
  import subprocess
11
11
  import threading
12
12
  import time
13
+ from collections import defaultdict
13
14
  from contextlib import suppress
14
15
  from dataclasses import dataclass
15
16
  from functools import cache, wraps
@@ -18,7 +19,7 @@ from threading import Lock
18
19
  from typing import TYPE_CHECKING, Any, TextIO, cast
19
20
 
20
21
  import urllib3
21
- from kubernetes.client import ( # type: ignore[attr-defined]
22
+ from kubernetes.client import (
22
23
  ApiClient,
23
24
  Configuration,
24
25
  )
@@ -46,7 +47,6 @@ from sretoolbox.utils import (
46
47
  )
47
48
 
48
49
  from reconcile.status import RunningState
49
- from reconcile.utils.datetime_util import utc_now
50
50
  from reconcile.utils.json import json_dumps
51
51
  from reconcile.utils.jump_host import (
52
52
  JumphostParameters,
@@ -70,6 +70,16 @@ urllib3.disable_warnings()
70
70
  GET_REPLICASET_MAX_ATTEMPTS = 20
71
71
  DEFAULT_GROUP = ""
72
72
  PROJECT_KIND = "Project.project.openshift.io"
73
+ POD_RECYCLE_SUPPORTED_TRIGGER_KINDS = [
74
+ "ConfigMap",
75
+ "Secret",
76
+ ]
77
+ POD_RECYCLE_SUPPORTED_OWNER_KINDS = [
78
+ "DaemonSet",
79
+ "Deployment",
80
+ "DeploymentConfig",
81
+ "StatefulSet",
82
+ ]
73
83
 
74
84
  oc_run_execution_counter = Counter(
75
85
  name="oc_run_execution_counter",
@@ -126,14 +136,6 @@ class JSONParsingError(Exception):
126
136
  pass
127
137
 
128
138
 
129
- class RecyclePodsUnsupportedKindError(Exception):
130
- pass
131
-
132
-
133
- class RecyclePodsInvalidAnnotationValueError(Exception):
134
- pass
135
-
136
-
137
139
  class PodNotReadyError(Exception):
138
140
  pass
139
141
 
@@ -649,9 +651,15 @@ class OCCli:
649
651
  raise e
650
652
  return True
651
653
 
654
+ def _use_oc_project(self, namespace: str) -> bool:
655
+ # Note, that openshift-* namespaces cannot be created via new-project
656
+ return self.is_kind_supported(PROJECT_KIND) and not namespace.startswith(
657
+ "openshift-"
658
+ )
659
+
652
660
  @OCDecorators.process_reconcile_time
653
661
  def new_project(self, namespace: str) -> OCProcessReconcileTimeDecoratorMsg:
654
- if self.is_kind_supported(PROJECT_KIND):
662
+ if self._use_oc_project(namespace=namespace):
655
663
  cmd = ["new-project", namespace]
656
664
  else:
657
665
  cmd = ["create", "namespace", namespace]
@@ -667,7 +675,7 @@ class OCCli:
667
675
 
668
676
  @OCDecorators.process_reconcile_time
669
677
  def delete_project(self, namespace: str) -> OCProcessReconcileTimeDecoratorMsg:
670
- if self.is_kind_supported(PROJECT_KIND):
678
+ if self._use_oc_project(namespace=namespace):
671
679
  cmd = ["delete", "project", namespace]
672
680
  else:
673
681
  cmd = ["delete", "namespace", namespace]
@@ -922,108 +930,105 @@ class OCCli:
922
930
  if not status["ready"]:
923
931
  raise PodNotReadyError(name)
924
932
 
925
- def recycle_pods(
926
- self, dry_run: bool, namespace: str, dep_kind: str, dep_resource: OR
927
- ) -> None:
928
- """recycles pods which are using the specified resources.
929
- will only act on Secrets containing the 'qontract.recycle' annotation.
930
- dry_run: simulate pods recycle.
931
- namespace: namespace in which dependant resource is applied.
932
- dep_kind: dependant resource kind. currently only supports Secret.
933
- dep_resource: dependant resource."""
934
-
935
- supported_kinds = ["Secret", "ConfigMap"]
936
- if dep_kind not in supported_kinds:
933
+ def _is_resource_supported_to_trigger_recycle(
934
+ self,
935
+ namespace: str,
936
+ resource: OR,
937
+ ) -> bool:
938
+ if resource.kind not in POD_RECYCLE_SUPPORTED_TRIGGER_KINDS:
937
939
  logging.debug([
938
940
  "skipping_pod_recycle_unsupported",
939
941
  self.cluster_name,
940
942
  namespace,
941
- dep_kind,
943
+ resource.kind,
944
+ resource.name,
942
945
  ])
943
- return
946
+ return False
944
947
 
945
- dep_annotations = dep_resource.body["metadata"].get("annotations", {})
946
948
  # Note, that annotations might have been set to None explicitly
947
- dep_annotations = dep_resource.body["metadata"].get("annotations") or {}
948
- qontract_recycle = dep_annotations.get("qontract.recycle")
949
- if qontract_recycle is True:
950
- raise RecyclePodsInvalidAnnotationValueError('should be "true"')
949
+ annotations = resource.body["metadata"].get("annotations") or {}
950
+ qontract_recycle = annotations.get("qontract.recycle")
951
951
  if qontract_recycle != "true":
952
952
  logging.debug([
953
953
  "skipping_pod_recycle_no_annotation",
954
954
  self.cluster_name,
955
955
  namespace,
956
- dep_kind,
956
+ resource.kind,
957
+ resource.name,
957
958
  ])
959
+ return False
960
+ return True
961
+
962
+ def recycle_pods(
963
+ self,
964
+ dry_run: bool,
965
+ namespace: str,
966
+ resource: OR,
967
+ ) -> None:
968
+ """
969
+ recycles pods which are using the specified resources.
970
+ will only act on Secret or ConfigMap containing the 'qontract.recycle' annotation.
971
+
972
+ Args:
973
+ dry_run (bool): if True, will only log the recycle action without executing it
974
+ namespace (str): namespace of the resource
975
+ resource (OR): resource object (Secret or ConfigMap) to check for pod usage
976
+ """
977
+
978
+ if not self._is_resource_supported_to_trigger_recycle(namespace, resource):
958
979
  return
959
980
 
960
- dep_name = dep_resource.name
961
981
  pods = self.get(namespace, "Pod")["items"]
962
-
963
- if dep_kind == "Secret":
964
- pods_to_recycle = [
965
- pod for pod in pods if self.secret_used_in_pod(dep_name, pod)
966
- ]
967
- elif dep_kind == "ConfigMap":
968
- pods_to_recycle = [
969
- pod for pod in pods if self.configmap_used_in_pod(dep_name, pod)
970
- ]
971
- else:
972
- raise RecyclePodsUnsupportedKindError(dep_kind)
973
-
974
- recyclables: dict[str, list[dict[str, Any]]] = {}
975
- supported_recyclables = [
976
- "Deployment",
977
- "DeploymentConfig",
978
- "StatefulSet",
979
- "DaemonSet",
982
+ pods_to_recycle = [
983
+ pod
984
+ for pod in pods
985
+ if self.is_resource_used_in_pod(
986
+ name=resource.name,
987
+ kind=resource.kind,
988
+ pod=pod,
989
+ )
980
990
  ]
991
+
992
+ recycle_names_by_kind = defaultdict(set)
981
993
  for pod in pods_to_recycle:
982
994
  owner = self.get_obj_root_owner(namespace, pod, allow_not_found=True)
983
995
  kind = owner["kind"]
984
- if kind not in supported_recyclables:
985
- continue
986
- recyclables.setdefault(kind, [])
987
- exists = False
988
- for obj in recyclables[kind]:
989
- owner_name = owner["metadata"]["name"]
990
- if obj["metadata"]["name"] == owner_name:
991
- exists = True
992
- break
993
- if not exists:
994
- recyclables[kind].append(owner)
995
-
996
- for kind, objs in recyclables.items():
997
- for obj in objs:
998
- self.recycle(dry_run, namespace, kind, obj)
999
-
1000
- @retry(exceptions=ObjectHasBeenModifiedError)
996
+ if kind in POD_RECYCLE_SUPPORTED_OWNER_KINDS:
997
+ recycle_names_by_kind[kind].add(owner["metadata"]["name"])
998
+
999
+ for kind, names in recycle_names_by_kind.items():
1000
+ for name in names:
1001
+ self.recycle(
1002
+ dry_run=dry_run,
1003
+ namespace=namespace,
1004
+ kind=kind,
1005
+ name=name,
1006
+ )
1007
+
1001
1008
  def recycle(
1002
- self, dry_run: bool, namespace: str, kind: str, obj: MutableMapping[str, Any]
1009
+ self,
1010
+ dry_run: bool,
1011
+ namespace: str,
1012
+ kind: str,
1013
+ name: str,
1003
1014
  ) -> None:
1004
- """Recycles an object by adding a recycle.time annotation
1015
+ """
1016
+ Recycles an object using oc rollout restart, which will add an annotation
1017
+ kubectl.kubernetes.io/restartedAt with the current timestamp to the pod
1018
+ template, triggering a rolling restart.
1005
1019
 
1006
- :param dry_run: Is this a dry run
1007
- :param namespace: Namespace to work in
1008
- :param kind: Object kind
1009
- :param obj: Object to recycle
1020
+ Args:
1021
+ dry_run (bool): if True, will only log the recycle action without executing it
1022
+ namespace (str): namespace of the object to recycle
1023
+ kind (str): kind of the object to recycle
1024
+ name (str): name of the object to recycle
1010
1025
  """
1011
- name = obj["metadata"]["name"]
1012
1026
  logging.info([f"recycle_{kind.lower()}", self.cluster_name, namespace, name])
1013
1027
  if not dry_run:
1014
- now = utc_now()
1015
- recycle_time = now.strftime("%d/%m/%Y %H:%M:%S")
1016
-
1017
- # get the object in case it was modified
1018
- obj = self.get(namespace, kind, name)
1019
- # honor update strategy by setting annotations to force
1020
- # a new rollout
1021
- a = obj["spec"]["template"]["metadata"].get("annotations", {})
1022
- a["recycle.time"] = recycle_time
1023
- obj["spec"]["template"]["metadata"]["annotations"] = a
1024
- cmd = ["apply", "-n", namespace, "-f", "-"]
1025
- stdin = json_dumps(obj)
1026
- self._run(cmd, stdin=stdin, apply=True)
1028
+ self._run(
1029
+ ["rollout", "restart", f"{kind}/{name}", "-n", namespace],
1030
+ apply=True,
1031
+ )
1027
1032
 
1028
1033
  def get_obj_root_owner(
1029
1034
  self,
@@ -1065,12 +1070,24 @@ class OCCli:
1065
1070
  )
1066
1071
  return obj
1067
1072
 
1068
- def secret_used_in_pod(self, name: str, pod: Mapping[str, Any]) -> bool:
1069
- used_resources = self.get_resources_used_in_pod_spec(pod["spec"], "Secret")
1070
- return name in used_resources
1073
+ def is_resource_used_in_pod(
1074
+ self,
1075
+ name: str,
1076
+ kind: str,
1077
+ pod: Mapping[str, Any],
1078
+ ) -> bool:
1079
+ """
1080
+ Check if a resource (Secret or ConfigMap) is used in a Pod.
1081
+
1082
+ Args:
1083
+ name: Name of the resource
1084
+ kind: "Secret" or "ConfigMap"
1085
+ pod: Pod object
1071
1086
 
1072
- def configmap_used_in_pod(self, name: str, pod: Mapping[str, Any]) -> bool:
1073
- used_resources = self.get_resources_used_in_pod_spec(pod["spec"], "ConfigMap")
1087
+ Returns:
1088
+ True if the resource is used in the Pod, False otherwise.
1089
+ """
1090
+ used_resources = self.get_resources_used_in_pod_spec(pod["spec"], kind)
1074
1091
  return name in used_resources
1075
1092
 
1076
1093
  @staticmethod
@@ -1079,25 +1096,39 @@ class OCCli:
1079
1096
  kind: str,
1080
1097
  include_optional: bool = True,
1081
1098
  ) -> dict[str, set[str]]:
1082
- if kind not in {"Secret", "ConfigMap"}:
1083
- raise KeyError(f"unsupported resource kind: {kind}")
1099
+ """
1100
+ Get resources (Secrets or ConfigMaps) used in a Pod spec.
1101
+ Returns a dictionary where keys are resource names and values are sets of keys used from that resource.
1102
+
1103
+ Args:
1104
+ spec: Pod spec
1105
+ kind: "Secret" or "ConfigMap"
1106
+ include_optional: Whether to include optional resources
1107
+
1108
+ Returns:
1109
+ A dictionary mapping resource names to sets of keys used.
1110
+ """
1111
+ match kind:
1112
+ case "Secret":
1113
+ volume_kind, volume_kind_ref, env_from_kind, env_kind, env_ref = (
1114
+ "secret",
1115
+ "secretName",
1116
+ "secretRef",
1117
+ "secretKeyRef",
1118
+ "name",
1119
+ )
1120
+ case "ConfigMap":
1121
+ volume_kind, volume_kind_ref, env_from_kind, env_kind, env_ref = (
1122
+ "configMap",
1123
+ "name",
1124
+ "configMapRef",
1125
+ "configMapKeyRef",
1126
+ "name",
1127
+ )
1128
+ case _:
1129
+ raise KeyError(f"unsupported resource kind: {kind}")
1130
+
1084
1131
  optional = "optional"
1085
- if kind == "Secret":
1086
- volume_kind, volume_kind_ref, env_from_kind, env_kind, env_ref = (
1087
- "secret",
1088
- "secretName",
1089
- "secretRef",
1090
- "secretKeyRef",
1091
- "name",
1092
- )
1093
- elif kind == "ConfigMap":
1094
- volume_kind, volume_kind_ref, env_from_kind, env_kind, env_ref = (
1095
- "configMap",
1096
- "name",
1097
- "configMapRef",
1098
- "configMapKeyRef",
1099
- "name",
1100
- )
1101
1132
 
1102
1133
  resources: dict[str, set[str]] = {}
1103
1134
  for v in spec.get("volumes") or []:
@@ -1126,8 +1157,8 @@ class OCCli:
1126
1157
  continue
1127
1158
  resource_name = resource_ref[env_ref]
1128
1159
  resources.setdefault(resource_name, set())
1129
- secret_key = resource_ref["key"]
1130
- resources[resource_name].add(secret_key)
1160
+ key = resource_ref["key"]
1161
+ resources[resource_name].add(key)
1131
1162
  except (KeyError, TypeError):
1132
1163
  continue
1133
1164
 
@@ -1,21 +1,35 @@
1
+ import base64
1
2
  import re
2
3
  from datetime import UTC
4
+ from enum import StrEnum
3
5
 
4
6
  import requests
5
7
  from cryptography import x509
6
8
  from cryptography.hazmat.primitives import hashes, serialization
7
9
  from cryptography.hazmat.primitives.asymmetric import rsa
10
+ from cryptography.hazmat.primitives.serialization import pkcs12
8
11
  from cryptography.x509.oid import NameOID
9
12
  from pydantic import BaseModel, Field
10
13
 
11
14
 
12
- class RhcsV2Cert(BaseModel, validate_by_name=True, validate_by_alias=True):
15
+ class CertificateFormat(StrEnum):
16
+ PEM = "PEM"
17
+ PKCS12 = "PKCS12"
18
+
19
+
20
+ class RhcsV2CertPem(BaseModel, validate_by_name=True, validate_by_alias=True):
13
21
  certificate: str = Field(alias="tls.crt")
14
22
  private_key: str = Field(alias="tls.key")
15
23
  ca_cert: str = Field(alias="ca.crt")
16
24
  expiration_timestamp: int
17
25
 
18
26
 
27
+ class RhcsV2CertPkcs12(BaseModel, validate_by_name=True, validate_by_alias=True):
28
+ pkcs12_keystore: str = Field(alias="keystore.pkcs12.b64")
29
+ pkcs12_truststore: str = Field(alias="truststore.pkcs12.b64")
30
+ expiration_timestamp: int
31
+
32
+
19
33
  def extract_cert(text: str) -> re.Match:
20
34
  # The CA webform returns an HTML page with inline JS that builds an array of “outputList”
21
35
  # objects. Each object looks roughly like:
@@ -67,7 +81,66 @@ def get_cert_expiry_timestamp(js_escaped_pem: str) -> int:
67
81
  return int(dt_expiry.timestamp())
68
82
 
69
83
 
70
- def generate_cert(issuer_url: str, uid: str, pwd: str, ca_url: str) -> RhcsV2Cert:
84
+ def _format_pem(
85
+ private_key: rsa.RSAPrivateKey,
86
+ cert_pem: str,
87
+ ca_pem: str,
88
+ cert_expiry_timestamp: int,
89
+ ) -> RhcsV2CertPem:
90
+ """Generate RhcsV2Cert with PEM components."""
91
+ private_key_pem = private_key.private_bytes(
92
+ encoding=serialization.Encoding.PEM,
93
+ format=serialization.PrivateFormat.TraditionalOpenSSL,
94
+ encryption_algorithm=serialization.NoEncryption(),
95
+ ).decode()
96
+ return RhcsV2CertPem(
97
+ private_key=private_key_pem,
98
+ certificate=cert_pem.encode().decode("unicode_escape").replace("\\/", "/"),
99
+ ca_cert=ca_pem,
100
+ expiration_timestamp=cert_expiry_timestamp,
101
+ )
102
+
103
+
104
+ def _format_pkcs12(
105
+ private_key: rsa.RSAPrivateKey,
106
+ cert_pem: str,
107
+ ca_pem: str,
108
+ uid: str,
109
+ pwd: str,
110
+ cert_expiry_timestamp: int,
111
+ ) -> RhcsV2CertPkcs12:
112
+ """Generate PKCS#12 keystore and truststore components, returns base64-encoded strings."""
113
+ clean_cert_pem = cert_pem.encode().decode("unicode_escape").replace("\\/", "/")
114
+ cert_obj = x509.load_pem_x509_certificate(clean_cert_pem.encode())
115
+ ca_obj = x509.load_pem_x509_certificate(ca_pem.encode())
116
+ keystore_p12 = pkcs12.serialize_key_and_certificates(
117
+ name=uid.encode("utf-8"),
118
+ key=private_key,
119
+ cert=cert_obj,
120
+ cas=[ca_obj],
121
+ encryption_algorithm=serialization.BestAvailableEncryption(pwd.encode("utf-8")),
122
+ )
123
+ truststore_p12 = pkcs12.serialize_key_and_certificates(
124
+ name=b"ca-trust",
125
+ key=None,
126
+ cert=None,
127
+ cas=[ca_obj],
128
+ encryption_algorithm=serialization.NoEncryption(),
129
+ )
130
+ return RhcsV2CertPkcs12(
131
+ pkcs12_keystore=base64.b64encode(keystore_p12).decode("utf-8"),
132
+ pkcs12_truststore=base64.b64encode(truststore_p12).decode("utf-8"),
133
+ expiration_timestamp=cert_expiry_timestamp,
134
+ )
135
+
136
+
137
+ def generate_cert(
138
+ issuer_url: str,
139
+ uid: str,
140
+ pwd: str,
141
+ ca_url: str,
142
+ cert_format: CertificateFormat = CertificateFormat.PEM,
143
+ ) -> RhcsV2CertPem | RhcsV2CertPkcs12:
71
144
  private_key = rsa.generate_private_key(65537, 4096)
72
145
  csr = (
73
146
  x509.CertificateSigningRequestBuilder()
@@ -78,6 +151,7 @@ def generate_cert(issuer_url: str, uid: str, pwd: str, ca_url: str) -> RhcsV2Cer
78
151
  )
79
152
  .sign(private_key, hashes.SHA256())
80
153
  )
154
+
81
155
  data = {
82
156
  "uid": uid,
83
157
  "pwd": pwd,
@@ -87,27 +161,19 @@ def generate_cert(issuer_url: str, uid: str, pwd: str, ca_url: str) -> RhcsV2Cer
87
161
  "renewal": "false",
88
162
  "xmlOutput": "false",
89
163
  }
90
- response = requests.post(issuer_url, data=data)
164
+ response = requests.post(issuer_url, data=data, timeout=30)
91
165
  response.raise_for_status()
166
+ cert_pem = extract_cert(response.text).group(1)
167
+ cert_expiry_timestamp = get_cert_expiry_timestamp(cert_pem)
92
168
 
93
- cert_pem = extract_cert(response.text)
94
- cert_expiry_timestamp = get_cert_expiry_timestamp(cert_pem.group(1))
95
- private_key_pem = private_key.private_bytes(
96
- encoding=serialization.Encoding.PEM,
97
- format=serialization.PrivateFormat.TraditionalOpenSSL,
98
- encryption_algorithm=serialization.NoEncryption(),
99
- ).decode()
100
-
101
- response = requests.get(ca_url)
169
+ response = requests.get(ca_url, timeout=30)
102
170
  response.raise_for_status()
103
171
  ca_pem = response.text
104
172
 
105
- return RhcsV2Cert(
106
- private_key=private_key_pem,
107
- certificate=cert_pem.group(1)
108
- .encode()
109
- .decode("unicode_escape")
110
- .replace("\\/", "/"),
111
- ca_cert=ca_pem,
112
- expiration_timestamp=cert_expiry_timestamp,
113
- )
173
+ match cert_format:
174
+ case CertificateFormat.PKCS12:
175
+ return _format_pkcs12(
176
+ private_key, cert_pem, ca_pem, uid, pwd, cert_expiry_timestamp
177
+ )
178
+ case CertificateFormat.PEM:
179
+ return _format_pem(private_key, cert_pem, ca_pem, cert_expiry_timestamp)
@@ -178,6 +178,22 @@ class RosaSession:
178
178
  )
179
179
  result.write_logs_to_logger(logging.info)
180
180
 
181
+ def upgrade_rosa_roles(
182
+ self,
183
+ cluster_name: str,
184
+ upgrade_version: str,
185
+ policy_version: str,
186
+ dry_run: bool,
187
+ ) -> None:
188
+ logging.info(
189
+ f"Upgrade roles in AWS account {self.aws_account_id} to {upgrade_version}"
190
+ )
191
+ if not dry_run:
192
+ result = self.cli_execute(
193
+ f"rosa upgrade roles -c {cluster_name} --cluster-version {upgrade_version} --policy-version {policy_version} -y -m=auto"
194
+ )
195
+ result.write_logs_to_logger(logging.info)
196
+
181
197
 
182
198
  def generate_rosa_creation_script(
183
199
  cluster_name: str, cluster: OCMSpec, dry_run: bool
@@ -92,8 +92,7 @@ from reconcile.utils.state import State
92
92
  from reconcile.utils.vcs import VCS
93
93
 
94
94
  TARGET_CONFIG_HASH = "target_config_hash"
95
-
96
-
95
+ TEMPLATE_API_VERSION = "template.openshift.io/v1"
97
96
  UNIQUE_SAAS_FILE_ENV_COMBO_LEN = 56
98
97
  REQUEST_TIMEOUT = 60
99
98
 
@@ -874,10 +873,23 @@ class SaasHerder:
874
873
  """
875
874
  if parameter_name in consolidated_parameters:
876
875
  return False
877
- for template_parameter in template.get("parameters", {}):
878
- if template_parameter["name"] == parameter_name:
879
- return True
880
- return False
876
+ return any(
877
+ template_parameter["name"] == parameter_name
878
+ for template_parameter in template.get("parameters") or []
879
+ )
880
+
881
+ @staticmethod
882
+ def _pre_process_template(template: dict[str, Any]) -> dict[str, Any]:
883
+ """
884
+ The only supported apiVersion for OpenShift Template is "template.openshift.io/v1".
885
+ There are examples of templates using "v1", it can't pass validation on 4.19+ oc versions.
886
+
887
+ Args:
888
+ template (dict): The OpenShift template dictionary.
889
+ Returns:
890
+ dict: The OpenShift template dictionary with the correct apiVersion.
891
+ """
892
+ return template | {"apiVersion": TEMPLATE_API_VERSION}
881
893
 
882
894
  def _process_template(
883
895
  self, spec: TargetSpec
@@ -967,7 +979,8 @@ class SaasHerder:
967
979
  oc = OCLocal("cluster", None, None, local=True)
968
980
  try:
969
981
  resources: Iterable[Mapping[str, Any]] = oc.process(
970
- template, consolidated_parameters
982
+ template=self._pre_process_template(template),
983
+ parameters=consolidated_parameters,
971
984
  )
972
985
  except StatusCodeError as e:
973
986
  logging.error(f"{error_prefix} error processing template: {e!s}")