qontract-reconcile 0.10.2.dev345__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.
Files changed (126) hide show
  1. {qontract_reconcile-0.10.2.dev345.dist-info → qontract_reconcile-0.10.2.dev408.dist-info}/METADATA +11 -10
  2. {qontract_reconcile-0.10.2.dev345.dist-info → qontract_reconcile-0.10.2.dev408.dist-info}/RECORD +126 -120
  3. reconcile/aus/base.py +17 -14
  4. reconcile/automated_actions/config/integration.py +12 -0
  5. reconcile/aws_account_manager/integration.py +2 -2
  6. reconcile/aws_ami_cleanup/integration.py +6 -7
  7. reconcile/aws_ami_share.py +69 -62
  8. reconcile/aws_cloudwatch_log_retention/integration.py +155 -126
  9. reconcile/aws_ecr_image_pull_secrets.py +2 -2
  10. reconcile/aws_iam_keys.py +1 -0
  11. reconcile/aws_saml_idp/integration.py +7 -1
  12. reconcile/aws_saml_roles/integration.py +9 -3
  13. reconcile/change_owners/change_owners.py +1 -1
  14. reconcile/change_owners/diff.py +2 -4
  15. reconcile/checkpoint.py +11 -3
  16. reconcile/cli.py +33 -8
  17. reconcile/dashdotdb_dora.py +4 -11
  18. reconcile/database_access_manager.py +118 -111
  19. reconcile/endpoints_discovery/integration.py +4 -1
  20. reconcile/endpoints_discovery/merge_request_manager.py +9 -11
  21. reconcile/external_resources/factories.py +5 -12
  22. reconcile/external_resources/integration.py +1 -1
  23. reconcile/external_resources/manager.py +5 -3
  24. reconcile/external_resources/meta.py +0 -1
  25. reconcile/external_resources/model.py +10 -10
  26. reconcile/external_resources/reconciler.py +5 -2
  27. reconcile/external_resources/secrets_sync.py +4 -6
  28. reconcile/external_resources/state.py +5 -4
  29. reconcile/gabi_authorized_users.py +8 -5
  30. reconcile/gitlab_housekeeping.py +13 -15
  31. reconcile/gitlab_mr_sqs_consumer.py +2 -2
  32. reconcile/gitlab_owners.py +15 -11
  33. reconcile/gql_definitions/automated_actions/instance.py +41 -2
  34. reconcile/gql_definitions/aws_ami_cleanup/aws_accounts.py +10 -0
  35. reconcile/gql_definitions/aws_cloudwatch_log_retention/aws_accounts.py +22 -61
  36. reconcile/gql_definitions/aws_saml_idp/aws_accounts.py +10 -0
  37. reconcile/gql_definitions/aws_saml_roles/aws_accounts.py +10 -0
  38. reconcile/gql_definitions/common/aws_vpc_requests.py +10 -0
  39. reconcile/gql_definitions/common/clusters.py +2 -0
  40. reconcile/gql_definitions/external_resources/external_resources_namespaces.py +84 -1
  41. reconcile/gql_definitions/external_resources/external_resources_settings.py +2 -0
  42. reconcile/gql_definitions/fragments/aws_account_common.py +2 -0
  43. reconcile/gql_definitions/fragments/aws_organization.py +33 -0
  44. reconcile/gql_definitions/fragments/aws_vpc_request.py +2 -0
  45. reconcile/gql_definitions/introspection.json +3474 -1986
  46. reconcile/gql_definitions/jira_permissions_validator/jira_boards_for_permissions_validator.py +4 -0
  47. reconcile/gql_definitions/terraform_init/aws_accounts.py +14 -0
  48. reconcile/gql_definitions/terraform_resources/terraform_resources_namespaces.py +33 -1
  49. reconcile/gql_definitions/terraform_tgw_attachments/aws_accounts.py +10 -0
  50. reconcile/jenkins_worker_fleets.py +1 -0
  51. reconcile/jira_permissions_validator.py +236 -121
  52. reconcile/ocm/types.py +6 -0
  53. reconcile/openshift_base.py +47 -1
  54. reconcile/openshift_cluster_bots.py +2 -1
  55. reconcile/openshift_resources_base.py +6 -2
  56. reconcile/openshift_saas_deploy.py +2 -2
  57. reconcile/openshift_saas_deploy_trigger_cleaner.py +3 -5
  58. reconcile/openshift_upgrade_watcher.py +3 -3
  59. reconcile/queries.py +131 -0
  60. reconcile/saas_auto_promotions_manager/subscriber.py +4 -3
  61. reconcile/slack_usergroups.py +4 -3
  62. reconcile/sql_query.py +1 -0
  63. reconcile/statuspage/integrations/maintenances.py +4 -3
  64. reconcile/statuspage/status.py +5 -8
  65. reconcile/templates/rosa-classic-cluster-creation.sh.j2 +4 -0
  66. reconcile/templates/rosa-hcp-cluster-creation.sh.j2 +3 -0
  67. reconcile/templating/renderer.py +2 -1
  68. reconcile/terraform_aws_route53.py +7 -1
  69. reconcile/terraform_init/integration.py +185 -21
  70. reconcile/terraform_resources.py +11 -1
  71. reconcile/terraform_tgw_attachments.py +7 -1
  72. reconcile/terraform_users.py +7 -0
  73. reconcile/terraform_vpc_peerings.py +14 -3
  74. reconcile/terraform_vpc_resources/integration.py +7 -0
  75. reconcile/typed_queries/aws_account_tags.py +41 -0
  76. reconcile/typed_queries/saas_files.py +2 -2
  77. reconcile/utils/aggregated_list.py +4 -3
  78. reconcile/utils/aws_api.py +51 -20
  79. reconcile/utils/aws_api_typed/api.py +38 -9
  80. reconcile/utils/aws_api_typed/cloudformation.py +149 -0
  81. reconcile/utils/aws_api_typed/logs.py +73 -0
  82. reconcile/utils/datetime_util.py +67 -0
  83. reconcile/utils/differ.py +2 -3
  84. reconcile/utils/early_exit_cache.py +3 -2
  85. reconcile/utils/expiration.py +7 -3
  86. reconcile/utils/external_resource_spec.py +24 -1
  87. reconcile/utils/filtering.py +1 -1
  88. reconcile/utils/helm.py +2 -1
  89. reconcile/utils/helpers.py +1 -1
  90. reconcile/utils/jinja2/utils.py +4 -96
  91. reconcile/utils/jira_client.py +82 -63
  92. reconcile/utils/jjb_client.py +9 -12
  93. reconcile/utils/jobcontroller/controller.py +1 -1
  94. reconcile/utils/jobcontroller/models.py +17 -1
  95. reconcile/utils/json.py +32 -0
  96. reconcile/utils/merge_request_manager/merge_request_manager.py +3 -3
  97. reconcile/utils/merge_request_manager/parser.py +2 -2
  98. reconcile/utils/mr/app_interface_reporter.py +2 -2
  99. reconcile/utils/mr/base.py +2 -2
  100. reconcile/utils/mr/notificator.py +2 -2
  101. reconcile/utils/mr/update_access_report_base.py +3 -4
  102. reconcile/utils/oc.py +113 -95
  103. reconcile/utils/oc_filters.py +3 -3
  104. reconcile/utils/ocm/products.py +6 -0
  105. reconcile/utils/ocm/search_filters.py +3 -6
  106. reconcile/utils/ocm/service_log.py +3 -5
  107. reconcile/utils/openshift_resource.py +10 -5
  108. reconcile/utils/output.py +3 -2
  109. reconcile/utils/pagerduty_api.py +5 -5
  110. reconcile/utils/runtime/integration.py +1 -2
  111. reconcile/utils/runtime/runner.py +2 -2
  112. reconcile/utils/saasherder/models.py +2 -1
  113. reconcile/utils/saasherder/saasherder.py +9 -7
  114. reconcile/utils/slack_api.py +24 -2
  115. reconcile/utils/sloth.py +171 -2
  116. reconcile/utils/sqs_gateway.py +2 -1
  117. reconcile/utils/state.py +2 -1
  118. reconcile/utils/terraform_client.py +4 -3
  119. reconcile/utils/terrascript_aws_client.py +165 -111
  120. reconcile/utils/vault.py +1 -1
  121. reconcile/vault_replication.py +107 -42
  122. tools/app_interface_reporter.py +4 -4
  123. tools/cli_commands/systems_and_tools.py +5 -1
  124. tools/qontract_cli.py +25 -13
  125. {qontract_reconcile-0.10.2.dev345.dist-info → qontract_reconcile-0.10.2.dev408.dist-info}/WHEEL +0 -0
  126. {qontract_reconcile-0.10.2.dev345.dist-info → qontract_reconcile-0.10.2.dev408.dist-info}/entry_points.txt +0 -0
