qontract-reconcile 0.10.1rc801__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.
- {qontract_reconcile-0.10.1rc801.dist-info → qontract_reconcile-0.10.1rc803.dist-info}/METADATA +1 -1
- {qontract_reconcile-0.10.1rc801.dist-info → qontract_reconcile-0.10.1rc803.dist-info}/RECORD +25 -12
- reconcile/gql_definitions/cost_report/cost_namespaces.py +84 -0
- 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/typed_queries/cost_report/cost_namespaces.py +40 -0
- tools/cli_commands/cost_report/{command.py → aws.py} +53 -75
- tools/cli_commands/cost_report/cost_management_api.py +28 -4
- tools/cli_commands/cost_report/model.py +7 -8
- tools/cli_commands/cost_report/openshift.py +179 -0
- tools/cli_commands/cost_report/response.py +25 -4
- tools/cli_commands/cost_report/util.py +59 -0
- tools/cli_commands/cost_report/view.py +121 -56
- tools/qontract_cli.py +10 -2
- tools/test/test_qontract_cli.py +32 -6
- {qontract_reconcile-0.10.1rc801.dist-info → qontract_reconcile-0.10.1rc803.dist-info}/WHEEL +0 -0
- {qontract_reconcile-0.10.1rc801.dist-info → qontract_reconcile-0.10.1rc803.dist-info}/entry_points.txt +0 -0
- {qontract_reconcile-0.10.1rc801.dist-info → qontract_reconcile-0.10.1rc803.dist-info}/top_level.txt +0 -0
{qontract_reconcile-0.10.1rc801.dist-info → qontract_reconcile-0.10.1rc803.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.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
|
{qontract_reconcile-0.10.1rc801.dist-info → qontract_reconcile-0.10.1rc803.dist-info}/RECORD
RENAMED
@@ -260,6 +260,7 @@ reconcile/gql_definitions/common/state_aws_account.py,sha256=LAdpCG2-ykVpWBPO0Zu
|
|
260
260
|
reconcile/gql_definitions/common/users.py,sha256=uDiEDqa4QP89I2oFuKhCtVB61ZviIt7Y75fgrcCm7M4,1681
|
261
261
|
reconcile/gql_definitions/cost_report/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
262
262
|
reconcile/gql_definitions/cost_report/app_names.py,sha256=fzqYXyiTSll359J1F1o7qapco0MSxgs3sr_Ssb2Kbns,1786
|
263
|
+
reconcile/gql_definitions/cost_report/cost_namespaces.py,sha256=wV76J8Ynq0ObSfjSZQRLmCPUZ96frGc82Z5HPM_e4F4,2219
|
263
264
|
reconcile/gql_definitions/cost_report/settings.py,sha256=0nhBDJ5MZ1m7XkNDGrRLmsnUbzqZ4WRh_DDEEzKhcxU,2153
|
264
265
|
reconcile/gql_definitions/dashdotdb_slo/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
265
266
|
reconcile/gql_definitions/dashdotdb_slo/slo_documents_query.py,sha256=zUa-CmpOwiymVmOV6KwDHH5mMl06p000320FcOas6hU,4315
|
@@ -432,6 +433,15 @@ reconcile/skupper_network/integration.py,sha256=178Q9RSYuZ9NmrCK4jRMLMekrewUaaRd
|
|
432
433
|
reconcile/skupper_network/models.py,sha256=DNTI7HZv-rqY42GIIxyRuvroHLvdH6rJerjIq9lj3RU,6663
|
433
434
|
reconcile/skupper_network/reconciler.py,sha256=XS-1oKBr_1l3dYUAVqUH6gCHg1G5ZuOfY_7fgGVAiFA,9996
|
434
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
|
435
445
|
reconcile/templates/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
436
446
|
reconcile/templates/aws_access_key_email.j2,sha256=2MUr1ERmyISzKgHqsWYLd-1Wbl-peUa-FsGUS-JLUFc,238
|
437
447
|
reconcile/templates/email.yml.j2,sha256=OZgczNRgXPj2gVYTgwQyHAQrMGu7xp-e4W1rX19GcrU,690
|
@@ -584,6 +594,7 @@ reconcile/typed_queries/app_interface_metrics_exporter/__init__.py,sha256=47DEQp
|
|
584
594
|
reconcile/typed_queries/app_interface_metrics_exporter/onboarding_status.py,sha256=X-N1WJGOL6OR9940P0_K4-YJzkL5Vg4favhYrBxXD9A,327
|
585
595
|
reconcile/typed_queries/cost_report/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
586
596
|
reconcile/typed_queries/cost_report/app_names.py,sha256=HMEMIqAbMyVQfoQ5YXTXE4xDt7FaXBRz0QIHnsIZC1c,478
|
597
|
+
reconcile/typed_queries/cost_report/cost_namespaces.py,sha256=VXIqdGE5lwa5z4UTROEsuc3QdxgodRgXglei0LJI2Co,985
|
587
598
|
reconcile/typed_queries/cost_report/settings.py,sha256=xbTMMUQnbub2pav4B-ctzzRe7ijjTv2bqfqdtb9OnO0,589
|
588
599
|
reconcile/typed_queries/terraform_tgw_attachments/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
589
600
|
reconcile/typed_queries/terraform_tgw_attachments/aws_accounts.py,sha256=T5HSeyBcGKP-LzDmIzs-WlBwOtSenYpm0Odw5--xdOg,410
|
@@ -766,26 +777,28 @@ tools/app_interface_metrics_exporter.py,sha256=zkwkxdAUAxjdc-pzx2_oJXG25fo0Fnyd5
|
|
766
777
|
tools/app_interface_reporter.py,sha256=upA-J-n-HXHKVDINRuMR7vTt-iJvQORKUVi9D3leQto,17738
|
767
778
|
tools/glitchtip_access_reporter.py,sha256=oPBnk_YoDuljU3v0FaChzOwwnk4vap1xEE67QEjzdqs,2948
|
768
779
|
tools/glitchtip_access_revalidation.py,sha256=8kbBJk04mkq28kWoRDDkfCGIF3GRg3pJrFAh1sW0dbk,2821
|
769
|
-
tools/qontract_cli.py,sha256=
|
780
|
+
tools/qontract_cli.py,sha256=GduWl9WzfSmOmWjZfyoyR9U6oP8uf8ckF0iHf78tHvE,114858
|
770
781
|
tools/sd_app_sre_alert_report.py,sha256=e9vAdyenUz2f5c8-z-5WY0wv-SJ9aePKDH2r4IwB6pc,5063
|
771
782
|
tools/template_validation.py,sha256=-U-lTGeLaci8yWPEblCJeev2DOlY1jM9QOOh-O1zts8,3376
|
772
783
|
tools/cli_commands/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
773
784
|
tools/cli_commands/gpg_encrypt.py,sha256=w8hl4jIEWk5wKbEFN6fVEOwUJGmdlvOqYodW3XSN7mU,4978
|
774
785
|
tools/cli_commands/cost_report/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
775
|
-
tools/cli_commands/cost_report/
|
776
|
-
tools/cli_commands/cost_report/cost_management_api.py,sha256=
|
777
|
-
tools/cli_commands/cost_report/model.py,sha256=
|
778
|
-
tools/cli_commands/cost_report/
|
779
|
-
tools/cli_commands/cost_report/
|
786
|
+
tools/cli_commands/cost_report/aws.py,sha256=bdvbT3bDymBOMx4Ve4kt154MeGiGusTSpOpMMFrQ4SM,5191
|
787
|
+
tools/cli_commands/cost_report/cost_management_api.py,sha256=tFzjaZ3RSVJeAUsfSWXHiQlMVbV8dFcXibK158ODQcA,2549
|
788
|
+
tools/cli_commands/cost_report/model.py,sha256=KGkro82zoFXLmfQ4nv7dkv4QoNtOF-k6PT6VURCLZKE,548
|
789
|
+
tools/cli_commands/cost_report/openshift.py,sha256=XNEJpgtIQAi3Eoej1Btnl74IWg3g-gLh6W36WOwQ9bk,6188
|
790
|
+
tools/cli_commands/cost_report/response.py,sha256=_kbpBSjMjbPXGkjDgidTOLvFpVqfBo3VMkSOheUdmMA,1308
|
791
|
+
tools/cli_commands/cost_report/util.py,sha256=r4K8nC1S0YZNSUNro141cYG1nuG8HwkkYEqWV9GCvu8,1861
|
792
|
+
tools/cli_commands/cost_report/view.py,sha256=pKM6LDeWZJcTw2a7sWBwKSuR9p3SAk3lEb37uwMhlLw,10183
|
780
793
|
tools/sre_checkpoints/__init__.py,sha256=CDaDaywJnmRCLyl_NCcvxi-Zc0hTi_3OdwKiFOyS39I,145
|
781
794
|
tools/sre_checkpoints/util.py,sha256=zEDbGr18ZeHNQwW8pUsr2JRjuXIPz--WAGJxZo9sv_Y,894
|
782
795
|
tools/test/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
783
796
|
tools/test/test_app_interface_metrics_exporter.py,sha256=SX7qL3D1SIRKFo95FoQztvftCWEEf-g1mfXOtgCog-g,1271
|
784
|
-
tools/test/test_qontract_cli.py,sha256=
|
797
|
+
tools/test/test_qontract_cli.py,sha256=_D61RFGAN5x44CY1tYbouhlGXXABwYfxKSWSQx3Jrss,4941
|
785
798
|
tools/test/test_sd_app_sre_alert_report.py,sha256=v363r9zM7__0kR5K6mvJoGFcM9BvE33fWAayrqkpojA,2116
|
786
799
|
tools/test/test_sre_checkpoints.py,sha256=SKqPPTl9ua0RFdSSofnoQX-JZE6dFLO3LRhfQzqtfh8,2607
|
787
|
-
qontract_reconcile-0.10.
|
788
|
-
qontract_reconcile-0.10.
|
789
|
-
qontract_reconcile-0.10.
|
790
|
-
qontract_reconcile-0.10.
|
791
|
-
qontract_reconcile-0.10.
|
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,,
|
@@ -0,0 +1,84 @@
|
|
1
|
+
"""
|
2
|
+
Generated by qenerate plugin=pydantic_v1. DO NOT MODIFY MANUALLY!
|
3
|
+
"""
|
4
|
+
from collections.abc import Callable # noqa: F401 # pylint: disable=W0611
|
5
|
+
from datetime import datetime # noqa: F401 # pylint: disable=W0611
|
6
|
+
from enum import Enum # noqa: F401 # pylint: disable=W0611
|
7
|
+
from typing import ( # noqa: F401 # pylint: disable=W0611
|
8
|
+
Any,
|
9
|
+
Optional,
|
10
|
+
Union,
|
11
|
+
)
|
12
|
+
|
13
|
+
from pydantic import ( # noqa: F401 # pylint: disable=W0611
|
14
|
+
BaseModel,
|
15
|
+
Extra,
|
16
|
+
Field,
|
17
|
+
Json,
|
18
|
+
)
|
19
|
+
|
20
|
+
|
21
|
+
DEFINITION = """
|
22
|
+
query CostNamespaces($filter: JSON) {
|
23
|
+
namespaces: namespaces_v1(filter: $filter) {
|
24
|
+
name
|
25
|
+
app {
|
26
|
+
name
|
27
|
+
}
|
28
|
+
cluster {
|
29
|
+
name
|
30
|
+
spec {
|
31
|
+
external_id
|
32
|
+
}
|
33
|
+
}
|
34
|
+
}
|
35
|
+
}
|
36
|
+
"""
|
37
|
+
|
38
|
+
|
39
|
+
class ConfiguredBaseModel(BaseModel):
|
40
|
+
class Config:
|
41
|
+
smart_union=True
|
42
|
+
extra=Extra.forbid
|
43
|
+
|
44
|
+
|
45
|
+
class AppV1(ConfiguredBaseModel):
|
46
|
+
name: str = Field(..., alias="name")
|
47
|
+
|
48
|
+
|
49
|
+
class ClusterSpecV1(ConfiguredBaseModel):
|
50
|
+
external_id: Optional[str] = Field(..., alias="external_id")
|
51
|
+
|
52
|
+
|
53
|
+
class ClusterV1(ConfiguredBaseModel):
|
54
|
+
name: str = Field(..., alias="name")
|
55
|
+
spec: Optional[ClusterSpecV1] = Field(..., alias="spec")
|
56
|
+
|
57
|
+
|
58
|
+
class NamespaceV1(ConfiguredBaseModel):
|
59
|
+
name: str = Field(..., alias="name")
|
60
|
+
app: AppV1 = Field(..., alias="app")
|
61
|
+
cluster: ClusterV1 = Field(..., alias="cluster")
|
62
|
+
|
63
|
+
|
64
|
+
class CostNamespacesQueryData(ConfiguredBaseModel):
|
65
|
+
namespaces: Optional[list[NamespaceV1]] = Field(..., alias="namespaces")
|
66
|
+
|
67
|
+
|
68
|
+
def query(query_func: Callable, **kwargs: Any) -> CostNamespacesQueryData:
|
69
|
+
"""
|
70
|
+
This is a convenience function which queries and parses the data into
|
71
|
+
concrete types. It should be compatible with most GQL clients.
|
72
|
+
You do not have to use it to consume the generated data classes.
|
73
|
+
Alternatively, you can also mime and alternate the behavior
|
74
|
+
of this function in the caller.
|
75
|
+
|
76
|
+
Parameters:
|
77
|
+
query_func (Callable): Function which queries your GQL Server
|
78
|
+
kwargs: optional arguments that will be passed to the query function
|
79
|
+
|
80
|
+
Returns:
|
81
|
+
CostNamespacesQueryData: queried data parsed into generated classes
|
82
|
+
"""
|
83
|
+
raw_data: dict[Any, Any] = query_func(DEFINITION, **kwargs)
|
84
|
+
return CostNamespacesQueryData(**raw_data)
|
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
|