qontract-reconcile 0.10.2.dev167__py3-none-any.whl → 0.10.2.dev169__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/quay_mirror.py CHANGED
@@ -28,9 +28,9 @@ from reconcile.utils import (
28
28
  metrics,
29
29
  sharding,
30
30
  )
31
- from reconcile.utils.helpers import match_patterns
32
31
  from reconcile.utils.instrumented_wrappers import InstrumentedImage as Image
33
32
  from reconcile.utils.instrumented_wrappers import InstrumentedSkopeo as Skopeo
33
+ from reconcile.utils.quay_mirror import record_timestamp, sync_tag
34
34
  from reconcile.utils.secret_reader import SecretReader
35
35
 
36
36
  _LOG = logging.getLogger(__name__)
@@ -136,7 +136,7 @@ class QuayMirror:
136
136
  _LOG.error("skopeo command error message: '%s'", details)
137
137
 
138
138
  if self.is_compare_tags and not self.dry_run:
139
- self.record_timestamp(self.control_file_path)
139
+ record_timestamp(self.control_file_path)
140
140
 
141
141
  @classmethod
142
142
  def process_repos_query(
@@ -203,40 +203,6 @@ class QuayMirror:
203
203
  })
204
204
  return summary
205
205
 
206
- @staticmethod
207
- def sync_tag(
208
- tags: Iterable[str] | None,
209
- tags_exclude: Iterable[str] | None,
210
- candidate: str,
211
- ) -> bool:
212
- """
213
- Determine if the candidate tag should sync, tags_exclude check take precedence.
214
- :param tags: regex patterns to filter, match means to sync, None means no filter
215
- :param tags_exclude: regex patterns to filter, match means not to sync, None means no filter
216
- :param candidate: tag to check
217
- :return: bool, True means to sync, False means not to sync
218
- """
219
- if not tags and not tags_exclude:
220
- return True
221
-
222
- if not tags:
223
- # only tags_exclude provided
224
- assert tags_exclude # mypy can't infer not None
225
- return not match_patterns(tags_exclude, candidate)
226
-
227
- if not tags_exclude:
228
- # only tags provided
229
- return match_patterns(tags, candidate)
230
-
231
- # both tags and tags_exclude provided
232
- return not match_patterns(
233
- tags_exclude,
234
- candidate,
235
- ) and match_patterns(
236
- tags,
237
- candidate,
238
- )
239
-
240
206
  def process_sync_tasks(self):
241
207
  if self.is_compare_tags:
242
208
  _LOG.warning("Making a compare-tags run. This is a slow operation.")
@@ -282,7 +248,7 @@ class QuayMirror:
282
248
  tags_exclude = item["mirror"].get("tagsExclude")
283
249
 
284
250
  for tag in image_mirror:
