qontract-reconcile 0.10.1rc802__py3-none-any.whl → 0.10.1rc803__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.1
2
2
  Name: qontract-reconcile
3
- Version: 0.10.1rc802
3
+ Version: 0.10.1rc803
4
4
  Summary: Collection of tools to reconcile services with their desired state as defined in the app-interface DB.
5
5
  Home-page: https://github.com/app-sre/qontract-reconcile
6
6
  Author: Red Hat App-SRE Team
@@ -433,6 +433,15 @@ reconcile/skupper_network/integration.py,sha256=178Q9RSYuZ9NmrCK4jRMLMekrewUaaRd
433
433
  reconcile/skupper_network/models.py,sha256=DNTI7HZv-rqY42GIIxyRuvroHLvdH6rJerjIq9lj3RU,6663
434
434
  reconcile/skupper_network/reconciler.py,sha256=XS-1oKBr_1l3dYUAVqUH6gCHg1G5ZuOfY_7fgGVAiFA,9996
435
435
  reconcile/skupper_network/site_controller.py,sha256=A3K-62BjJ5HiFVydV0ouGoD1NwrO7XhAH15BHAcS9fk,1550
436
+ reconcile/statuspage/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
437
+ reconcile/statuspage/atlassian.py,sha256=m8tRECVD_9KnIvqWs6pUocAnmfj2w_eDTFE4il7hkH8,15335
438
+ reconcile/statuspage/integration.py,sha256=hsazrQMceJbr61nEkJLxJbHhudTGtFuH0mlCo66-2ug,711
439
+ reconcile/statuspage/page.py,sha256=DB69C6yAfwZIdo4HBQi8BTUZXL8l9yOPnjnt3k_HZgk,3177
440
+ reconcile/statuspage/state.py,sha256=HD9EOoKm_nEqCMLIwW809En3cq5VhyzKJPUbsh-bae8,1617
441
+ reconcile/statuspage/status.py,sha256=mfRJ_tW7jM4_Vy_1cc8C0fKJEoA2GwrA3gJeV1KImAw,2834
442
+ reconcile/statuspage/integrations/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
443
+ reconcile/statuspage/integrations/components.py,sha256=49KHd_E9AdRvcEA6n75q1McZv2LfN-hRsW-WA7dgw9g,2651
444
+ reconcile/statuspage/integrations/maintenances.py,sha256=pwt3WHymfkqP7ox1GB-D6Wn0crmcrDE_p4SW9wRaqWE,2651
436
445
  reconcile/templates/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
437
446
  reconcile/templates/aws_access_key_email.j2,sha256=2MUr1ERmyISzKgHqsWYLd-1Wbl-peUa-FsGUS-JLUFc,238
438
447
  reconcile/templates/email.yml.j2,sha256=OZgczNRgXPj2gVYTgwQyHAQrMGu7xp-e4W1rX19GcrU,690
@@ -788,8 +797,8 @@ tools/test/test_app_interface_metrics_exporter.py,sha256=SX7qL3D1SIRKFo95FoQztvf
788
797
  tools/test/test_qontract_cli.py,sha256=_D61RFGAN5x44CY1tYbouhlGXXABwYfxKSWSQx3Jrss,4941
789
798
  tools/test/test_sd_app_sre_alert_report.py,sha256=v363r9zM7__0kR5K6mvJoGFcM9BvE33fWAayrqkpojA,2116
790
799
  tools/test/test_sre_checkpoints.py,sha256=SKqPPTl9ua0RFdSSofnoQX-JZE6dFLO3LRhfQzqtfh8,2607
