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.
- {qontract_reconcile-0.10.2.dev414.dist-info → qontract_reconcile-0.10.2.dev456.dist-info}/METADATA +2 -2
- {qontract_reconcile-0.10.2.dev414.dist-info → qontract_reconcile-0.10.2.dev456.dist-info}/RECORD +55 -53
- {qontract_reconcile-0.10.2.dev414.dist-info → qontract_reconcile-0.10.2.dev456.dist-info}/WHEEL +1 -1
- reconcile/aus/advanced_upgrade_service.py +3 -0
- reconcile/aus/aus_sts_gate_handler.py +59 -0
- reconcile/aus/base.py +115 -8
- reconcile/aus/models.py +2 -0
- reconcile/aus/ocm_addons_upgrade_scheduler_org.py +1 -0
- reconcile/aus/ocm_upgrade_scheduler.py +8 -1
- reconcile/aus/ocm_upgrade_scheduler_org.py +20 -5
- reconcile/aus/version_gate_approver.py +1 -16
- reconcile/aus/version_gates/sts_version_gate_handler.py +5 -72
- reconcile/automated_actions/config/integration.py +1 -1
- reconcile/aws_ecr_image_pull_secrets.py +1 -1
- reconcile/change_owners/change_owners.py +100 -34
- reconcile/cli.py +63 -5
- reconcile/external_resources/manager.py +7 -18
- reconcile/external_resources/model.py +8 -8
- reconcile/external_resources/secrets_sync.py +2 -3
- reconcile/external_resources/state.py +1 -34
- reconcile/gql_definitions/common/aws_vpc_requests.py +3 -0
- reconcile/gql_definitions/common/clusters.py +2 -0
- reconcile/gql_definitions/external_resources/external_resources_namespaces.py +3 -1
- reconcile/gql_definitions/fragments/aws_vpc_request.py +5 -0
- reconcile/gql_definitions/introspection.json +48 -0
- reconcile/gql_definitions/rhcs/certs.py +20 -74
- reconcile/gql_definitions/rhcs/openshift_resource_rhcs_cert.py +43 -0
- reconcile/gql_definitions/terraform_resources/terraform_resources_namespaces.py +5 -1
- reconcile/gql_definitions/vpc_peerings_validator/vpc_peerings_validator.py +3 -0
- reconcile/gql_definitions/vpc_peerings_validator/vpc_peerings_validator_peered_cluster_fragment.py +1 -0
- reconcile/ocm_machine_pools.py +12 -6
- reconcile/openshift_base.py +60 -2
- reconcile/openshift_namespaces.py +3 -4
- reconcile/openshift_rhcs_certs.py +71 -34
- reconcile/rhidp/sso_client/base.py +15 -4
- reconcile/templates/rosa-classic-cluster-creation.sh.j2 +1 -1
- reconcile/templates/rosa-hcp-cluster-creation.sh.j2 +1 -1
- reconcile/terraform_vpc_resources/integration.py +10 -7
- reconcile/typed_queries/saas_files.py +9 -4
- reconcile/utils/binary.py +7 -12
- reconcile/utils/environ.py +5 -0
- reconcile/utils/gitlab_api.py +12 -0
- reconcile/utils/glitchtip/client.py +2 -2
- reconcile/utils/jjb_client.py +19 -3
- reconcile/utils/jobcontroller/controller.py +1 -1
- reconcile/utils/json.py +5 -1
- reconcile/utils/oc.py +144 -113
- reconcile/utils/rhcsv2_certs.py +87 -21
- reconcile/utils/rosa/session.py +16 -0
- reconcile/utils/saasherder/saasherder.py +20 -7
- reconcile/utils/terrascript_aws_client.py +140 -50
- reconcile/utils/vault.py +1 -1
- reconcile/vpc_peerings_validator.py +13 -0
- tools/cli_commands/erv2.py +1 -3
- {qontract_reconcile-0.10.2.dev414.dist-info → qontract_reconcile-0.10.2.dev456.dist-info}/entry_points.txt +0 -0
reconcile/utils/jjb_client.py
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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")
|
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(
|
|
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 (
|
|
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.
|
|
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.
|
|
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
|
|
926
|
-
self,
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
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
|
-
|
|
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
|
-
|
|
948
|
-
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
|
-
|
|
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
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
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
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
for
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
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,
|
|
1009
|
+
self,
|
|
1010
|
+
dry_run: bool,
|
|
1011
|
+
namespace: str,
|
|
1012
|
+
kind: str,
|
|
1013
|
+
name: str,
|
|
1003
1014
|
) -> None:
|
|
1004
|
-
"""
|
|
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
|
-
:
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
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
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
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
|
|
1069
|
-
|
|
1070
|
-
|
|
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
|
-
|
|
1073
|
-
|
|
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
|
-
|
|
1083
|
-
|
|
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
|
-
|
|
1130
|
-
resources[resource_name].add(
|
|
1160
|
+
key = resource_ref["key"]
|
|
1161
|
+
resources[resource_name].add(key)
|
|
1131
1162
|
except (KeyError, TypeError):
|
|
1132
1163
|
continue
|
|
1133
1164
|
|
reconcile/utils/rhcsv2_certs.py
CHANGED
|
@@ -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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
.
|
|
111
|
-
|
|
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)
|
reconcile/utils/rosa/session.py
CHANGED
|
@@ -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
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
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,
|
|
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}")
|