qontract-reconcile 0.10.1rc802__py3-none-any.whl → 0.10.1rc804__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.1rc804
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
@@ -104,7 +104,7 @@ reconcile/sendgrid_teammates.py,sha256=oO8QbLb4s1o8A6CGiCagN9CmS05BSS_WLztuY0Ym9
104
104
  reconcile/service_dependencies.py,sha256=PMKP9vc6oL-78rzyF_RE8DzLSQMSqN8vCqt9sWpBLAM,4470
105
105
  reconcile/signalfx_endpoint_monitoring.py,sha256=D1m8iq0EAKie0OD59FOcVCtpWWZ7xlo6lwBS9urwMIk,2894
106
106
  reconcile/slack_base.py,sha256=K3fSYx46G1djoPb07_C9j6ChhMCt5LgV5l6v2TFkNZk,3479
107
- reconcile/slack_usergroups.py,sha256=6G-KjcqacqqhJDsl-KMC-t5M9oexUpSSUc1yGyt2zvY,27120
107
+ reconcile/slack_usergroups.py,sha256=sp1QSPRUgTj3-hXpAWx1qOeR6okmVmlHcTUvUMTOLDI,27855
108
108
  reconcile/sql_query.py,sha256=FAQI9EIHsokZBbGwvGU4vnjg1fHemxpYQE20UtCB1qo,25941
109
109
  reconcile/status.py,sha256=cY4IJFXemhxptRJqR4qaaOWqei9e4jgLXuVSGajMsjg,544
110
110
  reconcile/status_board.py,sha256=nA74_133jukxVShjPKJpkXOA3vggDTTEhYTegoXbN1M,8632
@@ -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
@@ -513,7 +522,7 @@ reconcile/test/test_saasherder.py,sha256=hSZk34aZFq-wspT-kJmFuHjh8ztSr7IzGc5QdRV
513
522
  reconcile/test/test_saasherder_allowed_secret_paths.py,sha256=5NHQwNJO66at6HiyMZ5sVRTQDwxdvlOQo0KmkBWCw5Q,4853
514
523
  reconcile/test/test_secret_reader.py,sha256=kz7nzcPjvA08cytnvcA_PMA98AEyqJWsESkYeRn5xCk,4994
515
524
  reconcile/test/test_slack_base.py,sha256=gpbWOLNxMMX6fyAbs1JakhLTnwfedb3f7WpUae4tQZE,5060
516
- reconcile/test/test_slack_usergroups.py,sha256=wmS7xgl4U1jx4gu8qIDlljkhZxb_y8F6tR4UteyrlZE,24899
525
+ reconcile/test/test_slack_usergroups.py,sha256=Yj7SetVzdVsl0mzPakVil3Fb_4R_3r9lC32gnw_CwiA,24899
517
526
  reconcile/test/test_sql_query.py,sha256=rC-lf1_isT9i2ZIV9W0hkUkLi2oBIjZMRMhk-6mV-34,11029
518
527
  reconcile/test/test_status_board.py,sha256=go3YSWo03OLIdK95SuiDJa1Nqk-eN_9QtS7dfmu9__8,7875
519
528
  reconcile/test/test_terraform_aws_route53.py,sha256=xHggb8K1P76OyCfFcogbkmyKle-NlUylcbDnuv3IqvY,771
@@ -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.1rc804.dist-info/METADATA,sha256=1qgx2--INNmzLAiUE5w8oedIoXjGmXl0jvPcuxgT2ag,2314
801
+ qontract_reconcile-0.10.1rc804.dist-info/WHEEL,sha256=GJ7t_kWBFywbagK5eo9IoUwLW6oyOeTKmQ-9iHFVNxQ,92
802
+ qontract_reconcile-0.10.1rc804.dist-info/entry_points.txt,sha256=rIxI5zWtHNlfpDeq1a7pZXAPoqf7HG32KMTN3MeWK_8,429
803
+ qontract_reconcile-0.10.1rc804.dist-info/top_level.txt,sha256=l5ISPoXzt0SdR4jVdkfa7RPSKNc8zAHYWAnR-Dw8Ey8,24
804
+ qontract_reconcile-0.10.1rc804.dist-info/RECORD,,
@@ -109,9 +109,7 @@ class State(BaseModel):
109
109
  workspace: str = ""
