qontract-reconcile 0.10.2.dev226__py3-none-any.whl → 0.10.2.dev228__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: qontract-reconcile
3
- Version: 0.10.2.dev226
3
+ Version: 0.10.2.dev228
4
4
  Summary: Collection of tools to reconcile services with their desired state as defined in the app-interface DB.
5
5
  Project-URL: homepage, https://github.com/app-sre/qontract-reconcile
6
6
  Project-URL: repository, https://github.com/app-sre/qontract-reconcile
@@ -77,7 +77,7 @@ reconcile/openshift_rolebindings.py,sha256=9mlJ2FjWUoH-rsjtasreA_hV-K5Z_YR00qR_R
77
77
  reconcile/openshift_routes.py,sha256=fXvuPSjcjVw1X3j2EQvUAdbOepmIFdKk-M3qP8QzPiw,1075
78
78
  reconcile/openshift_saas_deploy.py,sha256=T1dvb9zajisaJNjbnR6-AZHU-itscHtr4oCqLj8KCK0,13037
79
79
  reconcile/openshift_saas_deploy_change_tester.py,sha256=12uyBwaeMka1C3_pejmQPIBPAx2V1sJ4dJkScq-2e2M,8793
80
- reconcile/openshift_saas_deploy_trigger_base.py,sha256=ftG8vqXCfaMUrkl1QqbPjnRpnQAmMIGCG0IT-YWAG6U,14366
80
+ reconcile/openshift_saas_deploy_trigger_base.py,sha256=fHCnlskcRKcitmIWWF3ePhKhQ0WlMeEaRDZM2IAW9BY,14649
81
81
  reconcile/openshift_saas_deploy_trigger_cleaner.py,sha256=roLyVAVntaQptKaZbnN1LyLvCA8fyvqELfjU6M8xfeY,3511
82
82
  reconcile/openshift_saas_deploy_trigger_configs.py,sha256=eUejMGWuaQabZTLuvPLLvROfN5HOFyYZOpH4YEsiU_g,928
83
83
  reconcile/openshift_saas_deploy_trigger_images.py,sha256=iUsiBGJf-CyFw7tSLWo59rXmSvsVnN6TTaAObbsVpNg,936
@@ -108,7 +108,7 @@ reconcile/slack_base.py,sha256=I-msunWxfgu5bSwXYulGbtLjxUB_tRmTCAUCU-3nabI,3484
108
108
  reconcile/slack_usergroups.py,sha256=xFkVe67RXSUj8JvpfSFEiRdQzB0TnJJEHW_b5PEwLng,30213
109
109
  reconcile/sql_query.py,sha256=auZCWe6dytsDp83Imfo4zqkpMCLRXU007IUlPeUE3j4,26376
110
110
  reconcile/status.py,sha256=cY4IJFXemhxptRJqR4qaaOWqei9e4jgLXuVSGajMsjg,544
111
- reconcile/status_board.py,sha256=kJ0bus_wdyX3zsFJuUPrH4n9BNG_jhDbiQ3waOLVRBE,8538
111
+ reconcile/status_board.py,sha256=Hxx6sFFyPTNi6zY-qlAco9OqPvdx6Kbgce0DunDUsNQ,15838
112
112
  reconcile/terraform_aws_route53.py,sha256=dQzzT46YhwRA902_H6pi-f7WlX4EaH187wXSdmJAUkQ,9958
113
113
  reconcile/terraform_cloudflare_dns.py,sha256=-aLEe2QnH5cJPu7HWqs-R9NmQ1NlFbcVUm0v7alVL3I,13431
114
114
  reconcile/terraform_cloudflare_resources.py,sha256=pq8Ieo5NmB-dYQ9X2F0s6iEoINMzhiqGw2yQK4ovok4,14980
@@ -740,7 +740,7 @@ reconcile/utils/ocm/products.py,sha256=Ki9o0VV4z_FsXQaJtSFzlUnxLvpk1H-RamvJpUwwb
740
740
  reconcile/utils/ocm/search_filters.py,sha256=uUCJ-XOEp4D5uxPW7lDqNe6s-mQWLOCqMu9_xvO6PXU,14798
741
741
  reconcile/utils/ocm/service_log.py,sha256=RG1f0MMn6joKaRCAm2xveSJCavdOPP1BVo9FXecDxaI,2018
742
742
  reconcile/utils/ocm/sre_capability_labels.py,sha256=nqh0imrYczNeeeC7ZNX3pEwuAIVkKLTKZf0YHSPZYpE,1537
