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
@@ -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
|
@@ -0,0 +1,40 @@
|
|
1
|
+
from pydantic import BaseModel
|
2
|
+
|
3
|
+
from reconcile.gql_definitions.cost_report.cost_namespaces import query
|
4
|
+
from reconcile.utils.gql import GqlApi
|
5
|
+
|
6
|
+
|
7
|
+
class CostNamespace(BaseModel):
|
8
|
+
name: str
|
9
|
+
app_name: str
|
10
|
+
cluster_name: str
|
11
|
+
cluster_external_id: str | None
|
12
|
+
|
13
|
+
class Config:
|
14
|
+
frozen = True
|
15
|
+
|
16
|
+
|
17
|
+
def get_cost_namespaces(
|
18
|
+
gql_api: GqlApi,
|
19
|
+
) -> list[CostNamespace]:
|
20
|
+
variables = {
|
21
|
+
"filter": {
|
22
|
+
"cluster": {
|
23
|
+
"filter": {
|
24
|
+
"enableCostReport": True,
|
25
|
+
},
|
26
|
+
},
|
27
|
+
},
|
28
|
+
}
|
29
|
+
namespaces = query(gql_api.query, variables=variables).namespaces or []
|
30
|
+
return [
|
31
|
+
CostNamespace(
|
32
|
+
name=namespace.name,
|
33
|
+
app_name=namespace.app.name,
|
34
|
+
cluster_name=namespace.cluster.name,
|
35
|
+
cluster_external_id=namespace.cluster.spec.external_id
|
36
|
+
if namespace.cluster.spec
|
37
|
+
else None,
|
38
|
+
)
|
39
|
+
for namespace in namespaces
|
40
|
+
]
|
@@ -1,7 +1,6 @@
|
|
1
|
-
from collections import
|
2
|
-
from collections.abc import Iterable, Mapping, MutableMapping
|
1
|
+
from collections.abc import Iterable, Mapping
|
3
2
|
from decimal import Decimal
|
4
|
-
from typing import
|
3
|
+
from typing import Self
|
5
4
|
|
6
5
|
from sretoolbox.utils import threaded
|
7
6
|
|
@@ -13,27 +12,31 @@ from reconcile.typed_queries.cost_report.settings import get_cost_report_setting
|
|
13
12
|
from reconcile.utils import gql
|
14
13
|
from reconcile.utils.secret_reader import create_secret_reader
|
15
14
|
from tools.cli_commands.cost_report.cost_management_api import CostManagementApi
|
16
|
-
from tools.cli_commands.cost_report.model import ChildAppReport, Report,
|
15
|
+
from tools.cli_commands.cost_report.model import ChildAppReport, Report, ReportItem
|
17
16
|
from tools.cli_commands.cost_report.response import ReportCostResponse
|
18
|
-
from tools.cli_commands.cost_report.
|
17
|
+
from tools.cli_commands.cost_report.util import process_reports
|
18
|
+
from tools.cli_commands.cost_report.view import render_aws_cost_report
|
19
19
|
|
20
20
|
THREAD_POOL_SIZE = 10
|
21
21
|
|
22
22
|
|
23
|
-
class
|
23
|
+
class AwsCostReportCommand:
|
24
24
|
def __init__(
|
25
25
|
self,
|
26
26
|
gql_api: gql.GqlApi,
|
27
27
|
cost_management_api: CostManagementApi,
|
28
28
|
cost_management_console_base_url: str,
|
29
|
+
thread_pool_size: int = THREAD_POOL_SIZE,
|
29
30
|
) -> None:
|
30
31
|
self.gql_api = gql_api
|
31
32
|
self.cost_management_api = cost_management_api
|
32
33
|
self.cost_management_console_base_url = cost_management_console_base_url
|
34
|
+
self.thread_pool_size = thread_pool_size
|
33
35
|
|
34
36
|
def execute(self) -> str:
|
35
37
|
apps = self.get_apps()
|
36
|
-
|
38
|
+
responses = self.get_reports(apps)
|
39
|
+
reports = self.process_reports(apps, responses)
|
37
40
|
return self.render(reports)
|
38
41
|
|
39
42
|
def get_apps(self) -> list[App]:
|
@@ -42,74 +45,44 @@ class CostReportCommand:
|
|
42
45
|
"""
|
43
46
|
return get_app_names(self.gql_api)
|
44
47
|
|
45
|
-
def
|
48
|
+
def _get_report(self, app: App) -> tuple[str, ReportCostResponse]:
|
46
49
|
return app.name, self.cost_management_api.get_aws_costs_report(app.name)
|
47
50
|
|
48
|
-
def
|
49
|
-
|
50
|
-
|
51
|
-
|
52
|
-
def get_reports(self, apps: Iterable[App]) -> dict[str, Report]:
|
51
|
+
def get_reports(
|
52
|
+
self,
|
53
|
+
apps: Iterable[App],
|
54
|
+
) -> Mapping[str, ReportCostResponse]:
|
53
55
|
"""
|
54
|
-
Fetch reports from cost management API
|
56
|
+
Fetch reports from cost management API
|
55
57
|
"""
|
56
|
-
|
57
|
-
|
58
|
-
child_apps_by_parent = defaultdict(list)
|
59
|
-
for app in apps:
|
60
|
-
child_apps_by_parent[app.parent_app_name].append(app.name)
|
61
|
-
|
62
|
-
reports: dict[str, Report] = {}
|
63
|
-
root_apps = child_apps_by_parent.get(None, [])
|
64
|
-
for app_name in root_apps:
|
65
|
-
self._dfs_reports(
|
66
|
-
app_name,
|
67
|
-
None,
|
68
|
-
child_apps_by_parent=child_apps_by_parent,
|
69
|
-
responses=responses,
|
70
|
-
reports=reports,
|
71
|
-
)
|
72
|
-
return reports
|
73
|
-
|
74
|
-
def render(self, reports: Mapping[str, Report]) -> str:
|
75
|
-
return render_report(
|
76
|
-
reports=reports,
|
77
|
-
cost_management_console_base_url=self.cost_management_console_base_url,
|
78
|
-
)
|
58
|
+
results = threaded.run(self._get_report, apps, self.thread_pool_size)
|
59
|
+
return dict(results)
|
79
60
|
|
80
|
-
def
|
61
|
+
def process_reports(
|
81
62
|
self,
|
82
|
-
|
83
|
-
parent_app_name: str | None,
|
84
|
-
child_apps_by_parent: Mapping[str | None, list[str]],
|
63
|
+
apps: Iterable[App],
|
85
64
|
responses: Mapping[str, ReportCostResponse],
|
86
|
-
|
87
|
-
) -> None:
|
65
|
+
) -> dict[str, Report]:
|
88
66
|
"""
|
89
|
-
|
67
|
+
Build reports with parent-child app tree.
|
90
68
|
"""
|
91
|
-
|
92
|
-
|
93
|
-
|
94
|
-
|
95
|
-
|
96
|
-
|
97
|
-
|
98
|
-
|
99
|
-
)
|
100
|
-
reports[app_name] = self._build_report(
|
101
|
-
app_name=app_name,
|
102
|
-
parent_app_name=parent_app_name,
|
103
|
-
child_apps=child_apps,
|
69
|
+
return process_reports(
|
70
|
+
apps,
|
71
|
+
responses,
|
72
|
+
report_builder=self._build_report,
|
73
|
+
)
|
74
|
+
|
75
|
+
def render(self, reports: Mapping[str, Report]) -> str:
|
76
|
+
return render_aws_cost_report(
|
104
77
|
reports=reports,
|
105
|
-
|
78
|
+
cost_management_console_base_url=self.cost_management_console_base_url,
|
106
79
|
)
|
107
80
|
|
108
81
|
@staticmethod
|
109
82
|
def _build_report(
|
110
83
|
app_name: str,
|
111
84
|
parent_app_name: str | None,
|
112
|
-
child_apps:
|
85
|
+
child_apps: list[str],
|
113
86
|
reports: Mapping[str, Report],
|
114
87
|
response: ReportCostResponse,
|
115
88
|
) -> Report:
|
@@ -123,30 +96,34 @@ class CostReportCommand:
|
|
123
96
|
child_apps_total = Decimal(
|
124
97
|
sum(child_app.total for child_app in child_app_reports)
|
125
98
|
)
|
126
|
-
|
127
|
-
|
99
|
+
|
100
|
+
items = [
|
101
|
+
ReportItem(
|
102
|
+
name=service.service,
|
103
|
+
delta_value=value.delta_value,
|
104
|
+
delta_percent=value.delta_percent,
|
105
|
+
total=value.cost.total.value,
|
106
|
+
)
|
107
|
+
for data in response.data
|
108
|
+
for service in data.services
|
109
|
+
if len(service.values) == 1 and (value := service.values[0]) is not None
|
110
|
+
]
|
111
|
+
|
112
|
+
items_total = response.meta.total.cost.total.value
|
113
|
+
total = items_total + child_apps_total
|
128
114
|
date = next((d for data in response.data if (d := data.date)), "")
|
115
|
+
|
129
116
|
return Report(
|
130
117
|
app_name=app_name,
|
131
118
|
child_apps=child_app_reports,
|
132
119
|
child_apps_total=child_apps_total,
|
133
120
|
date=date,
|
134
121
|
parent_app_name=parent_app_name,
|
135
|
-
|
136
|
-
|
137
|
-
|
122
|
+
items_delta_value=response.meta.delta.value,
|
123
|
+
items_delta_percent=response.meta.delta.percent,
|
124
|
+
items_total=items_total,
|
138
125
|
total=total,
|
139
|
-
|
140
|
-
ServiceReport(
|
141
|
-
service=service.service,
|
142
|
-
delta_value=value.delta_value,
|
143
|
-
delta_percent=value.delta_percent,
|
144
|
-
total=value.cost.total.value,
|
145
|
-
)
|
146
|
-
for data in response.data
|
147
|
-
for service in data.services
|
148
|
-
if len(service.values) == 1 and (value := service.values[0]) is not None
|
149
|
-
],
|
126
|
+
items=items,
|
150
127
|
)
|
151
128
|
|
152
129
|
@classmethod
|
@@ -169,4 +146,5 @@ class CostReportCommand:
|
|
169
146
|
gql_api=gql_api,
|
170
147
|
cost_management_api=cost_management_api,
|
171
148
|
cost_management_console_base_url=secret["console_base_url"],
|
149
|
+
thread_pool_size=THREAD_POOL_SIZE,
|
172
150
|
)
|