qontract-reconcile 0.10.1rc801__py3-none-any.whl → 0.10.1rc802__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.1rc802.dist-info}/METADATA +1 -1
- {qontract_reconcile-0.10.1rc801.dist-info → qontract_reconcile-0.10.1rc802.dist-info}/RECORD +16 -12
- reconcile/gql_definitions/cost_report/cost_namespaces.py +84 -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.1rc802.dist-info}/WHEEL +0 -0
- {qontract_reconcile-0.10.1rc801.dist-info → qontract_reconcile-0.10.1rc802.dist-info}/entry_points.txt +0 -0
- {qontract_reconcile-0.10.1rc801.dist-info → qontract_reconcile-0.10.1rc802.dist-info}/top_level.txt +0 -0
{qontract_reconcile-0.10.1rc801.dist-info → qontract_reconcile-0.10.1rc802.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.1rc802
|
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.1rc802.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
|
@@ -584,6 +585,7 @@ reconcile/typed_queries/app_interface_metrics_exporter/__init__.py,sha256=47DEQp
|
|
584
585
|
reconcile/typed_queries/app_interface_metrics_exporter/onboarding_status.py,sha256=X-N1WJGOL6OR9940P0_K4-YJzkL5Vg4favhYrBxXD9A,327
|
585
586
|
reconcile/typed_queries/cost_report/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
586
587
|
reconcile/typed_queries/cost_report/app_names.py,sha256=HMEMIqAbMyVQfoQ5YXTXE4xDt7FaXBRz0QIHnsIZC1c,478
|
588
|
+
reconcile/typed_queries/cost_report/cost_namespaces.py,sha256=VXIqdGE5lwa5z4UTROEsuc3QdxgodRgXglei0LJI2Co,985
|
587
589
|
reconcile/typed_queries/cost_report/settings.py,sha256=xbTMMUQnbub2pav4B-ctzzRe7ijjTv2bqfqdtb9OnO0,589
|
588
590
|
reconcile/typed_queries/terraform_tgw_attachments/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
589
591
|
reconcile/typed_queries/terraform_tgw_attachments/aws_accounts.py,sha256=T5HSeyBcGKP-LzDmIzs-WlBwOtSenYpm0Odw5--xdOg,410
|
@@ -766,26 +768,28 @@ tools/app_interface_metrics_exporter.py,sha256=zkwkxdAUAxjdc-pzx2_oJXG25fo0Fnyd5
|
|
766
768
|
tools/app_interface_reporter.py,sha256=upA-J-n-HXHKVDINRuMR7vTt-iJvQORKUVi9D3leQto,17738
|
767
769
|
tools/glitchtip_access_reporter.py,sha256=oPBnk_YoDuljU3v0FaChzOwwnk4vap1xEE67QEjzdqs,2948
|
768
770
|
tools/glitchtip_access_revalidation.py,sha256=8kbBJk04mkq28kWoRDDkfCGIF3GRg3pJrFAh1sW0dbk,2821
|
769
|
-
tools/qontract_cli.py,sha256=
|
771
|
+
tools/qontract_cli.py,sha256=GduWl9WzfSmOmWjZfyoyR9U6oP8uf8ckF0iHf78tHvE,114858
|
770
772
|
tools/sd_app_sre_alert_report.py,sha256=e9vAdyenUz2f5c8-z-5WY0wv-SJ9aePKDH2r4IwB6pc,5063
|
771
773
|
tools/template_validation.py,sha256=-U-lTGeLaci8yWPEblCJeev2DOlY1jM9QOOh-O1zts8,3376
|
772
774
|
tools/cli_commands/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
773
775
|
tools/cli_commands/gpg_encrypt.py,sha256=w8hl4jIEWk5wKbEFN6fVEOwUJGmdlvOqYodW3XSN7mU,4978
|
774
776
|
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/
|
777
|
+
tools/cli_commands/cost_report/aws.py,sha256=bdvbT3bDymBOMx4Ve4kt154MeGiGusTSpOpMMFrQ4SM,5191
|
778
|
+
tools/cli_commands/cost_report/cost_management_api.py,sha256=tFzjaZ3RSVJeAUsfSWXHiQlMVbV8dFcXibK158ODQcA,2549
|
779
|
+
tools/cli_commands/cost_report/model.py,sha256=KGkro82zoFXLmfQ4nv7dkv4QoNtOF-k6PT6VURCLZKE,548
|
780
|
+
tools/cli_commands/cost_report/openshift.py,sha256=XNEJpgtIQAi3Eoej1Btnl74IWg3g-gLh6W36WOwQ9bk,6188
|
781
|
+
tools/cli_commands/cost_report/response.py,sha256=_kbpBSjMjbPXGkjDgidTOLvFpVqfBo3VMkSOheUdmMA,1308
|
782
|
+
tools/cli_commands/cost_report/util.py,sha256=r4K8nC1S0YZNSUNro141cYG1nuG8HwkkYEqWV9GCvu8,1861
|
783
|
+
tools/cli_commands/cost_report/view.py,sha256=pKM6LDeWZJcTw2a7sWBwKSuR9p3SAk3lEb37uwMhlLw,10183
|
780
784
|
tools/sre_checkpoints/__init__.py,sha256=CDaDaywJnmRCLyl_NCcvxi-Zc0hTi_3OdwKiFOyS39I,145
|
781
785
|
tools/sre_checkpoints/util.py,sha256=zEDbGr18ZeHNQwW8pUsr2JRjuXIPz--WAGJxZo9sv_Y,894
|
782
786
|
tools/test/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
783
787
|
tools/test/test_app_interface_metrics_exporter.py,sha256=SX7qL3D1SIRKFo95FoQztvftCWEEf-g1mfXOtgCog-g,1271
|
784
|
-
tools/test/test_qontract_cli.py,sha256=
|
788
|
+
tools/test/test_qontract_cli.py,sha256=_D61RFGAN5x44CY1tYbouhlGXXABwYfxKSWSQx3Jrss,4941
|
785
789
|
tools/test/test_sd_app_sre_alert_report.py,sha256=v363r9zM7__0kR5K6mvJoGFcM9BvE33fWAayrqkpojA,2116
|
786
790
|
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.
|
791
|
+
qontract_reconcile-0.10.1rc802.dist-info/METADATA,sha256=ZX90nMGQNMCTXIBib8Objt33uoTAraXDTmTSXq6tdtQ,2314
|
792
|
+
qontract_reconcile-0.10.1rc802.dist-info/WHEEL,sha256=GJ7t_kWBFywbagK5eo9IoUwLW6oyOeTKmQ-9iHFVNxQ,92
|
793
|
+
qontract_reconcile-0.10.1rc802.dist-info/entry_points.txt,sha256=rIxI5zWtHNlfpDeq1a7pZXAPoqf7HG32KMTN3MeWK_8,429
|
794
|
+
qontract_reconcile-0.10.1rc802.dist-info/top_level.txt,sha256=l5ISPoXzt0SdR4jVdkfa7RPSKNc8zAHYWAnR-Dw8Ey8,24
|
795
|
+
qontract_reconcile-0.10.1rc802.dist-info/RECORD,,
|
@@ -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)
|
@@ -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
|
)
|
@@ -1,12 +1,13 @@
|
|
1
|
-
from typing import List
|
2
|
-
|
3
1
|
from urllib3.util import Retry
|
4
2
|
|
5
3
|
from reconcile.utils.oauth2_backend_application_session import (
|
6
4
|
OAuth2BackendApplicationSession,
|
7
5
|
)
|
8
6
|
from reconcile.utils.rest_api_base import ApiBase
|
9
|
-
from tools.cli_commands.cost_report.response import
|
7
|
+
from tools.cli_commands.cost_report.response import (
|
8
|
+
OpenShiftReportCostResponse,
|
9
|
+
ReportCostResponse,
|
10
|
+
)
|
10
11
|
|
11
12
|
REQUEST_TIMEOUT = 60
|
12
13
|
|
@@ -18,7 +19,7 @@ class CostManagementApi(ApiBase):
|
|
18
19
|
token_url: str,
|
19
20
|
client_id: str,
|
20
21
|
client_secret: str,
|
21
|
-
scope:
|
22
|
+
scope: list[str] | None = None,
|
22
23
|
) -> None:
|
23
24
|
session = OAuth2BackendApplicationSession(
|
24
25
|
client_id=client_id,
|
@@ -56,3 +57,26 @@ class CostManagementApi(ApiBase):
|
|
56
57
|
)
|
57
58
|
response.raise_for_status()
|
58
59
|
return ReportCostResponse.parse_obj(response.json())
|
60
|
+
|
61
|
+
def get_openshift_costs_report(
|
62
|
+
self,
|
63
|
+
cluster: str,
|
64
|
+
project: str,
|
65
|
+
) -> OpenShiftReportCostResponse:
|
66
|
+
params = {
|
67
|
+
"delta": "cost",
|
68
|
+
"filter[resolution]": "monthly",
|
69
|
+
"filter[cluster]": cluster,
|
70
|
+
"filter[exact:project]": project,
|
71
|
+
"filter[time_scope_units]": "month",
|
72
|
+
"filter[time_scope_value]": "-2",
|
73
|
+
"group_by[project]": "*",
|
74
|
+
}
|
75
|
+
response = self.session.request(
|
76
|
+
method="GET",
|
77
|
+
url=f"{self.host}/reports/openshift/costs/",
|
78
|
+
params=params,
|
79
|
+
timeout=self.read_timeout,
|
80
|
+
)
|
81
|
+
response.raise_for_status()
|
82
|
+
return OpenShiftReportCostResponse.parse_obj(response.json())
|
@@ -1,11 +1,10 @@
|
|
1
1
|
from decimal import Decimal
|
2
|
-
from typing import List
|
3
2
|
|
4
3
|
from pydantic import BaseModel
|
5
4
|
|
6
5
|
|
7
|
-
class
|
8
|
-
|
6
|
+
class ReportItem(BaseModel):
|
7
|
+
name: str
|
9
8
|
delta_value: Decimal
|
10
9
|
delta_percent: float | None
|
11
10
|
total: Decimal
|
@@ -18,12 +17,12 @@ class ChildAppReport(BaseModel):
|
|
18
17
|
|
19
18
|
class Report(BaseModel):
|
20
19
|
app_name: str
|
21
|
-
child_apps:
|
20
|
+
child_apps: list[ChildAppReport]
|
22
21
|
child_apps_total: Decimal
|
23
22
|
date: str
|
24
23
|
parent_app_name: str | None
|
25
|
-
|
26
|
-
|
27
|
-
|
28
|
-
|
24
|
+
items: list[ReportItem]
|
25
|
+
items_delta_percent: float | None
|
26
|
+
items_delta_value: Decimal
|
27
|
+
items_total: Decimal
|
29
28
|
total: Decimal
|
@@ -0,0 +1,179 @@
|
|
1
|
+
from collections import defaultdict
|
2
|
+
from collections.abc import Iterable, Mapping
|
3
|
+
from decimal import Decimal
|
4
|
+
from typing import Self
|
5
|
+
|
6
|
+
from sretoolbox.utils import threaded
|
7
|
+
|
8
|
+
from reconcile.typed_queries.app_interface_vault_settings import (
|
9
|
+
get_app_interface_vault_settings,
|
10
|
+
)
|
11
|
+
from reconcile.typed_queries.cost_report.app_names import App, get_app_names
|
12
|
+
from reconcile.typed_queries.cost_report.cost_namespaces import (
|
13
|
+
CostNamespace,
|
14
|
+
get_cost_namespaces,
|
15
|
+
)
|
16
|
+
from reconcile.typed_queries.cost_report.settings import get_cost_report_settings
|
17
|
+
from reconcile.utils import gql
|
18
|
+
from reconcile.utils.secret_reader import create_secret_reader
|
19
|
+
from tools.cli_commands.cost_report.cost_management_api import CostManagementApi
|
20
|
+
from tools.cli_commands.cost_report.model import ChildAppReport, Report, ReportItem
|
21
|
+
from tools.cli_commands.cost_report.response import OpenShiftReportCostResponse
|
22
|
+
from tools.cli_commands.cost_report.util import process_reports
|
23
|
+
from tools.cli_commands.cost_report.view import render_openshift_cost_report
|
24
|
+
|
25
|
+
THREAD_POOL_SIZE = 10
|
26
|
+
|
27
|
+
|
28
|
+
class OpenShiftCostReportCommand:
|
29
|
+
def __init__(
|
30
|
+
self,
|
31
|
+
gql_api: gql.GqlApi,
|
32
|
+
cost_management_api: CostManagementApi,
|
33
|
+
thread_pool_size: int = THREAD_POOL_SIZE,
|
34
|
+
) -> None:
|
35
|
+
self.gql_api = gql_api
|
36
|
+
self.cost_management_api = cost_management_api
|
37
|
+
self.thread_pool_size = thread_pool_size
|
38
|
+
|
39
|
+
def execute(self) -> str:
|
40
|
+
apps = self.get_apps()
|
41
|
+
cost_namespaces = self.get_cost_namespaces()
|
42
|
+
responses = self.get_reports(cost_namespaces)
|
43
|
+
reports = self.process_reports(apps, responses)
|
44
|
+
return self.render(reports)
|
45
|
+
|
46
|
+
def get_apps(self) -> list[App]:
|
47
|
+
return get_app_names(self.gql_api)
|
48
|
+
|
49
|
+
def get_cost_namespaces(self) -> list[CostNamespace]:
|
50
|
+
return get_cost_namespaces(self.gql_api)
|
51
|
+
|
52
|
+
def _get_report(
|
53
|
+
self,
|
54
|
+
cost_namespace: CostNamespace,
|
55
|
+
) -> tuple[CostNamespace, OpenShiftReportCostResponse]:
|
56
|
+
cluster = (
|
57
|
+
cost_namespace.cluster_external_id
|
58
|
+
if cost_namespace.cluster_external_id is not None
|
59
|
+
else cost_namespace.cluster_name
|
60
|
+
)
|
61
|
+
response = self.cost_management_api.get_openshift_costs_report(
|
62
|
+
project=cost_namespace.name,
|
63
|
+
cluster=cluster,
|
64
|
+
)
|
65
|
+
return cost_namespace, response
|
66
|
+
|
67
|
+
def get_reports(
|
68
|
+
self,
|
69
|
+
cost_namespaces: Iterable[CostNamespace],
|
70
|
+
) -> dict[CostNamespace, OpenShiftReportCostResponse]:
|
71
|
+
"""
|
72
|
+
Fetch reports from cost management API
|
73
|
+
"""
|
74
|
+
results = threaded.run(self._get_report, cost_namespaces, self.thread_pool_size)
|
75
|
+
return dict(results)
|
76
|
+
|
77
|
+
def process_reports(
|
78
|
+
self,
|
79
|
+
apps: Iterable[App],
|
80
|
+
responses: Mapping[CostNamespace, OpenShiftReportCostResponse],
|
81
|
+
) -> dict[str, Report]:
|
82
|
+
"""
|
83
|
+
Build reports with parent-child app tree.
|
84
|
+
"""
|
85
|
+
app_responses = defaultdict(list)
|
86
|
+
for cost_namespace, response in responses.items():
|
87
|
+
app_responses[cost_namespace.app_name].append(response)
|
88
|
+
return process_reports(
|
89
|
+
apps,
|
90
|
+
app_responses,
|
91
|
+
report_builder=self._build_report,
|
92
|
+
)
|
93
|
+
|
94
|
+
@staticmethod
|
95
|
+
def render(
|
96
|
+
reports: Mapping[str, Report],
|
97
|
+
) -> str:
|
98
|
+
return render_openshift_cost_report(reports=reports)
|
99
|
+
|
100
|
+
@staticmethod
|
101
|
+
def _build_report(
|
102
|
+
app_name: str,
|
103
|
+
parent_app_name: str,
|
104
|
+
child_apps: list[str],
|
105
|
+
reports: Mapping[str, Report],
|
106
|
+
response: list[OpenShiftReportCostResponse],
|
107
|
+
) -> Report:
|
108
|
+
child_app_reports = [
|
109
|
+
ChildAppReport(
|
110
|
+
name=child_app,
|
111
|
+
total=reports[child_app].total,
|
112
|
+
)
|
113
|
+
for child_app in child_apps
|
114
|
+
]
|
115
|
+
child_apps_total = Decimal(
|
116
|
+
sum(child_app.total for child_app in child_app_reports)
|
117
|
+
)
|
118
|
+
|
119
|
+
items = [
|
120
|
+
ReportItem(
|
121
|
+
name=f"{value.clusters[0]}/{project.project}",
|
122
|
+
delta_value=value.delta_value,
|
123
|
+
delta_percent=value.delta_percent,
|
124
|
+
total=value.cost.total.value,
|
125
|
+
)
|
126
|
+
for r in response
|
127
|
+
for data in r.data
|
128
|
+
for project in data.projects
|
129
|
+
if len(project.values) == 1 and (value := project.values[0]) is not None
|
130
|
+
]
|
131
|
+
|
132
|
+
items_total = Decimal(sum(item.total for item in items))
|
133
|
+
items_delta_value = Decimal(sum(item.delta_value for item in items))
|
134
|
+
previous_items_total = items_total - items_delta_value
|
135
|
+
items_delta_percent = (
|
136
|
+
items_delta_value / previous_items_total * 100
|
137
|
+
if previous_items_total != 0
|
138
|
+
else None
|
139
|
+
)
|
140
|
+
total = items_total + child_apps_total
|
141
|
+
date = next(
|
142
|
+
(d for r in response for data in r.data if (d := data.date)),
|
143
|
+
"",
|
144
|
+
)
|
145
|
+
|
146
|
+
return Report(
|
147
|
+
app_name=app_name,
|
148
|
+
child_apps=child_app_reports,
|
149
|
+
child_apps_total=child_apps_total,
|
150
|
+
date=date,
|
151
|
+
parent_app_name=parent_app_name,
|
152
|
+
items_delta_value=items_delta_value,
|
153
|
+
items_delta_percent=items_delta_percent,
|
154
|
+
items_total=items_total,
|
155
|
+
total=total,
|
156
|
+
items=items,
|
157
|
+
)
|
158
|
+
|
159
|
+
@classmethod
|
160
|
+
def create(
|
161
|
+
cls,
|
162
|
+
) -> Self:
|
163
|
+
gql_api = gql.get_api()
|
164
|
+
vault_settings = get_app_interface_vault_settings(gql_api.query)
|
165
|
+
secret_reader = create_secret_reader(use_vault=vault_settings.vault)
|
166
|
+
cost_report_settings = get_cost_report_settings(gql_api)
|
167
|
+
secret = secret_reader.read_all_secret(cost_report_settings.credentials)
|
168
|
+
cost_management_api = CostManagementApi(
|
169
|
+
base_url=secret["api_base_url"],
|
170
|
+
token_url=secret["token_url"],
|
171
|
+
client_id=secret["client_id"],
|
172
|
+
client_secret=secret["client_secret"],
|
173
|
+
scope=secret["scope"].split(" "),
|
174
|
+
)
|
175
|
+
return cls(
|
176
|
+
gql_api=gql_api,
|
177
|
+
cost_management_api=cost_management_api,
|
178
|
+
thread_pool_size=THREAD_POOL_SIZE,
|
179
|
+
)
|
@@ -1,5 +1,4 @@
|
|
1
1
|
from decimal import Decimal
|
2
|
-
from typing import List
|
3
2
|
|
4
3
|
from pydantic import BaseModel
|
5
4
|
|
@@ -35,14 +34,36 @@ class ServiceCostValueResponse(BaseModel):
|
|
35
34
|
|
36
35
|
class ServiceCostResponse(BaseModel):
|
37
36
|
service: str
|
38
|
-
values:
|
37
|
+
values: list[ServiceCostValueResponse]
|
39
38
|
|
40
39
|
|
41
40
|
class CostResponse(BaseModel):
|
42
41
|
date: str
|
43
|
-
services:
|
42
|
+
services: list[ServiceCostResponse]
|
44
43
|
|
45
44
|
|
46
45
|
class ReportCostResponse(BaseModel):
|
47
46
|
meta: ReportMetaResponse
|
48
|
-
data:
|
47
|
+
data: list[CostResponse]
|
48
|
+
|
49
|
+
|
50
|
+
class ProjectCostValueResponse(BaseModel):
|
51
|
+
delta_value: Decimal
|
52
|
+
delta_percent: float | None
|
53
|
+
cost: CostTotalResponse
|
54
|
+
clusters: list[str]
|
55
|
+
|
56
|
+
|
57
|
+
class ProjectCostResponse(BaseModel):
|
58
|
+
project: str
|
59
|
+
values: list[ProjectCostValueResponse]
|
60
|
+
|
61
|
+
|
62
|
+
class OpenShiftCostResponse(BaseModel):
|
63
|
+
date: str
|
64
|
+
projects: list[ProjectCostResponse]
|
65
|
+
|
66
|
+
|
67
|
+
class OpenShiftReportCostResponse(BaseModel):
|
68
|
+
meta: ReportMetaResponse
|
69
|
+
data: list[OpenShiftCostResponse]
|
@@ -0,0 +1,59 @@
|
|
1
|
+
from collections import defaultdict
|
2
|
+
from collections.abc import Callable, Iterable, Mapping, MutableMapping
|
3
|
+
from typing import Any
|
4
|
+
|
5
|
+
from reconcile.typed_queries.cost_report.app_names import App
|
6
|
+
from tools.cli_commands.cost_report.model import Report
|
7
|
+
|
8
|
+
|
9
|
+
def dfs_build_reports(
|
10
|
+
app_name: str,
|
11
|
+
parent_app_name: str | None,
|
12
|
+
child_apps_by_parent: Mapping[str | None, list[str]],
|
13
|
+
responses: Mapping[str, Any],
|
14
|
+
reports: MutableMapping[str, Report],
|
15
|
+
report_builder: Callable[..., Report],
|
16
|
+
) -> None:
|
17
|
+
"""
|
18
|
+
Depth-first search to build the reports. Build leaf nodes first to ensure total is calculated correctly.
|
19
|
+
"""
|
20
|
+
child_apps = child_apps_by_parent.get(app_name, [])
|
21
|
+
for child_app in child_apps:
|
22
|
+
dfs_build_reports(
|
23
|
+
app_name=child_app,
|
24
|
+
parent_app_name=app_name,
|
25
|
+
child_apps_by_parent=child_apps_by_parent,
|
26
|
+
responses=responses,
|
27
|
+
reports=reports,
|
28
|
+
report_builder=report_builder,
|
29
|
+
)
|
30
|
+
reports[app_name] = report_builder(
|
31
|
+
app_name=app_name,
|
32
|
+
parent_app_name=parent_app_name,
|
33
|
+
child_apps=child_apps,
|
34
|
+
reports=reports,
|
35
|
+
response=responses[app_name],
|
36
|
+
)
|
37
|
+
|
38
|
+
|
39
|
+
def process_reports(
|
40
|
+
apps: Iterable[App],
|
41
|
+
responses: Mapping[str, Any],
|
42
|
+
report_builder: Callable[..., Report],
|
43
|
+
) -> dict[str, Report]:
|
44
|
+
child_apps_by_parent = defaultdict(list)
|
45
|
+
for app in apps:
|
46
|
+
child_apps_by_parent[app.parent_app_name].append(app.name)
|
47
|
+
|
48
|
+
reports: dict[str, Report] = {}
|
49
|
+
root_apps = child_apps_by_parent.get(None, [])
|
50
|
+
for app_name in root_apps:
|
51
|
+
dfs_build_reports(
|
52
|
+
app_name,
|
53
|
+
None,
|
54
|
+
child_apps_by_parent=child_apps_by_parent,
|
55
|
+
responses=responses,
|
56
|
+
reports=reports,
|
57
|
+
report_builder=report_builder,
|
58
|
+
)
|
59
|
+
return reports
|
@@ -1,4 +1,4 @@
|
|
1
|
-
from collections.abc import Mapping
|
1
|
+
from collections.abc import Callable, Mapping
|
2
2
|
from decimal import Decimal
|
3
3
|
from typing import Any
|
4
4
|
|
@@ -15,11 +15,15 @@ LAYOUT = """\
|
|
15
15
|
{cost_breakdown}\
|
16
16
|
"""
|
17
17
|
|
18
|
-
|
18
|
+
AWS_HEADER = """\
|
19
19
|
# AWS Cost Report
|
20
20
|
"""
|
21
21
|
|
22
|
-
|
22
|
+
OPENSHIFT_HEADER = """\
|
23
|
+
# OpenShift Cost Report
|
24
|
+
"""
|
25
|
+
|
26
|
+
AWS_SUMMARY = """\
|
23
27
|
## Summary
|
24
28
|
|
25
29
|
Total AWS Cost for {date}: {total_cost}
|
@@ -29,6 +33,16 @@ Total AWS Cost for {date}: {total_cost}
|
|
29
33
|
```
|
30
34
|
"""
|
31
35
|
|
36
|
+
OPENSHIFT_SUMMARY = """\
|
37
|
+
## Summary
|
38
|
+
|
39
|
+
Total OpenShift Cost for {date}: {total_cost}
|
40
|
+
|
41
|
+
```json:table
|
42
|
+
{json_table}
|
43
|
+
```
|
44
|
+
"""
|
45
|
+
|
32
46
|
MONTH_OVER_MONTH_CHANGE = """\
|
33
47
|
## Month Over Month Change
|
34
48
|
|
@@ -52,7 +66,7 @@ APP = """\
|
|
52
66
|
"""
|
53
67
|
|
54
68
|
AWS_SERVICES_COST = """\
|
55
|
-
AWS Services Cost: {
|
69
|
+
AWS Services Cost: {items_total}, {items_delta_value}{items_delta_percent} \
|
56
70
|
compared to previous month.
|
57
71
|
View in [Cost Management Console]({cost_management_console_url}).
|
58
72
|
|
@@ -61,6 +75,15 @@ View in [Cost Management Console]({cost_management_console_url}).
|
|
61
75
|
```
|
62
76
|
"""
|
63
77
|
|
78
|
+
OPENSHIFT_WORKLOADS_COST = """\
|
79
|
+
OpenShift Workloads Cost: {items_total}, {items_delta_value}{items_delta_percent} \
|
80
|
+
compared to previous month.
|
81
|
+
|
82
|
+
```json:table
|
83
|
+
{json_table}
|
84
|
+
```
|
85
|
+
"""
|
86
|
+
|
64
87
|
COST_MANAGEMENT_CONSOLE_EXPLORE_URL = (
|
65
88
|
"{base_url}/explorer?"
|
66
89
|
"dateRangeType=previous_month&"
|
@@ -100,7 +123,7 @@ class JsonTable(BaseModel):
|
|
100
123
|
class SummaryItem(BaseModel):
|
101
124
|
name: str
|
102
125
|
child_apps_total: Decimal
|
103
|
-
|
126
|
+
items_total: Decimal
|
104
127
|
total: Decimal
|
105
128
|
|
106
129
|
|
@@ -111,6 +134,18 @@ class MonthOverMonthChangeItem(BaseModel):
|
|
111
134
|
total: Decimal
|
112
135
|
|
113
136
|
|
137
|
+
class ViewReportItem(BaseModel):
|
138
|
+
name: str
|
139
|
+
delta_value: Decimal
|
140
|
+
delta_percent: float | None
|
141
|
+
total: Decimal
|
142
|
+
|
143
|
+
|
144
|
+
class ViewChildAppReport(BaseModel):
|
145
|
+
name: str
|
146
|
+
total: Decimal
|
147
|
+
|
148
|
+
|
114
149
|
def format_cost_value(value: Decimal) -> str:
|
115
150
|
return f"${value:,.2f}"
|
116
151
|
|
@@ -132,7 +167,10 @@ def get_date(reports: Mapping[str, Report]) -> str:
|
|
132
167
|
return next((d for report in reports.values() if (d := report.date)), "")
|
133
168
|
|
134
169
|
|
135
|
-
def render_summary(
|
170
|
+
def render_summary(
|
171
|
+
template: str,
|
172
|
+
reports: Mapping[str, Report],
|
173
|
+
) -> str:
|
136
174
|
root_apps = {
|
137
175
|
name: report
|
138
176
|
for name, report in reports.items()
|
@@ -143,7 +181,7 @@ def render_summary(reports: Mapping[str, Report]) -> str:
|
|
143
181
|
SummaryItem(
|
144
182
|
name=name,
|
145
183
|
child_apps_total=round(report.child_apps_total, 2),
|
146
|
-
|
184
|
+
items_total=round(report.items_total, 2),
|
147
185
|
total=round(report.total, 2),
|
148
186
|
)
|
149
187
|
for name, report in root_apps.items()
|
@@ -153,12 +191,12 @@ def render_summary(reports: Mapping[str, Report]) -> str:
|
|
153
191
|
items=sorted(summary_items, key=lambda item: item.total, reverse=True),
|
154
192
|
fields=[
|
155
193
|
TableField(key="name", label="Name", sortable=True),
|
156
|
-
TableField(key="
|
194
|
+
TableField(key="items_total", label="Self App ($)", sortable=True),
|
157
195
|
TableField(key="child_apps_total", label="Child Apps ($)", sortable=True),
|
158
196
|
TableField(key="total", label="Total ($)", sortable=True),
|
159
197
|
],
|
160
198
|
)
|
161
|
-
return
|
199
|
+
return template.format(
|
162
200
|
date=get_date(reports),
|
163
201
|
total_cost=format_cost_value(total_cost),
|
164
202
|
json_table=json_table.json(indent=2),
|
@@ -169,13 +207,13 @@ def render_month_over_month_change(reports: Mapping[str, Report]) -> str:
|
|
169
207
|
items = [
|
170
208
|
MonthOverMonthChangeItem(
|
171
209
|
name=name,
|
172
|
-
delta_value=round(report.
|
210
|
+
delta_value=round(report.items_delta_value, 2),
|
173
211
|
delta_percent=(
|
174
|
-
round(report.
|
175
|
-
if report.
|
212
|
+
round(report.items_delta_percent, 2)
|
213
|
+
if report.items_delta_percent is not None
|
176
214
|
else None
|
177
215
|
),
|
178
|
-
total=round(report.
|
216
|
+
total=round(report.items_total, 2),
|
179
217
|
)
|
180
218
|
for name, report in reports.items()
|
181
219
|
]
|
@@ -212,46 +250,60 @@ def render_aws_services_cost(
|
|
212
250
|
report: Report,
|
213
251
|
cost_management_console_base_url: str,
|
214
252
|
) -> str:
|
215
|
-
|
216
|
-
|
217
|
-
|
218
|
-
|
219
|
-
|
220
|
-
|
221
|
-
|
222
|
-
|
223
|
-
|
253
|
+
json_table = _build_items_cost_json_table(report, name_label="Service")
|
254
|
+
return AWS_SERVICES_COST.format(
|
255
|
+
cost_management_console_url=build_cost_management_console_url(
|
256
|
+
cost_management_console_base_url,
|
257
|
+
report.app_name,
|
258
|
+
),
|
259
|
+
items_total=format_cost_value(report.items_total),
|
260
|
+
items_delta_value=format_delta_value(report.items_delta_value),
|
261
|
+
items_delta_percent=format_delta_percent(report.items_delta_percent),
|
262
|
+
json_table=json_table.json(indent=2),
|
263
|
+
)
|
264
|
+
|
265
|
+
|
266
|
+
def render_openshift_workloads_cost(
|
267
|
+
report: Report,
|
268
|
+
) -> str:
|
269
|
+
json_table = _build_items_cost_json_table(report, name_label="Cluster/Namespace")
|
270
|
+
return OPENSHIFT_WORKLOADS_COST.format(
|
271
|
+
items_total=format_cost_value(report.items_total),
|
272
|
+
items_delta_value=format_delta_value(report.items_delta_value),
|
273
|
+
items_delta_percent=format_delta_percent(report.items_delta_percent),
|
274
|
+
json_table=json_table.json(indent=2),
|
275
|
+
)
|
276
|
+
|
277
|
+
|
278
|
+
def _build_items_cost_json_table(report: Report, name_label: str) -> JsonTable:
|
279
|
+
items = [
|
280
|
+
ViewReportItem(
|
281
|
+
name=s.name,
|
282
|
+
delta_value=round(s.delta_value, 2),
|
283
|
+
delta_percent=round(s.delta_percent, 2)
|
284
|
+
if s.delta_percent is not None
|
285
|
+
else None,
|
286
|
+
total=round(s.total, 2),
|
224
287
|
)
|
225
|
-
for s in report.
|
288
|
+
for s in report.items
|
226
289
|
]
|
227
|
-
|
290
|
+
return JsonTable(
|
228
291
|
filter=True,
|
229
|
-
items=sorted(
|
292
|
+
items=sorted(items, key=lambda item: item.total, reverse=True),
|
230
293
|
fields=[
|
231
|
-
TableField(key="
|
294
|
+
TableField(key="name", label=name_label, sortable=True),
|
232
295
|
TableField(key="delta_value", label="Change ($)", sortable=True),
|
233
296
|
TableField(key="delta_percent", label="Change (%)", sortable=True),
|
234
297
|
TableField(key="total", label="Total ($)", sortable=True),
|
235
298
|
],
|
236
299
|
)
|
237
|
-
return AWS_SERVICES_COST.format(
|
238
|
-
cost_management_console_url=build_cost_management_console_url(
|
239
|
-
cost_management_console_base_url,
|
240
|
-
report.app_name,
|
241
|
-
),
|
242
|
-
services_total=format_cost_value(report.services_total),
|
243
|
-
services_delta_value=format_delta_value(report.services_delta_value),
|
244
|
-
services_delta_percent=format_delta_percent(report.services_delta_percent),
|
245
|
-
json_table=json_table.json(indent=2),
|
246
|
-
)
|
247
300
|
|
248
301
|
|
249
302
|
def render_child_apps_cost(report: Report) -> str:
|
250
303
|
child_apps = [
|
251
|
-
|
252
|
-
|
253
|
-
|
254
|
-
}
|
304
|
+
ViewChildAppReport(
|
305
|
+
name=app.name,
|
306
|
+
total=round(app.total, 2),
|
255
307
|
)
|
256
308
|
for app in report.child_apps
|
257
309
|
]
|
@@ -278,16 +330,12 @@ def render_total_cost(report: Report) -> str:
|
|
278
330
|
def render_app_cost(
|
279
331
|
name: str,
|
280
332
|
report: Report,
|
281
|
-
|
333
|
+
item_cost_renderer: Callable[..., str],
|
334
|
+
**kwargs: Any,
|
282
335
|
) -> str:
|
283
336
|
cost_details_sections = []
|
284
|
-
if report.
|
285
|
-
cost_details_sections.append(
|
286
|
-
render_aws_services_cost(
|
287
|
-
report,
|
288
|
-
cost_management_console_base_url,
|
289
|
-
)
|
290
|
-
)
|
337
|
+
if report.items:
|
338
|
+
cost_details_sections.append(item_cost_renderer(report=report, **kwargs))
|
291
339
|
if report.child_apps:
|
292
340
|
cost_details_sections.append(render_child_apps_cost(report))
|
293
341
|
cost_details_sections.append(render_total_cost(report))
|
@@ -302,13 +350,15 @@ def render_app_cost(
|
|
302
350
|
|
303
351
|
def render_cost_breakdown(
|
304
352
|
reports: Mapping[str, Report],
|
305
|
-
|
353
|
+
item_cost_renderer: Callable[..., str],
|
354
|
+
**kwargs: Any,
|
306
355
|
) -> str:
|
307
356
|
apps = "\n".join(
|
308
357
|
render_app_cost(
|
309
|
-
name,
|
310
|
-
report,
|
311
|
-
|
358
|
+
name=name,
|
359
|
+
report=report,
|
360
|
+
item_cost_renderer=item_cost_renderer,
|
361
|
+
**kwargs,
|
312
362
|
)
|
313
363
|
for name, report in sorted(
|
314
364
|
reports.items(),
|
@@ -318,16 +368,31 @@ def render_cost_breakdown(
|
|
318
368
|
return COST_BREAKDOWN.format(apps=apps)
|
319
369
|
|
320
370
|
|
321
|
-
def
|
371
|
+
def render_aws_cost_report(
|
322
372
|
reports: Mapping[str, Report],
|
323
373
|
cost_management_console_base_url: str,
|
324
374
|
) -> str:
|
325
375
|
return LAYOUT.format(
|
326
|
-
header=
|
327
|
-
summary=render_summary(reports),
|
376
|
+
header=AWS_HEADER,
|
377
|
+
summary=render_summary(AWS_SUMMARY, reports),
|
328
378
|
month_over_month_change=render_month_over_month_change(reports),
|
329
379
|
cost_breakdown=render_cost_breakdown(
|
330
380
|
reports,
|
331
|
-
|
381
|
+
item_cost_renderer=render_aws_services_cost,
|
382
|
+
cost_management_console_base_url=cost_management_console_base_url,
|
383
|
+
),
|
384
|
+
)
|
385
|
+
|
386
|
+
|
387
|
+
def render_openshift_cost_report(
|
388
|
+
reports: Mapping[str, Report],
|
389
|
+
) -> str:
|
390
|
+
return LAYOUT.format(
|
391
|
+
header=OPENSHIFT_HEADER,
|
392
|
+
summary=render_summary(OPENSHIFT_SUMMARY, reports),
|
393
|
+
month_over_month_change=render_month_over_month_change(reports),
|
394
|
+
cost_breakdown=render_cost_breakdown(
|
395
|
+
reports,
|
396
|
+
item_cost_renderer=render_openshift_workloads_cost,
|
332
397
|
),
|
333
398
|
)
|
tools/qontract_cli.py
CHANGED
@@ -136,7 +136,8 @@ from reconcile.utils.secret_reader import (
|
|
136
136
|
from reconcile.utils.semver_helper import parse_semver
|
137
137
|
from reconcile.utils.state import init_state
|
138
138
|
from reconcile.utils.terraform_client import TerraformClient as Terraform
|
139
|
-
from tools.cli_commands.cost_report.
|
139
|
+
from tools.cli_commands.cost_report.aws import AwsCostReportCommand
|
140
|
+
from tools.cli_commands.cost_report.openshift import OpenShiftCostReportCommand
|
140
141
|
from tools.cli_commands.gpg_encrypt import (
|
141
142
|
GPGEncryptCommand,
|
142
143
|
GPGEncryptCommandData,
|
@@ -2538,7 +2539,14 @@ def alerts(ctx, file_path):
|
|
2538
2539
|
@get.command()
|
2539
2540
|
@click.pass_context
|
2540
2541
|
def aws_cost_report(ctx):
|
2541
|
-
command =
|
2542
|
+
command = AwsCostReportCommand.create()
|
2543
|
+
print(command.execute())
|
2544
|
+
|
2545
|
+
|
2546
|
+
@get.command()
|
2547
|
+
@click.pass_context
|
2548
|
+
def openshift_cost_report(ctx):
|
2549
|
+
command = OpenShiftCostReportCommand.create()
|
2542
2550
|
print(command.execute())
|
2543
2551
|
|
2544
2552
|
|
tools/test/test_qontract_cli.py
CHANGED
@@ -128,12 +128,14 @@ def test_early_exit_cache_delete(env_vars, mock_queries, mock_early_exit_cache):
|
|
128
128
|
|
129
129
|
|
130
130
|
@pytest.fixture
|
131
|
-
def
|
132
|
-
return mocker.patch("tools.qontract_cli.
|
131
|
+
def mock_aws_cost_report_command(mocker):
|
132
|
+
return mocker.patch("tools.qontract_cli.AwsCostReportCommand", autospec=True)
|
133
133
|
|
134
134
|
|
135
|
-
def test_get_aws_cost_report(env_vars, mock_queries,
|
136
|
-
|
135
|
+
def test_get_aws_cost_report(env_vars, mock_queries, mock_aws_cost_report_command):
|
136
|
+
mock_aws_cost_report_command.create.return_value.execute.return_value = (
|
137
|
+
"some report"
|
138
|
+
)
|
137
139
|
runner = CliRunner()
|
138
140
|
result = runner.invoke(
|
139
141
|
qontract_cli.get,
|
@@ -143,5 +145,29 @@ def test_get_aws_cost_report(env_vars, mock_queries, mock_cost_report_command):
|
|
143
145
|
|
144
146
|
assert result.exit_code == 0
|
145
147
|
assert result.output == "some report\n"
|
146
|
-
|
147
|
-
|
148
|
+
mock_aws_cost_report_command.create.assert_called_once_with()
|
149
|
+
mock_aws_cost_report_command.create.return_value.execute.assert_called_once_with()
|
150
|
+
|
151
|
+
|
152
|
+
@pytest.fixture
|
153
|
+
def mock_openshift_cost_report_command(mocker):
|
154
|
+
return mocker.patch("tools.qontract_cli.OpenShiftCostReportCommand", autospec=True)
|
155
|
+
|
156
|
+
|
157
|
+
def test_get_openshift_cost_report(
|
158
|
+
env_vars, mock_queries, mock_openshift_cost_report_command
|
159
|
+
):
|
160
|
+
mock_openshift_cost_report_command.create.return_value.execute.return_value = (
|
161
|
+
"some report"
|
162
|
+
)
|
163
|
+
runner = CliRunner()
|
164
|
+
result = runner.invoke(
|
165
|
+
qontract_cli.get,
|
166
|
+
"openshift-cost-report",
|
167
|
+
obj={},
|
168
|
+
)
|
169
|
+
|
170
|
+
assert result.exit_code == 0
|
171
|
+
assert result.output == "some report\n"
|
172
|
+
mock_openshift_cost_report_command.create.assert_called_once_with()
|
173
|
+
mock_openshift_cost_report_command.create.return_value.execute.assert_called_once_with()
|
File without changes
|
File without changes
|
{qontract_reconcile-0.10.1rc801.dist-info → qontract_reconcile-0.10.1rc802.dist-info}/top_level.txt
RENAMED
File without changes
|