110
110
  usergroup: str = ""
111
111
  description: str = ""
112
- users: set[SlackObject] = set()
113
112
  user_names: set[str] = set()
114
- channels: set[SlackObject] = set()
115
113
  channel_names: set[str] = set()
116
114
 
117
115
  def __bool__(self) -> bool:
@@ -208,10 +206,8 @@ def get_current_state(
208
206
  current_state.setdefault(workspace, {})[ug] = State(
209
207
  workspace=workspace,
210
208
  usergroup=ug,
211
- users={SlackObject(pk=pk, name=name) for pk, name in users.items()},
212
- channels={
213
- SlackObject(pk=pk, name=name) for pk, name in channels.items()
214
- },
209
+ user_names={name for _, name in users.items()},
210
+ channel_names={name for _, name in channels.items()},
215
211
  description=description,
216
212
  )
217
213
 
@@ -448,7 +444,7 @@ def get_desired_state(
448
444
  workspace=p.workspace.name,
449
445
  usergroup=usergroup,
450
446
  user_names=user_names,
451
- channel_names=sorted(p.channels or []),
447
+ channel_names=sorted(set(p.channels or [])),
452
448
  description=p.description,
453
449
  )
454
450
  return desired_state
@@ -478,11 +474,11 @@ def get_desired_state_cluster_usergroups(
478
474
  for u in openshift_users_desired_state
479
475
  if u["cluster"] == cluster.name
480
476
  ]
481
- cluster_usernames = set({
477
+ cluster_usernames = {
482
478
  get_slack_username(u)
483
479
  for u in users
484
480
  if include_user_to_cluster_usergroup(u, cluster, desired_cluster_users)
485
- })
481
+ }
486
482
  cluster_user_group = compute_cluster_user_group(cluster.name)
487
483
  for workspace, spec in slack_map.items():
488
484
  if not spec.slack.channel:
@@ -504,7 +500,7 @@ def get_desired_state_cluster_usergroups(
504
500
  workspace=workspace,
505
501
  usergroup=cluster_user_group,
506
502
  user_names=cluster_usernames,
507
- channel_names=set([spec.slack.channel]),
503
+ channel_names={spec.slack.channel},
508
504
  description=f"Users with access to the {cluster.name} cluster",
509
505
  )
510
506
  return desired_state
@@ -545,26 +541,40 @@ def _update_usergroup_users_from_state(
545
541
  ) -> None:
546
542
  """Update the users in a Slack usergroup."""
547
543
  global error_occurred # noqa: PLW0603
548
- if current_ug_state.users == desired_ug_state.users:
544
+ if current_ug_state.user_names == desired_ug_state.user_names:
549
545
  logging.debug(
550
546
  f"No usergroup user changes detected for {desired_ug_state.usergroup}"
551
547
  )
552
548
  return
553
549
 
554
- for user in desired_ug_state.users - current_ug_state.users:
550
+ slack_user_objects = [
551
+ SlackObject(pk=pk, name=name)
552
+ for pk, name in slack_client.get_users_by_names(
553
+ desired_ug_state.user_names
554
+ ).items()
555
+ ]
556
+
557
+ if len(slack_user_objects) != len(desired_ug_state.user_names):
558
+ logging.info(
559
+ f"Following usernames are incorrect for usergroup {desired_ug_state.usergroup} and could not be matched with slack users {desired_ug_state.user_names - set(s.name for s in slack_user_objects)}"
560
+ )
561
+ error_occurred = True
562
+ return
563
+
564
+ for user in desired_ug_state.user_names - current_ug_state.user_names:
555
565
  logging.info([
556
566
  "add_user_to_usergroup",
557
567
  desired_ug_state.workspace,
558
568
  desired_ug_state.usergroup,
559
- user.name,
569
+ user,
560
570
  ])
561
571
 
562
- for user in current_ug_state.users - desired_ug_state.users:
572
+ for user in current_ug_state.user_names - desired_ug_state.user_names:
563
573
  logging.info([
564
574
  "del_user_from_usergroup",
565
575
  desired_ug_state.workspace,
566
576
  desired_ug_state.usergroup,
567
- user.name,
577
+ user,
568
578
  ])
569
579
 
570
580
  if not dry_run:
@@ -577,7 +587,7 @@ def _update_usergroup_users_from_state(
577
587
  return
578
588
  slack_client.update_usergroup_users(
579
589
  id=ugid,
580
- users_list=sorted([user.pk for user in desired_ug_state.users]),
590
+ users_list=sorted([s.pk for s in slack_user_objects]),
581
591
  )
582
592
  except SlackApiError as error:
583
593
  # Prior to adding this, we weren't handling failed updates to user
@@ -597,7 +607,7 @@ def _update_usergroup_from_state(
597
607
  """Update a Slack usergroup."""
598
608
  global error_occurred # noqa: PLW0603
599
609
  if (
600
- current_ug_state.channels == desired_ug_state.channels
610
+ current_ug_state.channel_names == desired_ug_state.channel_names
601
611
  and current_ug_state.description == desired_ug_state.description
602
612
  ):
603
613
  logging.debug(
@@ -605,20 +615,39 @@ def _update_usergroup_from_state(
605
615
  )
606
616
  return
607
617
 
608
- for channel in desired_ug_state.channels - current_ug_state.channels:
618
+ slack_channel_objects = [
619
+ SlackObject(pk=pk, name=name)
620
+ for pk, name in slack_client.get_channels_by_names(
621
+ desired_ug_state.channel_names or []
622
+ ).items()
623
+ ]
624
+
625
+ # This is a hack to filter out the missing channels
626
+ desired_channel_names = {s.name for s in slack_channel_objects}
627
+
628
+ # Commenting this out is not correct, we should be checking the length of slack_channel_objects.
629
+ # However there are a couple of missing channels and filtering these out complies with current behavior.
630
+ # if len(slack_channel_objects) != len(desired_ug_state.channel_names):
631
+ # logging.info(
632
+ # f"Following channel names are incorrect for usergroup {desired_ug_state.usergroup} and could not be matched with slack channels {desired_ug_state.channel_names - set([s.name for s in slack_channel_objects])}"
633
+ # )
634
+ # error_occurred = True
635
+ # return
636
+
637
+ for channel in desired_channel_names - current_ug_state.channel_names:
609
638
  logging.info([
610
639
  "add_channel_to_usergroup",
611
640
  desired_ug_state.workspace,
612
641
  desired_ug_state.usergroup,
613
- channel.name,
642
+ channel,
614
643
  ])
615
644
 
616
- for channel in current_ug_state.channels - desired_ug_state.channels:
645
+ for channel in current_ug_state.channel_names - desired_channel_names:
617
646
  logging.info([
618
647
  "del_channel_from_usergroup",
619
648
  desired_ug_state.workspace,
620
649
  desired_ug_state.usergroup,
621
- channel.name,
650
+ channel,
622
651
  ])
623
652
 
624
653
  if current_ug_state.description != desired_ug_state.description:
@@ -639,9 +668,7 @@ def _update_usergroup_from_state(
639
668
  return
640
669
  slack_client.update_usergroup(
641
670
  id=ugid,
642
- channels_list=sorted([
643
- channel.pk for channel in desired_ug_state.channels
644
- ]),
671
+ channels_list=sorted(s.pk for s in slack_channel_objects),
645
672
  description=desired_ug_state.description,
646
673
  )
647
674
  except SlackApiError as error:
@@ -663,40 +690,24 @@ def act(
663
690
  usergroup, State()
664
691
  )
665
692
 
666
- slack_client = slack_map[workspace].slack
667
-
668
- desired_ug_state.users = {
669
- SlackObject(pk=pk, name=name)
670
- for pk, name in slack_client.get_users_by_names(
671
- sorted(desired_ug_state.user_names)
672
- ).items()
673
- }
674
-
675
- desired_ug_state.channels = {
676
- SlackObject(pk=pk, name=name)
677
- for pk, name in slack_client.get_channels_by_names(
678
- sorted(desired_ug_state.channel_names or [])
679
- ).items()
680
- }
681
-
682
693
  _create_usergroups(
683
694
  current_ug_state,
684
695
  desired_ug_state,
685
- slack_client=slack_client,
696
+ slack_client=slack_map[workspace].slack,
686
697
  dry_run=dry_run,
687
698
  )
688
699
 
689
700
  _update_usergroup_users_from_state(
690
701
  current_ug_state,
691
702
  desired_ug_state,
692
- slack_client=slack_client,
703
+ slack_client=slack_map[workspace].slack,
693
704
  dry_run=dry_run,
694
705
  )
695
706
 
696
707
  _update_usergroup_from_state(
697
708
  current_ug_state,
698
709
  desired_ug_state,
699
- slack_client=slack_client,
710
+ slack_client=slack_map[workspace].slack,
700
711
  dry_run=dry_run,
701
712
  )
702
713
 
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
@@ -36,7 +36,6 @@ from reconcile.gql_definitions.slack_usergroups.users import (
36
36
  from reconcile.gql_definitions.slack_usergroups.users import ClusterV1 as AccessCluster
37
37
  from reconcile.slack_usergroups import (
38
38
  SlackMap,
39
- SlackObject,
40
39
  SlackState,
41
40
  State,
42
41
  WorkspaceSpec,
@@ -61,9 +60,7 @@ def base_state():
61
60
  workspace="slack-workspace",
62
61
  usergroup="usergroup-1",
63
62
  usergroup_id="USERGA",
64
- users={SlackObject(name="username", pk="USERA")},
65
63
  user_names={"username"},
66
- channels={SlackObject(name="channelname", pk="CHANA")},
67
64
  channel_names={"channelname"},
68
65
  description="Some description",
69
66
  )
@@ -561,9 +558,7 @@ def test_act_dryrun_no_changes_made(
561
558
  current_state = base_state
562
559
  desired_state = copy.deepcopy(base_state)
563
560
 
564
- desired_state["slack-workspace"]["usergroup-1"].users = {
565
- SlackObject(name="foo", pk="bar")
566
- }
561
+ desired_state["slack-workspace"]["usergroup-1"].user_names = {"foo"}
567
562
 
568
563
  act(current_state, desired_state, slack_map, dry_run=True)
569
564
 
@@ -604,6 +599,11 @@ def test_act_update_usergroup_users(
604
599
  current_state = base_state
605
600
  desired_state = copy.deepcopy(base_state)
606
601
 
602
+ desired_state["slack-workspace"]["usergroup-1"].user_names = {
603
+ "someotherusername",
604
+ "anotheruser",
605
+ }
606
+
607
607
  slack_client_mock.get_usergroup_id.return_value = "USERGA"
608
608
  slack_client_mock.get_users_by_names.return_value = {
609
609
  "USERB": "someotherusername",
@@ -614,6 +614,7 @@ def test_act_update_usergroup_users(
614
614
  act(current_state, desired_state, slack_map, dry_run=False)
615
615
 
616
616
  slack_client_mock.update_usergroup.assert_not_called()
617
+ slack_client_mock.update_usergroup_users.assert_called_once()
617
618
  assert slack_client_mock.update_usergroup_users.call_args_list == [
618
619
  call(id="USERGA", users_list=["USERB", "USERC"])
619
620
  ]