791
- qontract_reconcile-0.10.1rc802.dist-info/METADATA,sha256=ZX90nMGQNMCTXIBib8Objt33uoTAraXDTmTSXq6tdtQ,2314
792
- qontract_reconcile-0.10.1rc802.dist-info/WHEEL,sha256=GJ7t_kWBFywbagK5eo9IoUwLW6oyOeTKmQ-9iHFVNxQ,92
793
- qontract_reconcile-0.10.1rc802.dist-info/entry_points.txt,sha256=rIxI5zWtHNlfpDeq1a7pZXAPoqf7HG32KMTN3MeWK_8,429
794
- qontract_reconcile-0.10.1rc802.dist-info/top_level.txt,sha256=l5ISPoXzt0SdR4jVdkfa7RPSKNc8zAHYWAnR-Dw8Ey8,24
795
- qontract_reconcile-0.10.1rc802.dist-info/RECORD,,
800
+ qontract_reconcile-0.10.1rc803.dist-info/METADATA,sha256=NTfgWm43JS7pKaeQtl4uuf73kEQxGqfhtxg1alCle_E,2314
801
+ qontract_reconcile-0.10.1rc803.dist-info/WHEEL,sha256=GJ7t_kWBFywbagK5eo9IoUwLW6oyOeTKmQ-9iHFVNxQ,92
802
+ qontract_reconcile-0.10.1rc803.dist-info/entry_points.txt,sha256=rIxI5zWtHNlfpDeq1a7pZXAPoqf7HG32KMTN3MeWK_8,429
803
+ qontract_reconcile-0.10.1rc803.dist-info/top_level.txt,sha256=l5ISPoXzt0SdR4jVdkfa7RPSKNc8zAHYWAnR-Dw8Ey8,24
804
+ qontract_reconcile-0.10.1rc803.dist-info/RECORD,,
File without changes
@@ -0,0 +1,425 @@
1
+ import logging
2
+ import time
3
+ from typing import (
4
+ Any,
5
+ Optional,
6
+ Self,
7
+ )
8
+
9
+ import requests
10
+ from pydantic import BaseModel
11
+ from requests import Response
12
+ from sretoolbox.utils import retry
13
+
14
+ from reconcile.gql_definitions.statuspage.statuspages import StatusPageV1
15
+ from reconcile.statuspage.page import (
16
+ StatusComponent,
17
+ StatusMaintenance,
18
+ StatusPage,
19
+ )
20
+ from reconcile.statuspage.state import ComponentBindingState
21
+ from reconcile.statuspage.status import ManualStatusProvider
22
+
23
+
24
+ class AtlassianRawComponent(BaseModel):
25
+ """
26
+ atlassian status page REST schema for component
27
+ """
28
+
29
+ id: str
30
+ name: str
31
+ description: Optional[str]
32
+ position: int
33
+ status: str
34
+ automation_email: Optional[str]
35
+ group_id: Optional[str]
36
+ group: Optional[bool]
37
+
38
+
39
+ class AtlassianRawMaintenanceUpdate(BaseModel):
40
+ """
41
+ atlassian status page REST schema for maintenance updates
42
+ """
43
+
44
+ body: str
45
+
46
+
47
+ class AtlassianRawMaintenance(BaseModel):
48
+ """
49
+ atlassian status page REST schema for maintenance
50
+ """
51
+
52
+ id: str
53
+ name: str
54
+ scheduled_for: str
55
+ scheduled_until: str
56
+ incident_updates: list[AtlassianRawMaintenanceUpdate]
57
+
58
+
59
+ class AtlassianAPI:
60
+ """
61
+ This API class wraps the statuspageio REST API for basic component operations.
62
+ """
63
+
64
+ def __init__(self, page_id: str, api_url: str, token: str):
65
+ self.page_id = page_id
66
+ self.api_url = api_url
67
+ self.token = token
68
+ self.auth_headers = {"Authorization": f"OAuth {self.token}"}
69
+
70
+ @retry(max_attempts=10)
71
+ def _do_get(self, url: str, params: dict[str, Any]) -> Response:
72
+ response = requests.get(
73
+ url, params=params, headers=self.auth_headers, timeout=30
74
+ )
75
+ response.raise_for_status()
76
+ return response
77
+
78
+ def _list_items(self, url: str) -> list[Any]:
79
+ all_items: list[Any] = []
80
+ page = 1
81
+ per_page = 100
82
+ while True:
83
+ params = {"page": page, "per_page": per_page}
84
+ response = self._do_get(url, params=params)
85
+ items = response.json()
86
+ all_items += items
87
+ if len(items) < per_page:
88
+ break
89
+ page += 1
90
+ # https://developer.statuspage.io/#section/Rate-Limiting
91
+ # Each API token is limited to 1 request / second as measured on a 60 second rolling window
92
+ time.sleep(1)
93
+
94
+ return all_items
95
+
96
+ def list_components(self) -> list[AtlassianRawComponent]:
97
+ url = f"{self.api_url}/v1/pages/{self.page_id}/components"
98
+ all_components = self._list_items(url)
99
+ return [AtlassianRawComponent(**c) for c in all_components]
100
+
101
+ def update_component(self, id: str, data: dict[str, Any]) -> None:
102
+ url = f"{self.api_url}/v1/pages/{self.page_id}/components/{id}"
103
+ requests.patch(
104
+ url, json={"component": data}, headers=self.auth_headers
105
+ ).raise_for_status()
106
+
107
+ def create_component(self, data: dict[str, Any]) -> str:
108
+ url = f"{self.api_url}/v1/pages/{self.page_id}/components"
109
+ response = requests.post(
110
+ url, json={"component": data}, headers=self.auth_headers
111
+ )
112
+ response.raise_for_status()
113
+ return response.json()["id"]
114
+
115
+ def delete_component(self, id: str) -> None:
116
+ url = f"{self.api_url}/v1/pages/{self.page_id}/components/{id}"
117
+ requests.delete(url, headers=self.auth_headers).raise_for_status()
118
+
119
+ def list_scheduled_maintenances(self) -> list[AtlassianRawMaintenance]:
120
+ url = f"{self.api_url}/v1/pages/{self.page_id}/incidents/scheduled"
121
+ all_scheduled_incidents = self._list_items(url)
122
+ return [AtlassianRawMaintenance(**i) for i in all_scheduled_incidents]
123
+
124
+ def create_incident(self, data: dict[str, Any]) -> str:
125
+ url = f"{self.api_url}/v1/pages/{self.page_id}/incidents"
126
+ response = requests.post(
127
+ url, json={"incident": data}, headers=self.auth_headers
128
+ )
129
+ response.raise_for_status()
130
+ return response.json()["id"]
131
+
132
+
133
+ class AtlassianStatusPageProvider:
134
+ """
135
+ The provider implements CRUD operations for Atlassian status pages.
136
+ It also takes care of a mixed set of components on a page, where some
137
+ components are managed by app-interface and some are managed manually
138
+ by various teams. The term `bound` used throughout the code refers to
139
+ components that are managed by app-interface. The binding status is
140
+ managed by the injected `ComponentBindingState` instance and persists
141
+ the binding information (what app-interface component name is bound to
142
+ what status page component id).
143
+ """
144
+
145
+ def __init__(
146
+ self,
147
+ page_name: str,
148
+ api: AtlassianAPI,
149
+ component_binding_state: ComponentBindingState,
150
+ ):
151
+ self.page_name = page_name
152
+ self._api = api
153
+ self._binding_state = component_binding_state
154
+
155
+ # component cache
156
+ self._components: list[AtlassianRawComponent] = []
157
+ self._components_by_id: dict[str, AtlassianRawComponent] = {}
158
+ self._components_by_displayname: dict[str, AtlassianRawComponent] = {}
159
+ self._group_name_to_id: dict[str, str] = {}
160
+ self._group_id_to_name: dict[str, str] = {}
161
+ self._build_component_cache()
162
+
163
+ def _build_component_cache(self):
164
+ self._components = self._api.list_components()
165
+ self._components_by_id = {c.id: c for c in self._components}
166
+ self._components_by_displayname = {c.name: c for c in self._components}
167
+ self._group_name_to_id = {g.name: g.id for g in self._components if g.group}
168
+ self._group_id_to_name = {g.id: g.name for g in self._components if g.group}
169
+
170
+ def get_component_by_id(self, id: str) -> Optional[StatusComponent]:
171
+ raw = self.get_raw_component_by_id(id)
172
+ if raw:
173
+ return self._bound_raw_component_to_status_component(raw)
174
+ return None
175
+
176
+ def get_raw_component_by_id(self, id: str) -> Optional[AtlassianRawComponent]:
177
+ return self._components_by_id.get(id)
178
+
179
+ def get_current_page(self) -> StatusPage:
180
+ """
181
+ Builds a StatusPage instance from the current state of the page. This
182
+ way the current state of the page can be compared to the desired state
183
+ of the page coming from GQL.
184
+ """
185
+ components = [
186
+ self._bound_raw_component_to_status_component(c) for c in self._components
187
+ ]
188
+ return StatusPage(
189
+ name=self.page_name,
190
+ components=[c for c in components if c is not None],
191
+ )
192
+
193
+ def _bound_raw_component_to_status_component(
194
+ self, raw_component: AtlassianRawComponent
195
+ ) -> Optional[StatusComponent]:
196
+ bound_component_name = self._binding_state.get_name_for_component_id(
197
+ raw_component.id
198
+ )
199
+ if bound_component_name:
200
+ group_name = (
201
+ self._group_id_to_name.get(raw_component.group_id)
202
+ if raw_component.group_id
203
+ else None
204
+ )
205
+ return StatusComponent(
206
+ name=bound_component_name,
207
+ display_name=raw_component.name,
208
+ description=raw_component.description,
209
+ group_name=group_name,
210
+ status_provider_configs=[
211
+ ManualStatusProvider(
212
+ component_status=raw_component.status,
213
+ )
214
+ ],
215
+ )
216
+ return None
217
+
218
+ def lookup_component(
219
+ self, desired_component: StatusComponent
220
+ ) -> tuple[Optional[AtlassianRawComponent], bool]:
221
+ """
222
+ Finds the component on the page that matches the desired component. This
223
+ is either done explicitely by using binding information if available or
224
+ by using the display name of the desired component to find a matching
225
+ component on the page. This way, this provider offers adoption logic
226
+ for existing components on the page that are not yes bound to app-interface.
227
+ """
228
+ component_id = self._binding_state.get_id_for_component_name(
229
+ desired_component.name
230
+ )
231
+ component = None
232
+ bound = True
233
+ if component_id:
234
+ component = self.get_raw_component_by_id(component_id)
235
+
236
+ if component is None:
237
+ bound = False
238
+ # either the component name is not bound to an ID or for whatever
239
+ # reason or the component is not found on the page anymore
240
+ component = self._components_by_displayname.get(
241
+ desired_component.display_name
242
+ )
243
+ if component and self._binding_state.get_name_for_component_id(
244
+ component.id
245
+ ):
246
+ # this component is already bound to a different component
247
+ # in app-interface. we are protecting this binding here by
248
+ # not allowing this component to be found via display name
249
+ component = None
250
+
251
+ return component, bound
252
+
253
+ def should_apply(
254
+ self, desired: StatusComponent, current: Optional[AtlassianRawComponent]
255
+ ) -> bool:
256
+ """
257
+ Verifies if the desired component should be applied to the status page
258
+ when compared to the current state of the component on the page.
259
+ """
260
+ current_group_name = (
261
+ self._group_id_to_name.get(current.group_id)
262
+ if current and current.group_id
263
+ else None
264
+ )
265
+
266
+ # check if group exists
267
+ group_id = None
268
+ if desired.group_name:
269
+ group_id = self._group_name_to_id.get(desired.group_name, None)
270
+ if not group_id:
271
+ raise ValueError(
272
+ f"Group {desired.group_name} referenced "
273
+ f"by {desired.name} does not exist"
274
+ )
275
+
276
+ # Special handling if a component needs to be moved out of any grouping.
277
+ # We would need to use the component_group endpoint but for not lets
278
+ # ignore this situation.
279
+ if current and current_group_name and not desired.group_name:
280
+ raise ValueError(
281
+ f"Remove grouping from the component "
282
+ f"{desired.group_name} is currently unsupported"
283
+ )
284
+
285
+ # component status
286
+ desired_component_status = desired.desired_component_status()
287
+ status_update_required = desired_component_status is not None and (
288
+ not current or desired_component_status != current.status
289
+ )
290
+
291
+ # shortcut execution if there is nothing to do
292
+ update_required = (
293
+ current is None
294
+ or desired.display_name != current.name
295
+ or desired.description != current.description
296
+ or desired.group_name != current_group_name
297
+ or status_update_required
298
+ )
299
+ return update_required
300
+
301
+ def apply_component(self, dry_run: bool, desired: StatusComponent) -> None:
302
+ current_component, bound = self.lookup_component(desired)
303
+
304
+ # if the component is not yet bound to a statuspage component, bind it now
305
+ if current_component and not bound:
306
+ self._bind_component(
307
+ dry_run=dry_run,
308
+ component_name=desired.name,
309
+ component_id=current_component.id,
310
+ )
311
+
312
+ # validte the component and check if the current state needs to be updated
313
+ needs_update = self.should_apply(desired, current_component)
314
+ if not needs_update:
315
+ return
316
+
317
+ # calculate update
318
+ component_update = {
319
+ "name": desired.display_name,
320
+ "description": desired.description,
321
+ }
322
+
323
+ # resolve group
324
+ group_id = (
325
+ self._group_name_to_id.get(desired.group_name, None)
326
+ if desired.group_name
327
+ else None
328
+ )
329
+ if group_id:
330
+ component_update["group_id"] = group_id
331
+
332
+ # resolve status
333
+ desired_component_status = desired.desired_component_status()
334
+ if desired_component_status:
335
+ component_update["status"] = desired_component_status
336
+
337
+ if current_component:
338
+ logging.info(f"update component {desired.name}: {component_update}")
339
+ if not dry_run:
340
+ self._api.update_component(current_component.id, component_update)
341
+ else:
342
+ logging.info(f"create component {desired.name}: {component_update}")
343
+ if not dry_run:
344
+ component_id = self._api.create_component(component_update)
345
+ self._bind_component(
346
+ dry_run=dry_run,
347
+ component_name=desired.name,
348
+ component_id=component_id,
349
+ )
350
+
351
+ def delete_component(self, dry_run: bool, component_name: str) -> None:
352
+ component_id = self._binding_state.get_id_for_component_name(component_name)
353
+ if component_id:
354
+ if not dry_run:
355
+ self._api.delete_component(component_id)
356
+ self._binding_state.forget_component(component_name)
357
+ self._build_component_cache()
358
+ else:
359
+ logging.warning(
360
+ f"can't delete component {component_name} because it is not "
361
+ f"bound to any component on page {self.page_name}"
362
+ )
363
+
364
+ def has_component_binding_for(self, component_name: str) -> bool:
365
+ return self._binding_state.get_id_for_component_name(component_name) is not None
366
+
367
+ def _bind_component(
368
+ self,
369
+ dry_run: bool,
370
+ component_name: str,
371
+ component_id: str,
372
+ ) -> None:
373
+ logging.info(
374
+ f"bind component {component_name} to ID {component_id} "
375
+ f"on page {self.page_name}"
376
+ )
377
+ if not dry_run:
378
+ self._binding_state.bind_component(component_name, component_id)
379
+
380
+ @classmethod
381
+ def init_from_page(
382
+ cls,
383
+ page: StatusPageV1,
384
+ token: str,
385
+ component_binding_state: ComponentBindingState,
386
+ ) -> Self:
387
+ """
388
+ Initializes the provider for atlassian status page.
389
+ """
390
+ return cls(
391
+ page_name=page.name,
392
+ api=AtlassianAPI(
393
+ page_id=page.page_id,
394
+ api_url=page.api_url,
395
+ token=token,
396
+ ),
397
+ component_binding_state=component_binding_state,
398
+ )
399
+
400
+ @property
401
+ def maintenances(self) -> list[StatusMaintenance]:
402
+ return [
403
+ StatusMaintenance(
404
+ name=m.name,
405
+ message=m.incident_updates[0].body,
406
+ schedule_start=m.scheduled_for,
407
+ schedule_end=m.scheduled_until,
408
+ )
409
+ for m in self._api.list_scheduled_maintenances()
410
+ ]
411
+
412
+ def create_maintenance(self, maintenance: StatusMaintenance) -> None:
413
+ data = {
414
+ "name": maintenance.name,
415
+ "status": "scheduled",
416
+ "scheduled_for": maintenance.schedule_start,
417
+ "scheduled_until": maintenance.schedule_end,
418
+ "body": maintenance.message,
419
+ }
420
+ incident_id = self._api.create_incident(data)
421
+ self._bind_component(
422
+ dry_run=False,
423
+ component_name=maintenance.name,
424
+ component_id=incident_id,
425
+ )
@@ -0,0 +1,22 @@
1
+ from reconcile.gql_definitions.statuspage import statuspages
2
+ from reconcile.gql_definitions.statuspage.statuspages import StatusPageV1
3
+ from reconcile.statuspage.state import S3ComponentBindingState
4
+ from reconcile.utils import gql
5
+ from reconcile.utils.secret_reader import (
6
+ SecretReaderBase,
7
+ )
8
+ from reconcile.utils.state import init_state
9
+
10
+
11
+ def get_status_pages() -> list[StatusPageV1]:
12
+ return statuspages.query(gql.get_api().query).status_pages or []
13
+
14
+
15
+ def get_binding_state(
16
+ integration: str, secret_reader: SecretReaderBase
17
+ ) -> S3ComponentBindingState:
18
+ state = init_state(
19
+ integration=integration,
20
+ secret_reader=secret_reader,
21
+ )
22
+ return S3ComponentBindingState(state)
File without changes
@@ -0,0 +1,77 @@
1
+ import logging
2
+ import sys
3
+
4
+ from reconcile.statuspage.atlassian import AtlassianStatusPageProvider
5
+ from reconcile.statuspage.integration import get_binding_state, get_status_pages
6
+ from reconcile.statuspage.page import StatusPage
7
+ from reconcile.utils.runtime.integration import (
8
+ NoParams,
9
+ QontractReconcileIntegration,
10
+ )
11
+ from reconcile.utils.semver_helper import make_semver
12
+
13
+ QONTRACT_INTEGRATION = "status-page-components"
14
+ QONTRACT_INTEGRATION_VERSION = make_semver(0, 1, 0)
15
+
16
+
17
+ class StatusPageComponentsIntegration(QontractReconcileIntegration[NoParams]):
18
+ def __init__(self) -> None:
19
+ super().__init__(NoParams())
20
+
21
+ @property
22
+ def name(self) -> str:
23
+ return QONTRACT_INTEGRATION
24
+
25
+ def reconcile(
26
+ self,
27
+ dry_run: bool,
28
+ desired_state: StatusPage,
29
+ current_state: StatusPage,
30
+ provider: AtlassianStatusPageProvider,
31
+ ) -> None:
32
+ """
33
+ Reconcile the desired state with the current state of a status page.
34
+ """
35
+ #
36
+ # D E L E T E
37
+ #
38
+ desired_component_names = {c.name for c in desired_state.components}
39
+ current_component_names = {c.name for c in current_state.components}
40
+ component_names_to_delete = current_component_names - desired_component_names
41
+ for component_name in component_names_to_delete:
42
+ logging.info(
43
+ f"delete component {component_name} from page {desired_state.name}"
44
+ )
45
+ provider.delete_component(dry_run, component_name)
46
+
47
+ #
48
+ # C R E A T E OR U P D A T E
49
+ #
50
+ for desired in desired_state.components:
51
+ provider.apply_component(dry_run, desired)
52
+
53
+ def run(self, dry_run: bool = False) -> None:
54
+ binding_state = get_binding_state(self.name, self.secret_reader)
55
+ pages = get_status_pages()
56
+
57
+ error = False
58
+ for p in pages:
59
+ try:
60
+ desired_state = StatusPage.init_from_page(p)
61
+ page_provider = AtlassianStatusPageProvider.init_from_page(
62
+ page=p,
63
+ token=self.secret_reader.read_secret(p.credentials),
64
+ component_binding_state=binding_state,
65
+ )
66
+ self.reconcile(
67
+ dry_run,
68
+ desired_state=desired_state,
69
+ current_state=page_provider.get_current_page(),
70
+ provider=page_provider,
71
+ )
72
+ except Exception:
73
+ logging.exception(f"failed to reconcile statuspage {p.name}")
74
+ error = True
75
+
76
+ if error:
77
+ sys.exit(1)
@@ -0,0 +1,73 @@
1
+ import logging
2
+ import sys
3
+
4
+ from reconcile.statuspage.atlassian import AtlassianStatusPageProvider
5
+ from reconcile.statuspage.integration import get_binding_state, get_status_pages
6
+ from reconcile.statuspage.page import StatusMaintenance
7
+ from reconcile.utils.differ import diff_iterables
8
+ from reconcile.utils.runtime.integration import (
9
+ NoParams,
10
+ QontractReconcileIntegration,
11
+ )
12
+ from reconcile.utils.semver_helper import make_semver
13
+
14
+ QONTRACT_INTEGRATION = "status-page-maintenances"
15
+ QONTRACT_INTEGRATION_VERSION = make_semver(0, 1, 0)
16
+
17
+
18
+ class StatusPageMaintenancesIntegration(QontractReconcileIntegration[NoParams]):
19
+ @property
20
+ def name(self) -> str:
21
+ return QONTRACT_INTEGRATION
22
+
23
+ def reconcile(
24
+ self,
25
+ dry_run: bool,
26
+ desired_state: list[StatusMaintenance],
27
+ current_state: list[StatusMaintenance],
28
+ provider: AtlassianStatusPageProvider,
29
+ ) -> None:
30
+ diff = diff_iterables(
31
+ current=current_state, desired=desired_state, key=lambda x: x.name
32
+ )
33
+ for a in diff.add.values():
34
+ logging.info(f"Create StatusPage Maintenance: {a.name}")
35
+ if not dry_run:
36
+ provider.create_maintenance(a)
37
+ for c in diff.change.values():
38
+ raise NotImplementedError(
39
+ f"Update StatusPage Maintenance is not supported at this time: {c.desired.name}"
40
+ )
41
+ for d in diff.delete.values():
42
+ raise NotImplementedError(
43
+ f"Delete StatusPage Maintenance is not supported at this time: {d.name}"
44
+ )
45
+
46
+ def run(self, dry_run: bool = False) -> None:
47
+ binding_state = get_binding_state(self.name, self.secret_reader)
48
+ pages = get_status_pages()
49
+
50
+ error = False
51
+ for p in pages:
52
+ try:
53
+ desired_state = [
54
+ StatusMaintenance.init_from_maintenance(m)
55
+ for m in p.maintenances or []
56
+ ]
57
+ page_provider = AtlassianStatusPageProvider.init_from_page(
58
+ page=p,
59
+ token=self.secret_reader.read_secret(p.credentials),
60
+ component_binding_state=binding_state,
61
+ )
62
+ self.reconcile(
63
+ dry_run=dry_run,
64
+ desired_state=desired_state,
65
+ current_state=page_provider.maintenances,
66
+ provider=page_provider,
67
+ )
68
+ except Exception:
69
+ logging.exception(f"failed to reconcile statuspage {p.name}")
70
+ error = True
71
+
72
+ if error:
73
+ sys.exit(1)
@@ -0,0 +1,114 @@
1
+ from typing import Optional, Self
2
+
3
+ from pydantic import BaseModel
4
+
5
+ from reconcile.gql_definitions.statuspage.statuspages import (
6
+ MaintenanceV1,
7
+ StatusPageComponentV1,
8
+ StatusPageV1,
9
+ )
10
+ from reconcile.statuspage.status import (
11
+ StatusProvider,
12
+ build_status_provider_config,
13
+ )
14
+
15
+
16
+ class StatusComponent(BaseModel):
17
+ """
18
+ Represents a status page component from the desired state.
19
+ """
20
+
21
+ name: str
22
+ display_name: str
23
+ description: Optional[str]
24
+ group_name: Optional[str]
25
+ status_provider_configs: list[StatusProvider]
26
+ """
27
+ Status provider configs hold different ways for a component to determine its status
28
+ """
29
+
30
+ def status_management_enabled(self) -> bool:
31
+ """
32
+ Determines if this component has any status configurations available for
33
+ it to be able to manage its status.
34
+ """
35
+ return bool(self.status_provider_configs)
36
+
37
+ def desired_component_status(self) -> Optional[str]:
38
+ if self.status_management_enabled():
39
+ for provider in self.status_provider_configs:
40
+ status = provider.get_status()
41
+ if status:
42
+ return status
43
+ return "operational"
44
+ return None
45
+
46
+ class Config:
47
+ arbitrary_types_allowed = True
48
+
49
+ @classmethod
50
+ def init_from_page_component(cls, component: StatusPageComponentV1) -> Self:
51
+ status_configs = [
52
+ build_status_provider_config(cfg) for cfg in component.status_config or []
53
+ ]
54
+ return cls(
55
+ name=component.name,
56
+ display_name=component.display_name,
57
+ description=component.description,
58
+ group_name=component.group_name,
59
+ status_provider_configs=[c for c in status_configs if c is not None],
60
+ )
61
+
62
+
63
+ class StatusPage(BaseModel):
64
+ """
65
+ Represents the desired state of a status page and its components.
66
+ """
67
+
68
+ name: str
69
+ """
70
+ The name of the status page.
71
+ """
72
+
73
+ components: list[StatusComponent]
74
+ """
75
+ The desired components of the status page are represented in this list.
76
+ Important note: the actual status page might have more components than
77
+ this desired state does. People can still manage components manually.
78
+ """
79
+
80
+ @classmethod
81
+ def init_from_page(
82
+ cls,
83
+ page: StatusPageV1,
84
+ ) -> Self:
85
+ """
86
+ Translate a desired state status page into a status page object.
87
+ """
88
+ return cls(
89
+ name=page.name,
90
+ components=[
91
+ StatusComponent.init_from_page_component(component=c)
92
+ for c in page.components or []
93
+ ],
94
+ )
95
+
96
+
97
+ class StatusMaintenance(BaseModel):
98
+ """
99
+ Represents the desired state of a status maintenance.
100
+ """
101
+
102
+ name: str
103
+ message: str
104
+ schedule_start: str
105
+ schedule_end: str
106
+
107
+ @classmethod
108
+ def init_from_maintenance(cls, maintenance: MaintenanceV1) -> Self:
109
+ return cls(
110
+ name=maintenance.name,
111
+ message=maintenance.message.rstrip("\n"),
112
+ schedule_start=maintenance.scheduled_start,
113
+ schedule_end=maintenance.scheduled_end,
114
+ )
@@ -0,0 +1,47 @@
1
+ from typing import (
2
+ Optional,
3
+ Protocol,
4
+ )
5
+
6
+ from reconcile.utils.state import State
7
+
8
+ # This module manages the binding state of components in the desired state
9
+ # and their representation on an actual status page.
10
+ #
11
+ # This state management is required to map component identities between app-interface
12
+ # and the status page provider.
13
+
14
+
15
+ class ComponentBindingState(Protocol):
16
+ def get_id_for_component_name(self, component_name: str) -> Optional[str]: ...
17
+
18
+ def get_name_for_component_id(self, component_id: str) -> Optional[str]: ...
19
+
20
+ def bind_component(self, component_name: str, component_id: str) -> None: ...
21
+
22
+ def forget_component(self, component_name: str) -> None: ...
23
+
24
+
25
+ class S3ComponentBindingState(ComponentBindingState):
26
+ def __init__(self, state: State):
27
+ self.state = state
28
+ self._update_cache()
29
+
30
+ def _update_cache(self) -> None:
31
+ self.name_to_id_cache: dict[str, str] = self.state.get_all("")
32
+ self.id_to_name_cache: dict[str, str] = {
33
+ v: k for k, v in self.name_to_id_cache.items()
34
+ }
35
+
36
+ def get_id_for_component_name(self, component_name: str) -> Optional[str]:
37
+ return self.name_to_id_cache.get(component_name)
38
+
39
+ def get_name_for_component_id(self, component_id: str) -> Optional[str]:
40
+ return self.id_to_name_cache.get(component_id)
41
+
42
+ def bind_component(self, component_name: str, component_id: str) -> None:
43
+ self.state.add(component_name, component_id, force=True)
44
+ self._update_cache()
45
+
46
+ def forget_component(self, component_name: str) -> None:
47
+ self.state.rm(component_name)
@@ -0,0 +1,97 @@
1
+ from abc import (
2
+ ABC,
3
+ abstractmethod,
4
+ )
5
+ from datetime import (
6
+ datetime,
7
+ timezone,
8
+ )
9
+ from typing import Optional
10
+
11
+ from dateutil.parser import isoparse
12
+ from pydantic import BaseModel
13
+
14
+ from reconcile.gql_definitions.statuspage.statuspages import (
15
+ ManualStatusProviderV1,
16
+ StatusProviderV1,
17
+ )
18
+
19
+ # This module defines the interface for status providers for components on status
20
+ # pages. A status provider is responsible for determining the status of a component.
21
+ # This status will then be used to update the status page component.
22
+
23
+
24
+ class StatusProvider(ABC):
25
+ """
26
+ The generic static provider interface that can be used to determine the
27
+ status of a component.
28
+ """
29
+
30
+ @abstractmethod
31
+ def get_status(self) -> Optional[str]: ...
32
+
33
+
34
+ class ManualStatusProvider(StatusProvider, BaseModel):
35
+ """
36
+ This status provider is used to manually define the status of a component.
37
+ An optional time windows can be defined in which the status is applied to
38
+ the component.
39
+ """
40
+
41
+ start: Optional[datetime] = None
42
+ """
43
+ The optional start time of the time window in which the manually
44
+ defined status is active.
45
+ """
46
+
47
+ end: Optional[datetime] = None
48
+ """
49
+ The optional end time of the time window in which the manually
50
+ defined status is active.
51
+ """
52
+
53
+ component_status: str
54
+ """
55
+ The status to be used for the component if the
56
+ time window is active or if no time window is defined.
57
+ """
58
+
59
+ def get_status(self) -> Optional[str]:
60
+ if self._is_active():
61
+ return self.component_status
62
+ return None
63
+
64
+ def _is_active(self) -> bool:
65
+ """
66
+ Returns true if the config is active in regards to the start and
67
+ end time window. If no time window is defined, the config is always
68
+ active.
69
+ """
70
+ if self.start and self.end and self.end < self.start:
71
+ raise ValueError(
72
+ "manual component status time window is invalid: end before start"
73
+ )
74
+ now = datetime.now(timezone.utc)
75
+ if self.start and now < self.start:
76
+ return False
77
+ if self.end and self.end < now:
78
+ return False
79
+ return True
80
+
81
+
82
+ def build_status_provider_config(
83
+ cfg: StatusProviderV1,
84
+ ) -> Optional[StatusProvider]:
85
+ """
86
+ Translates a status provider config from the desired state into
87
+ provider specific implementation that provides the status resolution logic.
88
+ """
89
+ if isinstance(cfg, ManualStatusProviderV1):
90
+ start = isoparse(cfg.manual.q_from) if cfg.manual.q_from else None
91
+ end = isoparse(cfg.manual.until) if cfg.manual.until else None
92
+ return ManualStatusProvider(
93
+ component_status=cfg.manual.component_status,
94
+ start=start,
95
+ end=end,
96
+ )
97
+ return None