743
- reconcile/utils/ocm/status_board.py,sha256=ycPWKNhz_YGNE-1g2tD5e_PaNG6UVw2BqIN7kOzG_Uc,2317
743
+ reconcile/utils/ocm/status_board.py,sha256=ptPRzwP1qjmg1d_ZD4X050gB6W2XNnJs8p7hc06tmbw,4142
744
744
  reconcile/utils/ocm/subscriptions.py,sha256=hehKXsDXIhnhqvWOuiYvx6y2FGq3zt0APGYj7WiBIdI,2765
745
745
  reconcile/utils/ocm/syncsets.py,sha256=9IQm1l5BodOVZa2OFbQmow3afmh4nXe5pn-CCJ5LxTI,1169
746
746
  reconcile/utils/ocm/upgrades.py,sha256=Hs3R9pkl5GoFgSyF7ZYhqG1gXOBCSGPL_ymDYAT3jXA,4804
@@ -803,7 +803,7 @@ tools/saas_promotion_state/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJ
803
803
  tools/saas_promotion_state/saas_promotion_state.py,sha256=UfwwRLS5Ya4_Nh1w5n1dvoYtchQvYE9yj1VANt2IKqI,3925
804
804
  tools/sre_checkpoints/__init__.py,sha256=CDaDaywJnmRCLyl_NCcvxi-Zc0hTi_3OdwKiFOyS39I,145
805
805
  tools/sre_checkpoints/util.py,sha256=zEDbGr18ZeHNQwW8pUsr2JRjuXIPz--WAGJxZo9sv_Y,894
806
- qontract_reconcile-0.10.2.dev226.dist-info/METADATA,sha256=bGNtcchcffnsilIn9PgEoNm2KuX18QtvVovsxSIbS1c,24431
807
- qontract_reconcile-0.10.2.dev226.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
808
- qontract_reconcile-0.10.2.dev226.dist-info/entry_points.txt,sha256=5i9l54La3vQrDLAdwDKQWC0iG4sV9RRfOb1BpvzOWLc,698
809
- qontract_reconcile-0.10.2.dev226.dist-info/RECORD,,
806
+ qontract_reconcile-0.10.2.dev228.dist-info/METADATA,sha256=5Vilv7XrctUYz2MggGRV5YpQQzRms_cQB_isU8a-HNE,24431
807
+ qontract_reconcile-0.10.2.dev228.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
808
+ qontract_reconcile-0.10.2.dev228.dist-info/entry_points.txt,sha256=5i9l54La3vQrDLAdwDKQWC0iG4sV9RRfOb1BpvzOWLc,698
809
+ qontract_reconcile-0.10.2.dev228.dist-info/RECORD,,
@@ -38,7 +38,7 @@ from reconcile.utils.saasherder import (
38
38
  TriggerSpecUnion,
39
39
  )
40
40
  from reconcile.utils.saasherder.interfaces import SaasPipelinesProviderTekton
41
- from reconcile.utils.saasherder.models import TriggerTypes
41
+ from reconcile.utils.saasherder.models import TriggerSpecConfig, TriggerTypes
42
42
  from reconcile.utils.secret_reader import create_secret_reader
43
43
  from reconcile.utils.sharding import is_in_shard
44
44
  from reconcile.utils.state import init_state
@@ -267,6 +267,10 @@ def _trigger_tekton(
267
267
  )
268
268
  return False
269
269
 