@@ -1,13 +1,10 @@
1
1
  import datetime
2
2
  import os
3
- import subprocess
4
- import tempfile
5
3
  from collections.abc import Mapping
6
4
  from functools import cache
7
5
  from typing import Any, Self
8
6
 
9
7
  import jinja2
10
- import yaml
11
8
  from github import Github
12
9
  from jinja2.sandbox import SandboxedEnvironment
13
10
  from pydantic import BaseModel
@@ -18,6 +15,7 @@ from reconcile.checkpoint import url_makes_sense
18
15
  from reconcile.github_org import get_default_config
19
16
  from reconcile.utils import gql
20
17
  from reconcile.utils.aws_api import AWSApi
18
+ from reconcile.utils.datetime_util import utc_now
21
19
  from reconcile.utils.github_api import GithubRepositoryApi
22
20
  from reconcile.utils.helpers import flatten
23
21
  from reconcile.utils.jinja2.extensions import B64EncodeExtension, RaiseErrorExtension
@@ -38,7 +36,7 @@ from reconcile.utils.secret_reader import (
38
36
  SecretReader,
39
37
  SecretReaderBase,
40
38
  )
41
- from reconcile.utils.sloth import process_sloth_output
39
+ from reconcile.utils.sloth import generate_sloth_rules
42
40
  from reconcile.utils.vault import SecretFieldNotFoundError
