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.
- {qontract_reconcile-0.10.1rc802.dist-info → qontract_reconcile-0.10.1rc804.dist-info}/METADATA +1 -1
- {qontract_reconcile-0.10.1rc802.dist-info → qontract_reconcile-0.10.1rc804.dist-info}/RECORD +16 -7
- reconcile/slack_usergroups.py +54 -43
- reconcile/statuspage/__init__.py +0 -0
- reconcile/statuspage/atlassian.py +425 -0
- reconcile/statuspage/integration.py +22 -0
- reconcile/statuspage/integrations/__init__.py +0 -0
- reconcile/statuspage/integrations/components.py +77 -0
- reconcile/statuspage/integrations/maintenances.py +73 -0
- reconcile/statuspage/page.py +114 -0
- reconcile/statuspage/state.py +47 -0
- reconcile/statuspage/status.py +97 -0
- reconcile/test/test_slack_usergroups.py +7 -6
- {qontract_reconcile-0.10.1rc802.dist-info → qontract_reconcile-0.10.1rc804.dist-info}/WHEEL +0 -0
- {qontract_reconcile-0.10.1rc802.dist-info → qontract_reconcile-0.10.1rc804.dist-info}/entry_points.txt +0 -0
- {qontract_reconcile-0.10.1rc802.dist-info → qontract_reconcile-0.10.1rc804.dist-info}/top_level.txt +0 -0
{qontract_reconcile-0.10.1rc802.dist-info → qontract_reconcile-0.10.1rc804.dist-info}/METADATA
RENAMED
@@ -1,6 +1,6 @@
|
|
1
1
|
Metadata-Version: 2.1
|
2
2
|
Name: qontract-reconcile
|
3
|
-
Version: 0.10.
|
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
|
{qontract_reconcile-0.10.1rc802.dist-info → qontract_reconcile-0.10.1rc804.dist-info}/RECORD
RENAMED
@@ -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=
|
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=
|
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.
|
792
|
-
qontract_reconcile-0.10.
|
793
|
-
qontract_reconcile-0.10.
|
794
|
-
qontract_reconcile-0.10.
|
795
|
-
qontract_reconcile-0.10.
|
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,,
|
reconcile/slack_usergroups.py
CHANGED
@@ -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
|
-
|
212
|
-
|
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 =
|
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=
|
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.
|
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
|
-
|
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
|
569
|
+
user,
|
560
570
|
])
|
561
571
|
|
562
|
-
for user in current_ug_state.
|
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
|
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([
|
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.
|
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
|
-
|
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
|
642
|
+
channel,
|
614
643
|
])
|
615
644
|
|
616
|
-
for channel in current_ug_state.
|
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
|
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=
|
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=
|
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=
|
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"].
|
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
|
]
|
File without changes
|
File without changes
|
{qontract_reconcile-0.10.1rc802.dist-info → qontract_reconcile-0.10.1rc804.dist-info}/top_level.txt
RENAMED
File without changes
|