285
- if not self.sync_tag(
251
+ if not sync_tag(
286
252
  tags=tags, tags_exclude=tags_exclude, candidate=tag
287
253
  ):
288
254
  continue
@@ -390,11 +356,6 @@ class QuayMirror:
390
356
  next_compare_tags = last_compare_tags + interval
391
357
  return time.time() >= next_compare_tags
392
358
 
393
- @staticmethod
394
- def record_timestamp(path) -> None:
395
- with open(path, "w", encoding="locale") as file_object:
396
- file_object.write(str(time.time()))
397
-
398
359
  def _get_push_creds(self):
399
360
  result = self.gqlapi.query(self.QUAY_ORG_CATALOG_QUERY)
400
361
 
@@ -0,0 +1,42 @@
1
+ import time
2
+ from collections.abc import Iterable
3
+
4
+ from reconcile.utils.helpers import match_patterns
5
+
6
+
7
+ def record_timestamp(path: str) -> None:
8
+ with open(path, "w", encoding="locale") as file_object:
9
+ file_object.write(str(time.time()))
10
+
11
+
12
+ def sync_tag(
13
+ tags: Iterable[str] | None,
14
+ tags_exclude: Iterable[str] | None,
15
+ candidate: str,
16
+ ) -> bool:
17
+ """
18
+ Determine if the candidate tag should sync, tags_exclude check take precedence.
19
+ :param tags: regex patterns to filter, match means to sync, None means no filter
20
+ :param tags_exclude: regex patterns to filter, match means not to sync, None means no filter
21
+ :param candidate: tag to check
22
+ :return: bool, True means to sync, False means not to sync
23
+ """
24
+ if tags:
25
+ if tags_exclude:
26
+ # both tags and tags_exclude provided
27
+ return not match_patterns(
28
+ tags_exclude,
29
+ candidate,
30
+ ) and match_patterns(
31
+ tags,
32
+ candidate,
33
+ )
34
+ else:
35
+ # only tags provided
36
+ return match_patterns(tags, candidate)
37
+ elif tags_exclude:
38
+ # only tags_exclude provided
39
+ return not match_patterns(tags_exclude, candidate)
40
+ else:
41
+ # neither tags nor tags_exclude provided
42
+ return True
@@ -0,0 +1,278 @@
1
+ import itertools
2
+ import logging
3
+ from dataclasses import dataclass
4
+ from math import isnan
5
+ from typing import Any, Self
6
+
7
+ import jinja2
8
+ import requests
9
+ from sretoolbox.utils import threaded
10
+
11
+ from reconcile.gql_definitions.fragments.saas_slo_document import (
12
+ SLODocument,
13
+ SLODocumentSLOV1,
14
+ SLOExternalPrometheusAccessV1,
15
+ SLONamespacesV1,
16
+ )
17
+ from reconcile.utils.rest_api_base import ApiBase, BearerTokenAuth
18
+ from reconcile.utils.secret_reader import SecretReaderBase
19
+
20
+ PROM_QUERY_URL = "api/v1/query"
21
+
22
+ DEFAULT_READ_TIMEOUT = 30
23
+ DEFAULT_RETRIES = 3
24
+ DEFAULT_THREAD_POOL_SIZE = 10
25
+
26
+
27
+ class EmptySLOResult(Exception):
28
+ pass
29
+
30
+
31
+ class EmptySLOValue(Exception):
32
+ pass
33
+
34
+
35
+ class InvalidSLOValue(Exception):
36
+ pass
37
+
38
+
39
+ @dataclass
40
+ class SLODetails:
41
+ namespace_name: str
42
+ slo_document_name: str
43
+ cluster_name: str
44
+ slo: SLODocumentSLOV1
45
+ service_name: str
46
+ current_slo_value: float
47
+
48
+
49
+ @dataclass
50
+ class NamespaceSLODocument:
51
+ name: str
52
+ namespace: SLONamespacesV1
53
+ slos: list[SLODocumentSLOV1] | None
54
+
55
+ def get_host_url(self) -> str:
56
+ return (
57
+ self.namespace.prometheus_access.url
58
+ if self.namespace.prometheus_access
59
+ else self.namespace.namespace.cluster.prometheus_url
60
+ )
61
+
62
+
63
+ class PrometheusClient(ApiBase):
64
+ def get_current_slo_value(
65
+ self,
66
+ slo: SLODocumentSLOV1,
67
+ slo_document_name: str,
68
+ namespace_name: str,
69
+ service_name: str,
70
+ cluster_name: str,
71
+ ) -> SLODetails | None:
72
+ """
73
+ Retrieve the current SLO value from Prometheus for provided SLO configuration.
74
+ Returns an SLODetails instance if successful, or None on error.
75
+ """
76
+ template = jinja2.Template(slo.expr)
77
+ prom_query = template.render({"window": slo.slo_parameters.window})
78
+ try:
79
+ current_slo_response = self._get(
80
+ url=PROM_QUERY_URL, params={"query": (prom_query)}
81
+ )
82
+ current_slo_value = self._extract_current_slo_value(
83
+ data=current_slo_response
84
+ )
85
+ return SLODetails(
86
+ namespace_name=namespace_name,
87
+ slo=slo,
88
+ slo_document_name=slo_document_name,
89
+ current_slo_value=current_slo_value,
90
+ cluster_name=cluster_name,
91
+ service_name=service_name,
92
+ )
93
+ except requests.exceptions.ConnectionError:
94
+ logging.error(
95
+ f"Connection error getting current value for SLO: {slo.name} of document: {slo_document_name} for namespace: {namespace_name}"
96
+ )
97
+ raise
98
+ except Exception as e:
99
+ logging.error(
100
+ f"Unexpected error getting current value for SLO: {slo.name} of document: {slo_document_name} for namespace: {namespace_name} details: {e}"
101
+ )
102
+ return None
103
+
104
+ def _extract_current_slo_value(self, data: dict[str, Any]) -> float:
105
+ result = data["data"]["result"]
106
+ if not result:
107
+ raise EmptySLOResult("prometheus returned empty result")
108
+ slo_value = result[0]["value"]
109
+ if not slo_value:
110
+ raise EmptySLOValue("prometheus returned empty SLO value")
111
+ slo_value = float(slo_value[1])
112
+ if isnan(slo_value):
113
+ raise InvalidSLOValue("slo value should be a number")
114
+ return slo_value
115
+
116
+
117
+ class PrometheusClientMap:
118
+ """
119
+ A mapping from Prometheus URLs to PrometheusClient instances.
120
+ """
121
+
122
+ def __init__(
123
+ self,
124
+ secret_reader: SecretReaderBase,
125
+ namespace_slo_documents: list[NamespaceSLODocument],
126
+ read_timeout: int = DEFAULT_READ_TIMEOUT,
127
+ max_retries: int = DEFAULT_RETRIES,
128
+ ):
129
+ self.secret_reader = secret_reader
130
+ self.read_timeout = read_timeout
131
+ self.max_retries = max_retries
132
+ self.pc_map: dict[str, PrometheusClient] = self._build_pc_map(
133
+ namespace_slo_documents
134
+ )
135
+
136
+ def __enter__(self) -> Self:
137
+ return self
138
+
139
+ def __exit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None:
140
+ self.cleanup()
141
+
142
+ def get_prometheus_client(self, prom_url: str) -> PrometheusClient:
143
+ return self.pc_map[prom_url]
144
+
145
+ def _build_pc_map(
146
+ self, namespace_slo_documents: list[NamespaceSLODocument]
147
+ ) -> dict[str, PrometheusClient]:
148
+ pc_map: dict[str, PrometheusClient] = {}
149
+ for doc in namespace_slo_documents:
150
+ key = doc.get_host_url()
151
+ if key not in pc_map:
152
+ prom_client = self.build_prom_client_from_namespace(doc.namespace)
153
+ pc_map[key] = prom_client
154
+ return pc_map
155
+
156
+ def cleanup(self) -> None:
157
+ for prom_client in self.pc_map.values():
158
+ prom_client.cleanup()
159
+
160
+ def build_auth_for_prometheus_access(
161
+ self, prometheus_access: SLOExternalPrometheusAccessV1
162
+ ) -> requests.auth.HTTPBasicAuth | None:
163
+ """
164
+ Build authentication for Prometheus endpoint referred in prometheusAccess section.
165
+ """
166
+ if prometheus_access.username and prometheus_access.password:
167
+ username = self.secret_reader.read_secret(prometheus_access.username)
168
+ password = self.secret_reader.read_secret(prometheus_access.password)
169
+ return requests.auth.HTTPBasicAuth(username, password)
170
+ return None
171
+
172
+ def build_prom_client_from_namespace(
173
+ self, namespace: SLONamespacesV1
174
+ ) -> PrometheusClient:
175
+ auth: requests.auth.HTTPBasicAuth | BearerTokenAuth | None
176
+ if namespace.prometheus_access:
177
+ prom_url = namespace.prometheus_access.url
178
+ auth = self.build_auth_for_prometheus_access(namespace.prometheus_access)
179
+ return PrometheusClient(
180
+ host=prom_url,
181
+ read_timeout=self.read_timeout,
182
+ max_retries=self.max_retries,
183
+ auth=auth,
184
+ )
185
+ if not namespace.namespace.cluster.automation_token:
186
+ raise Exception(
187
+ f"cluster {namespace.namespace.cluster.name} does not have automation token set"
188
+ )
189
+ auth = BearerTokenAuth(
190
+ self.secret_reader.read_secret(namespace.namespace.cluster.automation_token)
191
+ )
192
+ return PrometheusClient(
193
+ host=namespace.namespace.cluster.prometheus_url,
194
+ read_timeout=self.read_timeout,
195
+ max_retries=self.max_retries,
196
+ auth=auth,
197
+ )
198
+
199
+
200
+ class SLODocumentManager:
201
+ """
202
+ Manages SLO document including authentication, querying, and SLO value extraction.
203
+ """
204
+
205
+ def __init__(
206
+ self,
207
+ slo_documents: list[SLODocument],
208
+ secret_reader: SecretReaderBase,
209
+ thread_pool_size: int = DEFAULT_THREAD_POOL_SIZE,
210
+ read_timeout: int = DEFAULT_READ_TIMEOUT,
211
+ max_retries: int = DEFAULT_RETRIES,
212
+ ):
213
+ self.namespace_slo_documents = self._build_namespace_slo_documents(
214
+ slo_documents
215
+ )
216
+ self.thread_pool_size = thread_pool_size
217
+ self.secret_reader = secret_reader
218
+ self.max_retries = max_retries
219
+ self.read_timeout = read_timeout
220
+
221
+ @staticmethod
222
+ def _build_namespace_slo_documents(
223
+ slo_documents: list[SLODocument],
224
+ ) -> list[NamespaceSLODocument]:
225
+ return [
226
+ NamespaceSLODocument(
227
+ name=slo_document.name,
228
+ namespace=namespace,
229
+ slos=slo_document.slos,
230
+ )
231
+ for slo_document in slo_documents
232
+ for namespace in slo_document.namespaces
233
+ ]
234
+
235
+ def get_current_slo_list(self) -> list[SLODetails | None]:
236
+ with PrometheusClientMap(
237
+ secret_reader=self.secret_reader,
238
+ namespace_slo_documents=self.namespace_slo_documents,
239
+ read_timeout=self.read_timeout,
240
+ max_retries=self.max_retries,
241
+ ) as pc_map:
242
+ current_slo_list_iterable = threaded.run(
243
+ func=self._get_current_slo_details_list,
244
+ pc_map=pc_map,
245
+ iterable=self.namespace_slo_documents,
246
+ thread_pool_size=self.thread_pool_size,
247
+ )
248
+ return list(itertools.chain.from_iterable(current_slo_list_iterable))
249
+
250
+ def get_breached_slos(self) -> list[SLODetails]:
251
+ current_slo_details_list = self.get_current_slo_list()
252
+ missing_slos = [slo for slo in current_slo_details_list if not slo]
253
+ if missing_slos:
254
+ raise RuntimeError("slo validation failed due to retrival errors")
255
+ return [
256
+ slo
257
+ for slo in current_slo_details_list
258
+ if slo and slo.current_slo_value < slo.slo.slo_target
259
+ ]
260
+
261
+ @staticmethod
262
+ def _get_current_slo_details_list(
263
+ slo_document: NamespaceSLODocument,
264
+ pc_map: PrometheusClientMap,
265
+ ) -> list[SLODetails | None]:
266
+ key = slo_document.get_host_url()
267
+ prom_client = pc_map.get_prometheus_client(key)
268
+ slo_details_list: list[SLODetails | None] = [
269
+ prom_client.get_current_slo_value(
270
+ slo=slo,
271
+ slo_document_name=slo_document.name,
272
+ namespace_name=slo_document.namespace.namespace.name,
273
+ service_name=slo_document.namespace.namespace.app.name,
274
+ cluster_name=slo_document.namespace.namespace.cluster.name,
275
+ )
276
+ for slo in slo_document.slos or []
277
+ ]
278
+ return slo_details_list