43
41
 
44
42
 
@@ -192,89 +190,6 @@ def list_s3_objects(
192
190
  )
193
191
 
194
192
 
195
- def sloth_alerts(
196
- service: str,
197
- slo_name: str,
198
- objective: float,
199
- error_query: str,
200
- total_query: str,
201
- version: str = "prometheus/v1",
202
- ) -> str:
203
- """Generate Prometheus rules using sloth: https://sloth.dev
204
-
205
- Args:
206
- service: Service name identifier
207
- slo_name: Name of the SLO
208
- objective: Target percentage (e.g. 99.9)
209
- error_query: Prometheus query for error events
210
- total_query: Prometheus query for total events
211
- version: Spec version (default: "prometheus/v1")
212
-
213
- Returns:
214
- Generated Prometheus rules as YAML string
215
- """
216
- # Build the SLO definition
217
- slo = {
218
- "name": slo_name,
219
- "objective": objective,
220
- "description": f"{slo_name} SLO for {service}",
221
- "sli": {
222
- "events": {
223
- "error_query": error_query.replace("{{window}}", "{{.window}}"),
224
- "total_query": total_query.replace("{{window}}", "{{.window}}"),
225
- }
226
- },
227
- "alerting": {
228
- "name": f"{service.title()}{slo_name.title()}",
229
- "annotations": {
230
- "summary": f"High error rate on '{service}' {slo_name}",
231
- "message": f"High error rate on '{service}' {slo_name}",
232
- },
233
- "page_alert": {
234
- "labels": {
235
- "severity": "critical",
236
- "service": service,
237
- "slo": slo_name,
238
- }
239
- },
240
- "ticket_alert": {
241
- "labels": {
242
- "severity": "medium",
243
- "service": service,
244
- "slo": slo_name,
245
- }
246
- },
247
- },
248
- }
249
-
250
- spec = {
251
- "version": version,
252
- "service": service,
253
- "slos": [slo],
254
- }
255
-
256
- with (
257
- tempfile.NamedTemporaryFile(
258
- encoding="utf-8", mode="w", suffix=".yml"
259
- ) as input_file,
260
- tempfile.NamedTemporaryFile(
261
- encoding="utf-8", mode="w", suffix=".yml"
262
- ) as output_file,
263
- ):
264
- yaml.dump(spec, input_file, allow_unicode=True)
265
- cmd = ["sloth", "generate", "-i", input_file.name, "-o", output_file.name]
266
- try:
267
- subprocess.run(cmd, capture_output=True, check=True, text=True)
268
- except subprocess.CalledProcessError as e:
269
- error_msg = f"{e}"
270
- if e.stdout:
271
- error_msg += f"\nstdout: {e.stdout}"
272
- if e.stderr:
273
- error_msg += f"\nstderr: {e.stderr}"
274
- raise SlothGenerateError(error_msg) from e
275
- return process_sloth_output(output_file.name)
276
-
277
-
278
193
  @retry()
279
194
  def lookup_secret(
280
195
  path: str,
@@ -345,10 +260,8 @@ def process_jinja2_template(
345
260
  "s3": lookup_s3_object,
346
261
  "s3_ls": list_s3_objects,
347
262
  "flatten_dict": flatten,
348
- "yesterday": lambda: (datetime.datetime.now() - datetime.timedelta(1)).strftime(
349
- "%Y-%m-%d"
350
- ),
351
- "sloth_alerts": sloth_alerts,
263
+ "yesterday": lambda: (utc_now() - datetime.timedelta(1)).strftime("%Y-%m-%d"),
264
+ "sloth_alerts": generate_sloth_rules,
352
265
  })