270
+ target_ref = None
271
+ if isinstance(spec, TriggerSpecConfig):
272
+ target_ref = spec.target_ref
273
+
270
274
  tkn_trigger_resource, tkn_name = _construct_tekton_trigger_resource(
271
275
  spec.saas_file_name,
272
276
  spec.env_name,
@@ -278,6 +282,7 @@ def _trigger_tekton(
278
282
  integration_version,
279
283
  saasherder.include_trigger_trace,
280
284
  spec.reason,
285
+ target_ref,
281
286
  )
282
287
 
283
288
  error = False
@@ -334,6 +339,7 @@ def _construct_tekton_trigger_resource(
334
339
  integration_version: str,
335
340
  include_trigger_trace: bool,
336
341
  reason: str | None,
342
+ target_ref: str | None,
337
343
  ) -> tuple[OR, str]:
338
344
  """Construct a resource (PipelineRun) to trigger a deployment via Tekton.
339
345
 
@@ -348,6 +354,7 @@ def _construct_tekton_trigger_resource(
348
354
  integration_version (string): Version of calling integration
349
355
  include_trigger_trace (bool): Should include traces of the triggering integration and reason
350
356
  reason (string): The reason this trigger was created
357
+ target_ref (string): the SHA ref of the target
351
358
 
352
359
  Returns:
353
360
  OpenshiftResource: OpenShift resource to be applied
@@ -385,6 +392,7 @@ def _construct_tekton_trigger_resource(
385
392
  "labels": {
386
393
  "qontract.saas_file_name": saas_file_name,
387
394
  "qontract.env_name": env_name,
395
+ "qontract.target_ref": target_ref or "",
388
396
  },
389
397
  },
390
398
  "spec": {
reconcile/status_board.py CHANGED
@@ -4,26 +4,36 @@ from abc import (
4
4
  abstractmethod,
5
5
  )
6
6
  from collections.abc import Iterable, Mapping
7
+ from enum import Enum
7
8
  from typing import (
8
- Any,
9
9
  Optional,
10
10
  )
11
11
 
12
12
  from pydantic import BaseModel
13
13
 
14
+ from reconcile.gql_definitions.slo_documents.slo_documents import SLODocumentV1
14
15
  from reconcile.gql_definitions.status_board.status_board import StatusBoardV1
16
+ from reconcile.typed_queries.slo_documents import get_slo_documents
15
17
  from reconcile.typed_queries.status_board import (
16
18
  get_selected_app_names,
17
19
  get_status_board,
18
20
  )
19
21
  from reconcile.utils.differ import diff_mappings
20
22
  from reconcile.utils.ocm.status_board import (
23
+ ApplicationOCMSpec,
24
+ BaseOCMSpec,
25
+ ServiceMetadataSpec,
26
+ ServiceOCMSpec,
21
27
  create_application,
22
28
  create_product,
29
+ create_service,
23
30
  delete_application,
24
31
  delete_product,
32
+ delete_service,
33
+ get_application_services,
25
34
  get_managed_products,
26
35
  get_product_applications,
36
+ update_service,
27
37
  )
28
38
  from reconcile.utils.ocm_base_client import (
29
39
  OCMBaseClient,
@@ -34,6 +44,12 @@ from reconcile.utils.runtime.integration import QontractReconcileIntegration
34
44
  QONTRACT_INTEGRATION = "status-board-exporter"
35
45
 
36
46
 
47
+ class Action(Enum):
48
+ create = "create"
49
+ update = "update"
50
+ delete = "delete"
51
+
52
+
37
53
  class AbstractStatusBoard(ABC, BaseModel):
38
54
  """Abstract class for upgrade policies
39
55
  Used to create and delete upgrade policies in OCM."""
@@ -41,12 +57,15 @@ class AbstractStatusBoard(ABC, BaseModel):
41
57
  id: str | None
42
58
  name: str
43
59
  fullname: str
44
- metadata: dict[str, Any] | None
45
60
 
46
61
  @abstractmethod
47
62
  def create(self, ocm: OCMBaseClient) -> None:
48
63
  pass
49
64
 
65
+ @abstractmethod
66
+ def update(self, ocm: OCMBaseClient) -> None:
67
+ pass
68
+
50
69
  @abstractmethod
51
70
  def delete(self, ocm: OCMBaseClient) -> None:
52
71
  pass
@@ -60,16 +79,28 @@ class AbstractStatusBoard(ABC, BaseModel):
60
79
  def get_priority() -> int:
61
80
  pass
62
81
 
82
+ @abstractmethod
83
+ def to_ocm_spec(self) -> BaseOCMSpec:
84
+ pass
85
+
86
+ def __eq__(self, other: object) -> bool:
87
+ if not isinstance(other, AbstractStatusBoard):
88
+ return NotImplemented
89
+ return self.name == other.name and self.fullname == other.fullname
90
+
63
91
 
64
92
  class Product(AbstractStatusBoard):
65
93
  applications: list["Application"] | None
66
94
 
67
95
  def create(self, ocm: OCMBaseClient) -> None:
68
- spec = self.dict(by_alias=True)
69
- spec.pop("applications")
70
- spec.pop("id")
96
+ spec = self.to_ocm_spec()
71
97
  self.id = create_product(ocm, spec)
72
98
 
99
+ def update(self, ocm: OCMBaseClient) -> None:
100
+ err_msg = "Called update on StatusBoardHandler that doesn't have update method"
101
+ logging.error(err_msg)
102
+ raise UpdateNotSupported(err_msg)
103
+
73
104
  def delete(self, ocm: OCMBaseClient) -> None:
74
105
  if not self.id:
75
106
  logging.error(f'Trying to delete Product "{self.name}" without id')
@@ -79,6 +110,12 @@ class Product(AbstractStatusBoard):
79
110
  def summarize(self) -> str:
80
111
  return f'Product: "{self.name}"'
81
112
 
113
+ def to_ocm_spec(self) -> BaseOCMSpec:
114
+ return {
115
+ "name": self.name,
116
+ "fullname": self.fullname,
117
+ }
118
+
82
119
  @staticmethod
83
120
  def get_priority() -> int:
84
121
  return 0
@@ -86,18 +123,20 @@ class Product(AbstractStatusBoard):
86
123
 
87
124
  class Application(AbstractStatusBoard):
88
125
  product: Optional["Product"]
126
+ services: list["Service"] | None
89
127
 
90
128
  def create(self, ocm: OCMBaseClient) -> None:
91
- spec = self.dict(by_alias=True)
92
- spec.pop("id")
93
- product = spec.pop("product")
94
- product_id = product.get("id")
95
- if product_id:
96
- spec["product"] = {"id": product_id}
129
+ if self.product and self.product.id:
130
+ spec = self.to_ocm_spec()
97
131
  self.id = create_application(ocm, spec)
98
132
  else:
99
133
  logging.warning("Missing product id for application")
100
134
 
135
+ def update(self, ocm: OCMBaseClient) -> None:
136
+ err_msg = "Called update on StatusBoardHandler that doesn't have update method"
137
+ logging.error(err_msg)
138
+ raise UpdateNotSupported(err_msg)
139
+
101
140
  def delete(self, ocm: OCMBaseClient) -> None:
102
141
  if not self.id:
103
142
  logging.error(f'Trying to delete Application "{self.name}" without id')
@@ -107,25 +146,110 @@ class Application(AbstractStatusBoard):
107
146
  def summarize(self) -> str:
108
147
  return f'Application: "{self.name}" "{self.fullname}"'
109
148
 
149
+ def to_ocm_spec(self) -> ApplicationOCMSpec:
150
+ product_id = self.product.id if self.product and self.product.id else ""
151
+ return {
152
+ "name": self.name,
153
+ "fullname": self.fullname,
154
+ "product_id": product_id,
155
+ }
156
+
110
157
  @staticmethod
111
158
  def get_priority() -> int:
112
159
  return 1
113
160
 
114
161
 
162
+ class Service(AbstractStatusBoard):
163
+ # `application` here is used to create a flat map to easily compare state.
164
+ # This field is optional so we can create the Service object without the
165
+ # need to create an Application object first.
166
+ # This filed is needed when we are creating a Service on teh OCM API.
167
+ # This field is not used when we are mapping the services that belongs to an
168
+ # application in that case we use the `services` field in Application class.
169
+ application: Optional["Application"]
170
+ metadata: ServiceMetadataSpec
171
+
172
+ def create(self, ocm: OCMBaseClient) -> None:
173
+ spec = self.to_ocm_spec()
174
+ if self.application and self.application.id:
175
+ self.id = create_service(ocm, spec)
176
+ else:
177
+ logging.warning("Missing application id for service")
178
+
179
+ def delete(self, ocm: OCMBaseClient) -> None:
180
+ if not self.id:
181
+ logging.error(f'Trying to delete Service "{self.name}" without id')
182
+ return
183
+ delete_service(ocm, self.id)
184
+
185
+ def update(self, ocm: OCMBaseClient) -> None:
186
+ if not self.id:
187
+ logging.error(f'Trying to update Service "{self.name}" without id')
188
+ return
189
+ spec = self.to_ocm_spec()
190
+ if self.application and self.application.id:
191
+ update_service(ocm, self.id, spec)
192
+ else:
193
+ logging.warning("Missing application id for service")
194
+
195
+ def summarize(self) -> str:
196
+ return f'Service: "{self.name}" "{self.fullname}"'
197
+
198
+ def to_ocm_spec(self) -> ServiceOCMSpec:
199
+ application_id = (
200
+ self.application.id if self.application and self.application.id else ""
201
+ )
202
+
203
+ return {
204
+ "name": self.name,
205
+ "fullname": self.fullname,
206
+ "metadata": self.metadata,
207
+ "status_type": "traffic_light",
208
+ "service_endpoint": "none",
209
+ "application_id": application_id,
210
+ }
211
+
212
+ @staticmethod
213
+ def get_priority() -> int:
214
+ return 2
215
+
216
+ def __eq__(self, other: object) -> bool:
217
+ if not isinstance(other, Service):
218
+ return NotImplemented
219
+ return (
220
+ self.name == other.name
221
+ and self.fullname == other.fullname
222
+ and self.metadata == other.metadata
223
+ )
224
+
225
+
226
+ # Resolve forward references after class definitions
227
+ Product.update_forward_refs()
228
+ Application.update_forward_refs()
229
+ Service.update_forward_refs()
230
+
231
+
232
+ class UpdateNotSupported(Exception):
233
+ pass
234
+
235
+
115
236
  class StatusBoardHandler(BaseModel):
116
- action: str
237
+ action: Action
117
238
  status_board_object: AbstractStatusBoard
118
239
 
119
240
  def act(self, dry_run: bool, ocm: OCMBaseClient) -> None:
120
241
  logging.info(f"{self.action} - {self.status_board_object.summarize()}")
242
+
121
243
  if dry_run:
122
244
  return
123
245
 
124
246
  match self.action:
125
- case "delete":
247
+ case Action.delete:
126
248
  self.status_board_object.delete(ocm)
127
- case "create":
249
+ case Action.create:
128
250
  self.status_board_object.create(ocm)
251
+ case Action.update:
252
+ self.status_board_object.update(ocm)
129
253
 
130
254
 
131
255
  class StatusBoardExporterIntegration(QontractReconcileIntegration):
@@ -146,7 +270,9 @@ class StatusBoardExporterIntegration(QontractReconcileIntegration):
146
270
  }
147
271
 
148
272
  @staticmethod
149
- def get_current_products_applications(ocm_api: OCMBaseClient) -> list[Product]:
273
+ def get_current_products_applications_services(
274
+ ocm_api: OCMBaseClient,
275
+ ) -> list[Product]:
150
276
  products_raw = get_managed_products(ocm_api)
151
277
  products = [Product(**p) for p in products_raw]
152
278
 
@@ -157,82 +283,135 @@ class StatusBoardExporterIntegration(QontractReconcileIntegration):
157
283
  p.applications = [
158
284
  Application(**a) for a in get_product_applications(ocm_api, p.id)
159
285
  ]
286
+ for a in p.applications:
287
+ if not a.id:
288
+ logging.error(f'Application "{a.name}" has no id')
289
+ continue
290
+ a.services = [
291
+ Service(**s) for s in get_application_services(ocm_api, a.id)
292
+ ]
160
293
 
161
294
  return products
162
295
 
163
296
  @staticmethod
164
- def get_diff(
165
- desired_product_apps: Mapping[str, set[str]],
166
- current_products_applications: Iterable[Product],
167
- ) -> list[StatusBoardHandler]:
168
- def create_app(app_name: str, product: Product) -> Application:
169
- return Application(
170
- name=app_name,
171
- fullname=f"{product.name}/{app_name}",
172
- product=product,
297
+ def desired_abstract_status_board_map(
298
+ desired_product_apps: Mapping[str, set[str]], slodocs: list[SLODocumentV1]
299
+ ) -> dict[str, AbstractStatusBoard]:
300
+ """
301
+ Returns a Mapping of all the AbstractStatusBoard data objects as dictionaries.
302
+ The key is formed by combining the Product, Application and Service name
303
+ separeted by a '/' character. This is the same format as the fullname property
304
+ on Status Board OCM API.
305
+ """
306
+ desired_abstract_status_board_map: dict[str, AbstractStatusBoard] = {}
307
+ for product, apps in desired_product_apps.items():
308
+ desired_abstract_status_board_map[product] = Product(
309
+ name=product, fullname=product, applications=[], metadata={}
173
310
  )
311
+ for a in apps:
312
+ key = f"{product}/{a}"
313
+ desired_abstract_status_board_map[key] = Application(
314
+ name=a,
315
+ fullname=key,
316
+ services=[],
317
+ product=desired_abstract_status_board_map[product],
318
+ metadata={},
319
+ )
320
+ for slodoc in slodocs:
321
+ products = [
322
+ ns.namespace.environment.product.name for ns in slodoc.namespaces
323
+ ]
324
+ for slo in slodoc.slos or []:
325
+ for product in products:
326
+ if slodoc.app.parent_app:
327
+ app = f"{slodoc.app.parent_app.name}-{slodoc.app.name}"
328
+ else:
329
+ app = slodoc.app.name
330
+
331
+ # Check if the product or app is excluded from the desired list
332
+ product_or_app_excluded = (
333
+ product not in desired_product_apps
334
+ or app not in desired_product_apps.get(product, set())
335
+ )
336
+
337
+ # Check if statusBoard label exists and is explicitly disabled
338
+ status_board_enabled = (
339
+ slodoc.labels is not None
340
+ and "statusBoard" in slodoc.labels
341
+ and slodoc.labels["statusBoard"] == "enabled"
342
+ )
343
+
344
+ if product_or_app_excluded or not status_board_enabled:
345
+ continue
346
+
347
+ key = f"{product}/{app}/{slo.name}"
348
+ metadata = {
349
+ "sli_type": slo.sli_type,
350
+ "sli_specification": slo.sli_specification,
351
+ "slo_details": slo.slo_details,
352
+ "target": slo.slo_target,
353
+ "target_unit": slo.slo_target_unit,
354
+ "window": slo.slo_parameters.window,
355
+ }
356
+ desired_abstract_status_board_map[key] = Service(
357
+ name=slo.name,
358
+ fullname=key,
359
+ metadata=metadata,
360
+ application=desired_abstract_status_board_map[
361
+ f"{product}/{app}"
362
+ ],
363
+ )
364
+
365
+ return desired_abstract_status_board_map
174
366
 
175
- return_list: list[StatusBoardHandler] = []
176
- current_products = {p.name: p for p in current_products_applications}
367
+ @staticmethod
368
+ def current_abstract_status_board_map(
369
+ current_products_applications_services: Iterable[Product],
370
+ ) -> dict[str, AbstractStatusBoard]:
371
+ return_value: dict[str, AbstractStatusBoard] = {}
372
+ for product in current_products_applications_services:
373
+ return_value[product.name] = product
374
+ for app in product.applications or []:
375
+ return_value[f"{product.name}/{app.name}"] = app
376
+ for service in app.services or []:
377
+ return_value[f"{product.name}/{app.name}/{service.name}"] = service
378
+
379
+ return return_value
177
380
 
178
- current_as_mapping: Mapping[str, set[str]] = {
179
- c.name: {a.name for a in c.applications or []}
180
- for c in current_products_applications
181
- }
381
+ @staticmethod
382
+ def get_diff(
383
+ desired_abstract_status_board_map: Mapping[str, AbstractStatusBoard],
384
+ current_abstract_status_board_map: Mapping[str, AbstractStatusBoard],
385
+ current_products: Mapping[str, Product],
386
+ ) -> list[StatusBoardHandler]:
387
+ return_list: list[StatusBoardHandler] = []
182
388
 
183
389
  diff_result = diff_mappings(
184
- current_as_mapping,
185
- desired_product_apps,
390
+ current_abstract_status_board_map,
391
+ desired_abstract_status_board_map,
186
392
  )
187
393
 
188
- for product_name in diff_result.add:
189
- product = Product(name=product_name, fullname=product_name, applications=[])
190
- return_list.append(
191
- StatusBoardHandler(
192
- action="create",
193
- status_board_object=product,
194
- )
195
- )
196
- # new product, so it misses also the applications
197
- return_list.extend(
198
- StatusBoardHandler(
199
- action="create",
200
- status_board_object=create_app(app_name, product),
201
- )
202
- for app_name in desired_product_apps[product_name]
203
- )
394
+ return_list.extend(
395
+ StatusBoardHandler(action=Action.create, status_board_object=o)
396
+ for o in diff_result.add.values()
397
+ )
204
398
 
205
- # existing product, only add/remove applications
206
- for product_name, apps in diff_result.change.items():
207
- product = current_products[product_name]
208
- return_list.extend(
209
- StatusBoardHandler(
210
- action="create",
211
- status_board_object=create_app(app_name, product),
212
- )
213
- for app_name in apps.desired - apps.current
214
- )
215
- to_delete = apps.current - apps.desired
216
- return_list.extend(
217
- StatusBoardHandler(
218
- action="delete",
219
- status_board_object=application,
220
- )
221
- for application in product.applications or []
222
- if application.name in to_delete
223
- )
399
+ return_list.extend(
400
+ StatusBoardHandler(action=Action.delete, status_board_object=o)
401
+ for o in diff_result.delete.values()
402
+ )
403
+
404
+ services_to_update = [
405
+ s.desired
406
+ for _, s in diff_result.change.items()
407
+ if isinstance(s.desired, Service)
408
+ ]
409
+
410
+ return_list.extend(
411
+ StatusBoardHandler(action=Action.update, status_board_object=s)
412
+ for s in services_to_update
413
+ )
224
414
 
225
- # product is deleted entirely
226
- for product_name in diff_result.delete:
227
- return_list.extend(
228
- StatusBoardHandler(action="delete", status_board_object=application)
229
- for application in current_products[product_name].applications or []
230
- )
231
- return_list.append(
232
- StatusBoardHandler(
233
- action="delete", status_board_object=current_products[product_name]
234
- )
235
- )
236
415
  return return_list
237
416
 
238
417
  @staticmethod
@@ -241,35 +420,54 @@ class StatusBoardExporterIntegration(QontractReconcileIntegration):
241
420
  ) -> None:
242
421
  creations: list[StatusBoardHandler] = []
243
422
  deletions: list[StatusBoardHandler] = []
423
+ updates: list[StatusBoardHandler] = []
244
424
 
245
425
  for o in diff:
246
426
  match o.action:
247
- case "create":
427
+ case Action.create:
248
428
  creations.append(o)
249
- case "delete":
429
+ case Action.delete:
250
430
  deletions.append(o)
431
+ case Action.update:
432
+ updates.append(o)
251
433
 
252
434
  # Products need to be created before Applications
435
+ # Applications need to be created before Services
253
436
  creations.sort(key=lambda x: x.status_board_object.get_priority())
254
437
 
438
+ # Services need to be deleted before Applications
255
439
  # Applications need to be deleted before Products
256
440
  deletions.sort(key=lambda x: x.status_board_object.get_priority(), reverse=True)
257
441
 
258
- for d in creations + deletions:
442
+ for d in creations + deletions + updates:
259
443
  d.act(dry_run, ocm_api)
260
444
 
261
445
  def run(self, dry_run: bool) -> None:
262
- # update cyclic reference
263
- Product.update_forward_refs()
264
-
446
+ slodocs = get_slo_documents()
265
447
  for sb in get_status_board():
266
448
  ocm_api = init_ocm_base_client(sb.ocm, self.secret_reader)
449
+
450
+ # Desired state
267
451
  desired_product_apps: dict[str, set[str]] = self.get_product_apps(sb)
452
+ desired_abstract_status_board_map = self.desired_abstract_status_board_map(
453
+ desired_product_apps, slodocs
454
+ )
268
455
 
269
- current_products_applications = self.get_current_products_applications(
270
- ocm_api
456
+ # Current state
457
+ current_products_applications_services = (
458
+ self.get_current_products_applications_services(ocm_api)
459
+ )
460
+ current_abstract_status_board_map = self.current_abstract_status_board_map(
461
+ current_products_applications_services
271
462
  )
272
463
 
273
- diff = self.get_diff(desired_product_apps, current_products_applications)
464
+ current_products = {
465
+ p.name: p for p in current_products_applications_services
466
+ }
467
+ diff = self.get_diff(
468
+ desired_abstract_status_board_map,
469
+ current_abstract_status_board_map,
470
+ current_products,
471
+ )
274
472
 
275
473
  self.apply_diff(dry_run, ocm_api, diff)
@@ -1,7 +1,8 @@
1
- from typing import Any
1
+ from typing import Any, TypedDict
2
2
 
3
3
  from reconcile.utils.ocm_base_client import OCMBaseClient
4
4
 
5
+ SERVICE_DESIRED_KEYS = {"id", "name", "fullname", "metadata"}
5
6
  APPLICATION_DESIRED_KEYS = {"id", "name", "fullname", "metadata"}
6
7
  PRODUCTS_DESIRED_KEYS = {"id", "name", "fullname", "metadata"}
7
8
 
@@ -10,6 +11,33 @@ METADATA_MANAGED_BY_KEY = "managedBy"
10
11
  METADATA_MANAGED_BY_VALUE = "qontract-reconcile"
11
12
 
12
13
 
14
+ class BaseOCMSpec(TypedDict):
15
+ name: str
16
+ fullname: str
17
+
18
+
19
+ class ApplicationOCMSpec(BaseOCMSpec):
20
+ product_id: str
21
+
22
+
23
+ class ServiceMetadataSpec(TypedDict):
24
+ sli_type: str
25
+ sli_specification: str
26
+ slo_details: str
27
+ target: float
28
+ target_unit: str
29
+ window: str
30
+
31
+
32
+ class ServiceOCMSpec(BaseOCMSpec):
33
+ # The next two fields come from the orignal script at
34
+ # https://gitlab.cee.redhat.com/service/status-board/-/blob/main/scripts/create-services-from-app-intf.sh?ref_type=heads#L116
35
+ status_type: str
36
+ service_endpoint: str
37
+ application_id: str
38
+ metadata: ServiceMetadataSpec
39
+
40
+
13
41
  def get_product_applications(
14
42
  ocm_api: OCMBaseClient, product_id: str
15
43
  ) -> list[dict[str, Any]]:
@@ -29,6 +57,25 @@ def get_product_applications(
29
57
  return results
30
58
 
31
59
 
60
+ def get_application_services(
61
+ ocm_api: OCMBaseClient, app_id: str
62
+ ) -> list[dict[str, Any]]:
63
+ results: list[dict[str, Any]] = []
64
+
65
+ for service in ocm_api.get_paginated(
66
+ f"/api/status-board/v1/applications/{app_id}/services"
67
+ ):
68
+ if (
69
+ service.get("metadata", {}).get(METADATA_MANAGED_BY_KEY, "")
70
+ == METADATA_MANAGED_BY_VALUE
71
+ ):
72
+ results.append({ # noqa: PERF401
73
+ k: v for k, v in service.items() if k in SERVICE_DESIRED_KEYS
74
+ })
75
+
76
+ return results
77
+
78
+
32
79
  def get_managed_products(ocm_api: OCMBaseClient) -> list[dict[str, Any]]:
33
80
  results: list[dict[str, Any]] = []
34
81
  for product in ocm_api.get_paginated("/api/status-board/v1/products/"):
@@ -42,25 +89,50 @@ def get_managed_products(ocm_api: OCMBaseClient) -> list[dict[str, Any]]:
42
89
  return results
43
90
 
44
91
 
45
- def create_product(ocm_api: OCMBaseClient, spec: dict[str, Any]) -> str:
46
- if "metadata" not in spec or spec["metadata"] is None:
47
- spec["metadata"] = {}
48
- spec["metadata"][METADATA_MANAGED_BY_KEY] = METADATA_MANAGED_BY_VALUE
49
- resp = ocm_api.post("/api/status-board/v1/products/", data=spec)
92
+ def create_product(ocm_api: OCMBaseClient, spec: BaseOCMSpec) -> str:
93
+ data = spec | {"metadata": {METADATA_MANAGED_BY_KEY: METADATA_MANAGED_BY_VALUE}}
94
+
95
+ resp = ocm_api.post("/api/status-board/v1/products/", data=data)
96
+ return resp["id"]
97
+
98
+
99
+ def create_application(ocm_api: OCMBaseClient, spec: ApplicationOCMSpec) -> str:
100
+ data = spec | {"metadata": {METADATA_MANAGED_BY_KEY: METADATA_MANAGED_BY_VALUE}}
101
+
102
+ resp = ocm_api.post("/api/status-board/v1/applications/", data=data)
50
103
  return resp["id"]
51
104
 
52
105
 
53
- def create_application(ocm_api: OCMBaseClient, spec: dict[str, Any]) -> str:
54
- if "metadata" not in spec or spec["metadata"] is None:
55
- spec["metadata"] = {}
56
- spec["metadata"][METADATA_MANAGED_BY_KEY] = METADATA_MANAGED_BY_VALUE
57
- resp = ocm_api.post("/api/status-board/v1/applications/", data=spec)
106
+ def create_service(ocm_api: OCMBaseClient, spec: ServiceOCMSpec) -> str:
107
+ data = spec | {
108
+ "metadata": spec["metadata"]
109
+ | {METADATA_MANAGED_BY_KEY: METADATA_MANAGED_BY_VALUE}
110
+ }
111
+
112
+ resp = ocm_api.post("/api/status-board/v1/services/", data=data)
58
113
  return resp["id"]
59
114
 
60
115
 
116
+ def update_service(
117
+ ocm_api: OCMBaseClient,
118
+ service_id: str,
119
+ spec: ServiceOCMSpec,
120
+ ) -> None:
121
+ data = spec | {
122
+ "metadata": spec["metadata"]
123
+ | {METADATA_MANAGED_BY_KEY: METADATA_MANAGED_BY_VALUE}
124
+ }
125
+
126
+ ocm_api.patch(f"/api/status-board/v1/services/{service_id}", data=data)
127
+
128
+
61
129
  def delete_product(ocm_api: OCMBaseClient, product_id: str) -> None:
62
130
  ocm_api.delete(f"/api/status-board/v1/products/{product_id}")
63
131
 
64
132
 
65
133
  def delete_application(ocm_api: OCMBaseClient, application_id: str) -> None:
66
134
  ocm_api.delete(f"/api/status-board/v1/applications/{application_id}")
135
+
136
+
137
+ def delete_service(ocm_api: OCMBaseClient, service_id: str) -> None:
138
+ ocm_api.delete(f"/api/status-board/v1/services/{service_id}")