353
266
  if "_template_mocks" in vars:
354
267
  for k, v in vars["_template_mocks"].items():
@@ -381,11 +294,6 @@ def process_extracurlyjinja2_template(
381
294
  )
382
295
 
383
296
 
384
- class SlothGenerateError(Exception):
385
- def __init__(self, msg: Any):
386
- super().__init__("sloth generate failed: " + str(msg))
387
-
388
-
389
297
  class FetchSecretError(Exception):
390
298
  def __init__(self, msg: Any):
391
299
  super().__init__("error fetching secret: " + str(msg))
@@ -17,10 +17,8 @@ from jira.resources import CustomFieldOption as JiraCustomFieldOption
17
17
  from jira.resources import Resource
18
18
  from pydantic import BaseModel
19
19
 
20
- from reconcile.utils.secret_reader import SecretReader
21
-
22
20
  if TYPE_CHECKING:
23
- from collections.abc import Iterable, Mapping
21
+ from collections.abc import Iterable
24
22
 
25
23
 
26
24
  class JiraWatcherSettings(Protocol):
@@ -87,36 +85,18 @@ class IssueField(BaseModel):
87
85
  options: list[FieldOption | CustomFieldOption]
88
86
 
89
87
 
88
+ CREATE_ISSUES = "CREATE_ISSUES"
89
+ TRANSITION_ISSUES = "TRANSITION_ISSUES"
90
+ PERMISSIONS = [CREATE_ISSUES, TRANSITION_ISSUES]
91
+
92
+
90
93
  class JiraClient:
91
94
  """Wrapper around Jira client."""
92
95
 
93
96
  DEFAULT_CONNECT_TIMEOUT = 60
94
97
  DEFAULT_READ_TIMEOUT = 60
95
98
 
96
- def __init__(
97
- self,
98
- jira_board: Mapping[str, Any] | None = None,
99
- settings: Mapping | None = None,
100
- jira_api: JIRA | None = None,
101
- project: str | None = None,
102
- server: str | None = None,
103
- ):
104
- """
105
- Note: jira_board and settings is to be deprecated. Use JiraClient.create() instead.
106
- """
107
- if jira_api and jira_board:
108
- raise RuntimeError(
109
- "jira_board parameter is deprecated. Use JiraClient.create() instead."
110
- )
111
- if not (jira_api and project):
112
- # kept for backwards-compatibility
113
- if not jira_board:
114
- raise RuntimeError(
115
- "JiraClient needs jira_api and project or jira_board."
116
- )
117
- self._deprecated_init(jira_board=jira_board, settings=settings)
118
- return
119
-
99
+ def __init__(self, jira_api: JIRA, project: str, server: str):
120
100
  self.server = server
121
101
  self.project = project
122
102
  self.jira = jira_api
@@ -128,47 +108,31 @@ class JiraClient:
128
108
  self.project_issue_types = functools.cache(self._project_issue_types)
129
109
  self.project_issue_fields = functools.cache(self._project_issue_fields)
130
110
 
131
- def _deprecated_init(
132
- self, jira_board: Mapping[str, Any], settings: Mapping | None
133
- ) -> None:
134
- secret_reader = SecretReader(settings=settings)
135
- self.project = jira_board["name"]
136
- jira_server = jira_board["server"]
137
- self.server = jira_server["serverUrl"]
138
- token = jira_server["token"]
139
- token_auth = secret_reader.read(token)
140
- read_timeout = 60
141
- connect_timeout = 60
142
- if settings and settings["jiraWatcher"]:
143
- read_timeout = settings["jiraWatcher"]["readTimeout"]
144
- connect_timeout = settings["jiraWatcher"]["connectTimeout"]
145
- if not self.server:
146
- raise RuntimeError("JiraClient.server is not set.")
147
-
148
- self.jira = JIRA(
149
- self.server,
150
- token_auth=token_auth,
151
- timeout=(read_timeout, connect_timeout),
152
- logging=False,
153
- )
154
-
155
111
  @staticmethod
156
112
  def create(
157
113
  project_name: str,
158
114
  token: str,
115
+ email: str | None,
159
116
  server_url: str,
160
117
  jira_watcher_settings: JiraWatcherSettings | None = None,
161
118
  ) -> JiraClient:
119
+ """Create a Jira client for the given project."""
162
120
  read_timeout = JiraClient.DEFAULT_READ_TIMEOUT
163
121
  connect_timeout = JiraClient.DEFAULT_CONNECT_TIMEOUT
164
122
  if jira_watcher_settings:
165
123
  read_timeout = jira_watcher_settings.read_timeout
166
124
  connect_timeout = jira_watcher_settings.connect_timeout
125
+
126
+ # Jira Cloud uses email+API token for basic auth
127
+ # Jira Server/Data Center can use token auth (personal access token)
128
+ auth_params: dict[str, Any] = (
129
+ {"basic_auth": (email, token)} if email else {"token_auth": token}
130
+ )
167
131
  jira_api = JIRA(
168
132
  server=server_url,
169
- token_auth=token,
170
133
  timeout=(read_timeout, connect_timeout),
171
134
  logging=False,
135
+ **auth_params,
172
136
  )
173
137
  return JiraClient(
174
138
  jira_api=jira_api,
@@ -176,7 +140,13 @@ class JiraClient:
176
140
  server=server_url,
177
141
  )
178
142
 
143
+ @property
144
+ def is_cloud(self) -> bool:
145
+ """Return whether we are on a Cloud based Jira instance."""
146
+ return self.jira.deploymentType == "Cloud"
147
+
179
148
  def get_issues(self, fields: Iterable | None = None) -> list[Issue]:
149
+ """Return all issues for our project."""
180
150
  block_size = 100
181
151
  block_num = 0
182
152
 
@@ -227,20 +197,34 @@ class JiraClient:
227
197
  return issue
228
198
 
229
199
  def _my_permissions(self, project: str) -> dict[str, Any]:
200
+ """Return my permissions for the given project.
201
+
202
+ Don't use this function directly, use self.my_permissions which is cached."""
203
+ if self.is_cloud:
204
+ return self.jira.my_permissions(
205
+ projectKey=project, permissions=",".join(PERMISSIONS)
206
+ )["permissions"]
230
207
  return self.jira.my_permissions(projectKey=project)["permissions"]
231
208
 
232
209
  def can_i(self, permission: str) -> bool:
210
+ """Return whether I have the given permission in the project."""
233
211
  return bool(
234
212
  self.my_permissions(project=self.project)[permission]["havePermission"]
235
213
  )
236
214
 
237
215
  def can_create_issues(self) -> bool:
238
- return self.can_i("CREATE_ISSUES")
216
+ """Return whether I can create issues in the project."""
217
+ return self.can_i(CREATE_ISSUES)
239
218
 
240
219
  def can_transition_issues(self) -> bool:
241
- return self.can_i("TRANSITION_ISSUES")
220
+ """Return whether I can transition issues in the project."""
221
+ return self.can_i(TRANSITION_ISSUES)
242
222
 
243
223
  def _project_issue_types(self, project: str) -> list[IssueType]:
224
+ """Return all available issue types (e.g. Task, Bug) for the project.
225
+
226
+ Don't use this function directly, use self.project_issue_types which is cached.
227
+ """
244
228
  # Don't use self.project here, because of function.cache usage
245
229
  return [
246
230
  IssueType(id=t.id, name=t.name, statuses=[s.name for s in t.statuses])
@@ -248,6 +232,7 @@ class JiraClient:
248
232
  ]
249
233
 
250
234
  def get_issue_type(self, issue_type: str) -> IssueType | None:
235
+ """Return a issue type (e.g. Task) for the project if it exists."""
251
236
  for _issue_type in self.project_issue_types(self.project):
252
237
  if _issue_type.name == issue_type:
253
238
  return _issue_type
@@ -255,15 +240,23 @@ class JiraClient:
255
240
 
256
241
  @staticmethod
257
242
  def _get_allowed_issue_field_options(
258
- allowed_values: list[Resource],
243
+ allowed_values: list[Resource] | list[dict[str, str]],
259
244
  ) -> list[FieldOption | CustomFieldOption]:
260
- """Return a list of allowed values for a field."""
261
- return [
262
- CustomFieldOption(value=v.value)
263
- if isinstance(v, JiraCustomFieldOption)
264
- else FieldOption(name=v.name)
265
- for v in allowed_values
266
- ]
245
+ """Return a list of allowed values for a field. E.g. Minor, Major ... for Priority in a Task."""
246
+ items: list[FieldOption | CustomFieldOption] = []
247
+ for v in allowed_values:
248
+ match v:
249
+ case dict() if "value" in v:
250
+ items.append(CustomFieldOption(value=v["value"]))
251
+ case dict() if "name" in v:
252
+ items.append(FieldOption(name=v["name"]))
253
+ case JiraCustomFieldOption():
254
+ items.append(CustomFieldOption(value=v.value))
255
+ case Resource():
256
+ items.append(FieldOption(name=v.name))
257
+ case _:
258
+ logging.warning(f"Unknown allowed value type: {type(v)}")
259
+ return items
267
260
 
268
261
  def _project_issue_fields(
269
262
  self, project: str, issue_type_id: str
@@ -273,6 +266,27 @@ class JiraClient:
273
266
  This API endpoint needs createIssue project permissions.
274
267
  """
275
268
  # Don't use self.project here, because of function.cache usage
269
+ if self.is_cloud:
270
+ metadata = self.jira.createmeta(
271
+ projectKeys=self.project,
272
+ issuetypeIds=[issue_type_id],
273
+ expand="projects.issuetypes.fields",
274
+ )
275
+ if not metadata["projects"] or not metadata["projects"][0]["issuetypes"]:
276
+ return []
277
+ return [
278
+ IssueField(
279
+ name=field["name"],
280
+ id=field_id,
281
+ options=self._get_allowed_issue_field_options(
282
+ field.get("allowedValues", [])
283
+ ),
284
+ )
285
+ for field_id, field in metadata["projects"][0]["issuetypes"][0][
286
+ "fields"
287
+ ].items()
288
+ ]
289
+
276
290
  return [
277
291
  IssueField(
278
292
  name=field.name,
@@ -304,6 +318,10 @@ class JiraClient:
304
318
 
305
319
  def project_priority_scheme(self) -> list[str]:
306
320
  """Return a list of all priority IDs for the project."""
321
+ if self.is_cloud:
322
+ # Cloud does not have a way to retrieve project specific priority schemes
323
+ return []
324
+
307
325
  scheme = self.jira.project_priority_scheme(self.project)
308
326
  return scheme.optionIds
309
327
 
@@ -329,4 +347,5 @@ class JiraClient:
329
347
 
330
348
  @property
331
349
  def is_archived(self) -> bool:
332
- return self.jira.project(self.project).archived
350
+ """Return whether the project is archived."""
351
+ return getattr(self.jira.project(self.project), "archived", False)
@@ -1,6 +1,5 @@
1
1
  import difflib
2
2
  import filecmp
3
- import json
4
3
  import logging
5
4
  import os
6
5
  import re
@@ -10,6 +9,7 @@ import tempfile
10
9
  import xml.etree.ElementTree as ET
11
10
  from collections.abc import Iterable, Mapping
12
11
  from os import path
12
+ from pathlib import Path
13
13
  from subprocess import (
14
14
  PIPE,
15
15
  STDOUT,
@@ -18,14 +18,14 @@ from subprocess import (
18
18
  from typing import Any
19
19
 
20
20
  import yaml
21
- from jenkins_jobs.builder import JenkinsManager
22
21
  from jenkins_jobs.errors import JenkinsJobsException
23
- from jenkins_jobs.parser import YamlParser
24
- from jenkins_jobs.registry import ModuleRegistry
22
+ from jenkins_jobs.loader import load_files
23
+ from jenkins_jobs.roots import Roots
25
24
  from sretoolbox.utils import retry
26
25
 
27
26
  from reconcile.utils import throughput
28
27
  from reconcile.utils.helpers import toggle_logger
28
+ from reconcile.utils.json import json_dumps
29
29
  from reconcile.utils.secret_reader import SecretReaderBase
30
30
  from reconcile.utils.state import State
31
31
  from reconcile.utils.vcs import GITHUB_BASE_URL
@@ -292,13 +292,10 @@ class JJB:
292
292
 
293
293
  args = ["--conf", ini_path, "test", config_path]
294
294
  jjb = self.get_jjb(args)
295
- builder = JenkinsManager(jjb.jjb_config)
296
- registry = ModuleRegistry(jjb.jjb_config, builder.plugins_list)
297
- parser = YamlParser(jjb.jjb_config)
298
- parser.load_files(jjb.options.path)
299
- jobs, _ = parser.expandYaml(registry, jjb.options.names)
300
-
301
- return jobs
295
+ roots = Roots(jjb.jjb_config)
296
+ load_files(jjb.jjb_config, roots, [Path(config_path)])
297
+ job_view_data_list = roots.generate_jobs()
298
+ return [job.data for job in job_view_data_list]
302
299
 
303
300
  def get_job_webhooks_data(self) -> dict[str, list[dict[str, Any]]]:
304
301
  job_webhooks_data: dict[str, list[dict[str, Any]]] = {}
@@ -399,7 +396,7 @@ class JJB:
399
396
  found = True
400
397
  if not found:
401
398
  raise ValueError(f"job name {job_name} is not found")
402
- print(json.dumps(all_jobs, indent=2))
399
+ print(json_dumps(all_jobs, indent=2))
403
400
 
404
401
  def get_job_by_repo_url(self, repo_url: str, job_type: str) -> dict[str, Any]:
405
402
  for jobs in self.get_all_jobs(job_types=[job_type]).values():
@@ -100,7 +100,7 @@ class K8sJobController:
100
100
  """
101
101
  new_cache = {}
102
102
  for item in self.oc.get_items(
103
- kind="Job",
103
+ kind="Job.batch",
104
104
  namespace=self.namespace,
105
105
  ):
106
106
  openshift_resource = OpenshiftResource(
@@ -38,6 +38,8 @@ class JobValidationError(Exception):
38
38
 
39
39
 
40
40
  JOB_GENERATION_ANNOTATION = "qontract-reconcile/job.generation"
41
+ MAX_JOB_NAME_LENGTH = 63
42
+ UNIT_OF_WORK_DIGEST_LENGTH = 10
41
43
 
42
44
 
43
45
  class K8sJob(ABC):
@@ -72,7 +74,21 @@ class K8sJob(ABC):
72
74
  """
73
75
 
74
76
  def name(self) -> str:
75
- return f"{self.name_prefix()}-{self.unit_of_work_digest()}"
77
+ """
78
+ Generate the full job name by combining the name prefix with a digest.
79
+
80
+ The name is constructed from the name_prefix (truncated to ensure total
81
+ length compliance) and the unit_of_work_digest. The total length is
82
+ limited to MAX_JOB_NAME_LENGTH (63 characters) to comply with Kubernetes
83
+ naming constraints.
84
+
85
+ Returns:
86
+ A unique job name in the format: {name_prefix}-{digest}
87
+ """
88
+ prefix = self.name_prefix()[
89
+ : MAX_JOB_NAME_LENGTH - UNIT_OF_WORK_DIGEST_LENGTH - 1
90
+ ]
91
+ return f"{prefix}-{self.unit_of_work_digest(UNIT_OF_WORK_DIGEST_LENGTH)}"
76
92
 
77
93
  @abstractmethod
78
94
  def name_prefix(self) -> str:
@@ -0,0 +1,32 @@
1
+ import json
2
+ from typing import Any
3
+
4
+ JSON_COMPACT_SEPARATORS = (",", ":")
5
+
6
+
7
+ def json_dumps(
8
+ data: Any,
9
+ *,
10
+ compact: bool = False,
11
+ indent: int | None = None,
12
+ cls: type[json.JSONEncoder] | None = None,
13
+ ) -> str:
14
+ """
15
+ Serialize `data` to a consistent JSON formatted `str` with dict keys sorted.
16
+
17
+ Args:
18
+ data: The data to serialize.
19
+ compact: If True, use compact separators (no spaces after commas or colons).
20
+ indent: If specified, pretty-print the JSON with this many spaces of indentation.
21
+ cls: A custom JSONEncoder subclass to use for serialization.
22
+ Returns:
23
+ A JSON formatted string.
24
+ """
25
+ separators = JSON_COMPACT_SEPARATORS if compact else None
26
+ return json.dumps(
27
+ data,
28
+ indent=indent,
29
+ separators=separators,
30
+ sort_keys=True,
31
+ cls=cls,
32
+ )
@@ -1,7 +1,7 @@
1
1
  import logging
2
2
  from abc import abstractmethod
3
3
  from dataclasses import dataclass
4
- from typing import Any, Generic, TypeVar
4
+ from typing import Any, TypeVar
5
5
 
6
6
  from gitlab.v4.objects import ProjectMergeRequest
7
7
  from pydantic import BaseModel
@@ -17,12 +17,12 @@ T = TypeVar("T", bound=BaseModel)
17
17
 
18
18
 
19
19
  @dataclass
20
- class OpenMergeRequest(Generic[T]):
20
+ class OpenMergeRequest[T: BaseModel]:
21
21
  raw: ProjectMergeRequest
22
22
  mr_info: T
23
23
 
24
24
 
25
- class MergeRequestManagerBase(Generic[T]):
25
+ class MergeRequestManagerBase[T: BaseModel]:
26
26
  """ """
27
27
 
28
28
  def __init__(self, vcs: VCS, parser: Parser, mr_label: str):
@@ -1,5 +1,5 @@
1
1
  import re
2
- from typing import Generic, TypeVar
2
+ from typing import TypeVar
3
3
 
4
4
  from pydantic import BaseModel
5
5
 
@@ -17,7 +17,7 @@ class ParserVersionError(Exception):
17
17
  T = TypeVar("T", bound=BaseModel)
18
18
 
19
19
 
20
- class Parser(Generic[T]):
20
+ class Parser[T: BaseModel]:
21
21
  """This class is only concerned with parsing an MR description rendered by the Renderer."""
22
22
 
23
23
  def __init__(
@@ -1,9 +1,9 @@
1
1
  from collections.abc import Iterable
2
- from datetime import datetime
3
2
  from pathlib import Path
4
3
 
5
4
  from ruamel.yaml.scalarstring import PreservedScalarString
6
5
 
6
+ from reconcile.utils.datetime_util import utc_now
7
7
  from reconcile.utils.gitlab_api import GitLabApi
8
8
  from reconcile.utils.mr.base import (
9
9
  MergeRequestBase,
@@ -26,7 +26,7 @@ class CreateAppInterfaceReporter(MergeRequestBase):
26
26
 
27
27
  self.labels = [AUTO_MERGE]
28
28
 
29
- now = datetime.now()
29
+ now = utc_now()
30
30
  self.isodate = now.isoformat()
31
31
  self.ts = now.strftime("%Y%m%d%H%M%S")
32
32
 
@@ -1,4 +1,3 @@
1
- import json
2
1
  import logging
3
2
  from abc import (
4
3
  ABC,
@@ -14,6 +13,7 @@ from jinja2 import Template
14
13
  from reconcile.gql_definitions.fragments.user import User
15
14
  from reconcile.utils.constants import PROJ_ROOT
16
15
  from reconcile.utils.gitlab_api import GitLabApi
16
+ from reconcile.utils.json import json_dumps
17
17
  from reconcile.utils.mr.labels import DO_NOT_MERGE_HOLD
18
18
  from reconcile.utils.sqs_gateway import SQSGateway
19
19
 
@@ -193,7 +193,7 @@ class MergeRequestBase(ABC):
193
193
  # logging this exception
194
194
  raise MergeRequestProcessingError(
195
195
  f"error processing {self.name} changes "
196
- f"{json.dumps(self.sqs_msg_data)} "
196
+ f"{json_dumps(self.sqs_msg_data)} "
197
197
  f"into temporary branch {self.branch}. "
198
198
  f"Reason: {err}"
199
199
  ) from err
@@ -1,9 +1,9 @@
1
1
  import logging
2
- from datetime import datetime
3
2
  from pathlib import Path
4
3
 
5
4
  from pydantic import BaseModel
6
5
 
6
+ from reconcile.utils.datetime_util import utc_now
7
7
  from reconcile.utils.gitlab_api import GitLabApi
8
8
  from reconcile.utils.mr.base import (
9
9
  MergeRequestBase,
@@ -58,7 +58,7 @@ class CreateAppInterfaceNotificator(MergeRequestBase):
58
58
  )
59
59
 
60
60
  def process(self, gitlab_cli: GitLabApi) -> None:
61
- now = datetime.now()
61
+ now = utc_now()
62
62
  ts = now.strftime("%Y%m%d%H%M%S")
63
63
  short_date = now.strftime("%Y-%m-%d")
64
64
 
@@ -1,14 +1,13 @@
1
1
  import logging
2
2
  from abc import abstractmethod
3
3
  from collections.abc import Sequence
4
- from datetime import UTC, date
5
- from datetime import datetime as dt
6
4
  from pathlib import Path
7
5
  from typing import TypeVar
8
6
 
9
7
  from jinja2 import Template
10
8
  from pydantic import BaseModel
11
9
 
10
+ from reconcile.utils.datetime_util import utc_now
12
11
  from reconcile.utils.gitlab_api import GitLabApi
13
12
  from reconcile.utils.mr.base import MergeRequestBase
14
13
  from reconcile.utils.mr.labels import AUTO_MERGE
@@ -27,7 +26,7 @@ class UpdateAccessReportBase(MergeRequestBase):
27
26
  self.labels = [AUTO_MERGE]
28
27
  self._users = users
29
28
  self._workbook_file_name = str(workbook_path)
30
- self._isodate = dt.now(tz=UTC).isoformat()
29
+ self._isodate = utc_now().isoformat()
31
30
  self._dry_run = dry_run
32
31
 
33
32
  @property
@@ -65,7 +64,7 @@ class UpdateAccessReportBase(MergeRequestBase):
65
64
 
66
65
  def _render_tracking_table_row(self, old_number_of_users: int) -> str:
67
66
  # | Date Reviewed | Number of Current Users | +/- Red Hat Users |
68
- return f"| {date.today()} | {len(self._users)} | {len(self._users) - old_number_of_users} |\n"
67
+ return f"| {utc_now().date()} | {len(self._users)} | {len(self._users) - old_number_of_users} |\n"
69
68
 
70
69
  def _update_workbook(self, workbook_md: str) -> str:
71
70
  new_workbook_md = ""