qontract-reconcile 0.10.1rc696__py3-none-any.whl → 0.10.1rc702__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.1rc696.dist-info → qontract_reconcile-0.10.1rc702.dist-info}/METADATA +1 -1
- {qontract_reconcile-0.10.1rc696.dist-info → qontract_reconcile-0.10.1rc702.dist-info}/RECORD +42 -18
- reconcile/aws_account_manager/__init__.py +0 -0
- reconcile/aws_account_manager/integration.py +342 -0
- reconcile/aws_account_manager/merge_request_manager.py +111 -0
- reconcile/aws_account_manager/reconciler.py +353 -0
- reconcile/aws_account_manager/utils.py +38 -0
- reconcile/aws_saml_idp/integration.py +2 -0
- reconcile/aws_version_sync/integration.py +12 -11
- reconcile/aws_version_sync/merge_request_manager/merge_request_manager.py +39 -112
- reconcile/cli.py +79 -0
- reconcile/gql_definitions/aws_account_manager/__init__.py +0 -0
- reconcile/gql_definitions/aws_account_manager/aws_accounts.py +163 -0
- reconcile/gql_definitions/cost_report/__init__.py +0 -0
- reconcile/gql_definitions/cost_report/app_names.py +68 -0
- reconcile/gql_definitions/cost_report/settings.py +77 -0
- reconcile/gql_definitions/fragments/aws_account_managed.py +49 -0
- reconcile/queries.py +7 -1
- reconcile/templating/lib/merge_request_manager.py +8 -82
- reconcile/templating/renderer.py +2 -2
- reconcile/typed_queries/cost_report/__init__.py +0 -0
- reconcile/typed_queries/cost_report/app_names.py +22 -0
- reconcile/typed_queries/cost_report/settings.py +15 -0
- reconcile/utils/aws_api_typed/api.py +49 -6
- reconcile/utils/aws_api_typed/iam.py +22 -7
- reconcile/utils/aws_api_typed/organization.py +78 -30
- reconcile/utils/aws_api_typed/service_quotas.py +79 -0
- reconcile/utils/aws_api_typed/support.py +79 -0
- reconcile/utils/merge_request_manager/merge_request_manager.py +102 -0
- reconcile/utils/oauth2_backend_application_session.py +102 -0
- reconcile/utils/state.py +42 -38
- tools/cli_commands/cost_report/__init__.py +0 -0
- tools/cli_commands/cost_report/command.py +172 -0
- tools/cli_commands/cost_report/cost_management_api.py +57 -0
- tools/cli_commands/cost_report/model.py +29 -0
- tools/cli_commands/cost_report/response.py +48 -0
- tools/cli_commands/cost_report/view.py +333 -0
- tools/qontract_cli.py +10 -2
- tools/test/test_qontract_cli.py +20 -0
- {qontract_reconcile-0.10.1rc696.dist-info → qontract_reconcile-0.10.1rc702.dist-info}/WHEEL +0 -0
- {qontract_reconcile-0.10.1rc696.dist-info → qontract_reconcile-0.10.1rc702.dist-info}/entry_points.txt +0 -0
- {qontract_reconcile-0.10.1rc696.dist-info → qontract_reconcile-0.10.1rc702.dist-info}/top_level.txt +0 -0
reconcile/utils/state.py
CHANGED
@@ -1,9 +1,10 @@
|
|
1
|
+
import contextlib
|
1
2
|
import json
|
2
3
|
import logging
|
3
4
|
import os
|
4
5
|
from abc import abstractmethod
|
5
|
-
from collections.abc import Callable, Mapping
|
6
|
-
from
|
6
|
+
from collections.abc import Callable, Generator, Mapping
|
7
|
+
from dataclasses import dataclass, field
|
7
8
|
from typing import (
|
8
9
|
Any,
|
9
10
|
Optional,
|
@@ -212,6 +213,10 @@ def acquire_state_settings(
|
|
212
213
|
)
|
213
214
|
|
214
215
|
|
216
|
+
class AbortStateTransaction(Exception):
|
217
|
+
"""Raise to abort a state transaction."""
|
218
|
+
|
219
|
+
|
215
220
|
class State:
|
216
221
|
"""
|
217
222
|
A state object to be used by stateful integrations.
|
@@ -405,52 +410,51 @@ class State:
|
|
405
410
|
def __setitem__(self, key: str, value: Any) -> None:
|
406
411
|
self._set(key, value)
|
407
412
|
|
408
|
-
|
413
|
+
@contextlib.contextmanager
|
414
|
+
def transaction(
|
415
|
+
self, key: str, value: Any = None
|
416
|
+
) -> Generator["TransactionStateObj", None, None]:
|
409
417
|
"""Get a context manager to set the key in the state if no exception occurs.
|
410
418
|
|
419
|
+
You can set the value either via the value parameter or by setting the value attribute of the returned object.
|
420
|
+
If both are provided, the value attribute of the state object will take precedence.
|
421
|
+
|
411
422
|
Attention!
|
412
423
|
|
413
424
|
This is not a locking mechanism. It is a way to ensure that a key is set in the state if no exception occurs.
|
414
425
|
This method is not thread-safe nor multi-process-safe! There is no locking mechanism in place.
|
415
426
|
"""
|
416
|
-
|
427
|
+
try:
|
428
|
+
_current_value = self[key]
|
429
|
+
except KeyError:
|
430
|
+
_current_value = None
|
431
|
+
state_obj = TransactionStateObj(key, value=_current_value)
|
432
|
+
try:
|
433
|
+
yield state_obj
|
434
|
+
except AbortStateTransaction:
|
435
|
+
return
|
436
|
+
else:
|
437
|
+
if state_obj.changed and state_obj.value != _current_value:
|
438
|
+
self[state_obj.key] = state_obj.value
|
439
|
+
elif value is not None and state_obj.value != value:
|
440
|
+
self[state_obj.key] = value
|
417
441
|
|
418
442
|
|
419
|
-
|
420
|
-
|
443
|
+
@dataclass
|
444
|
+
class TransactionStateObj:
|
445
|
+
"""Represents a transistion state object with a key and a value."""
|
421
446
|
|
422
|
-
|
423
|
-
|
424
|
-
|
425
|
-
key: str,
|
426
|
-
value: Any,
|
427
|
-
):
|
428
|
-
self.state = state
|
429
|
-
self.key = key
|
430
|
-
self.value = value
|
447
|
+
key: str
|
448
|
+
value: Any = None
|
449
|
+
_init_value: Any = field(init=False)
|
431
450
|
|
432
|
-
def
|
433
|
-
|
451
|
+
def __post_init__(self) -> None:
|
452
|
+
self._init_value = self.value
|
434
453
|
|
435
|
-
|
436
|
-
|
437
|
-
self.
|
438
|
-
try:
|
439
|
-
self._previous_value = self.state[self.key]
|
440
|
-
return True
|
441
|
-
except KeyError:
|
442
|
-
return False
|
454
|
+
@property
|
455
|
+
def changed(self) -> bool:
|
456
|
+
return self.value != self._init_value
|
443
457
|
|
444
|
-
|
445
|
-
|
446
|
-
|
447
|
-
exc_value: BaseException | None,
|
448
|
-
traceback: TracebackType | None,
|
449
|
-
) -> None:
|
450
|
-
if exc_type:
|
451
|
-
# if an exception occurred, we don't want to write to the state
|
452
|
-
return
|
453
|
-
if self._previous_value == self.value:
|
454
|
-
# if the value didn't change, we don't want to write to the state
|
455
|
-
return
|
456
|
-
self.state[self.key] = self.value
|
458
|
+
@property
|
459
|
+
def exists(self) -> bool:
|
460
|
+
return self._init_value is not None
|
File without changes
|
@@ -0,0 +1,172 @@
|
|
1
|
+
from collections import defaultdict
|
2
|
+
from collections.abc import Iterable, Mapping, MutableMapping
|
3
|
+
from decimal import Decimal
|
4
|
+
from typing import List, Self, Tuple
|
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.settings import get_cost_report_settings
|
13
|
+
from reconcile.utils import gql
|
14
|
+
from reconcile.utils.secret_reader import create_secret_reader
|
15
|
+
from tools.cli_commands.cost_report.cost_management_api import CostManagementApi
|
16
|
+
from tools.cli_commands.cost_report.model import ChildAppReport, Report, ServiceReport
|
17
|
+
from tools.cli_commands.cost_report.response import ReportCostResponse
|
18
|
+
from tools.cli_commands.cost_report.view import render_report
|
19
|
+
|
20
|
+
THREAD_POOL_SIZE = 10
|
21
|
+
|
22
|
+
|
23
|
+
class CostReportCommand:
|
24
|
+
def __init__(
|
25
|
+
self,
|
26
|
+
gql_api: gql.GqlApi,
|
27
|
+
cost_management_api: CostManagementApi,
|
28
|
+
cost_management_console_base_url: str,
|
29
|
+
) -> None:
|
30
|
+
self.gql_api = gql_api
|
31
|
+
self.cost_management_api = cost_management_api
|
32
|
+
self.cost_management_console_base_url = cost_management_console_base_url
|
33
|
+
|
34
|
+
def execute(self) -> str:
|
35
|
+
apps = self.get_apps()
|
36
|
+
reports = self.get_reports(apps)
|
37
|
+
return self.render(reports)
|
38
|
+
|
39
|
+
def get_apps(self) -> list[App]:
|
40
|
+
"""
|
41
|
+
Get all apps from the gql API.
|
42
|
+
"""
|
43
|
+
return get_app_names(self.gql_api)
|
44
|
+
|
45
|
+
def _fetch_report(self, app: App) -> Tuple[str, ReportCostResponse]:
|
46
|
+
return app.name, self.cost_management_api.get_aws_costs_report(app.name)
|
47
|
+
|
48
|
+
def _fetch_reports(self, apps: Iterable[App]) -> dict[str, ReportCostResponse]:
|
49
|
+
results = threaded.run(self._fetch_report, apps, THREAD_POOL_SIZE)
|
50
|
+
return dict(results)
|
51
|
+
|
52
|
+
def get_reports(self, apps: Iterable[App]) -> dict[str, Report]:
|
53
|
+
"""
|
54
|
+
Fetch reports from cost management API and build reports with parent-child app tree.
|
55
|
+
"""
|
56
|
+
responses = self._fetch_reports(apps)
|
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
|
+
)
|
79
|
+
|
80
|
+
def _dfs_reports(
|
81
|
+
self,
|
82
|
+
app_name: str,
|
83
|
+
parent_app_name: str | None,
|
84
|
+
child_apps_by_parent: Mapping[str | None, list[str]],
|
85
|
+
responses: Mapping[str, ReportCostResponse],
|
86
|
+
reports: MutableMapping[str, Report],
|
87
|
+
) -> None:
|
88
|
+
"""
|
89
|
+
Depth-first search to build the reports. Build leaf nodes first to ensure total is calculated correctly.
|
90
|
+
"""
|
91
|
+
child_apps = child_apps_by_parent.get(app_name, [])
|
92
|
+
for child_app in child_apps:
|
93
|
+
self._dfs_reports(
|
94
|
+
app_name=child_app,
|
95
|
+
parent_app_name=app_name,
|
96
|
+
child_apps_by_parent=child_apps_by_parent,
|
97
|
+
responses=responses,
|
98
|
+
reports=reports,
|
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,
|
104
|
+
reports=reports,
|
105
|
+
response=responses[app_name],
|
106
|
+
)
|
107
|
+
|
108
|
+
@staticmethod
|
109
|
+
def _build_report(
|
110
|
+
app_name: str,
|
111
|
+
parent_app_name: str | None,
|
112
|
+
child_apps: List[str],
|
113
|
+
reports: Mapping[str, Report],
|
114
|
+
response: ReportCostResponse,
|
115
|
+
) -> Report:
|
116
|
+
child_app_reports = [
|
117
|
+
ChildAppReport(
|
118
|
+
name=child_app,
|
119
|
+
total=reports[child_app].total,
|
120
|
+
)
|
121
|
+
for child_app in child_apps
|
122
|
+
]
|
123
|
+
child_apps_total = Decimal(
|
124
|
+
sum(child_app.total for child_app in child_app_reports)
|
125
|
+
)
|
126
|
+
services_total = response.meta.total.cost.total.value
|
127
|
+
total = services_total + child_apps_total
|
128
|
+
date = next((d for data in response.data if (d := data.date)), "")
|
129
|
+
return Report(
|
130
|
+
app_name=app_name,
|
131
|
+
child_apps=child_app_reports,
|
132
|
+
child_apps_total=child_apps_total,
|
133
|
+
date=date,
|
134
|
+
parent_app_name=parent_app_name,
|
135
|
+
services_delta_value=response.meta.delta.value,
|
136
|
+
services_delta_percent=response.meta.delta.percent,
|
137
|
+
services_total=services_total,
|
138
|
+
total=total,
|
139
|
+
services=[
|
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
|
+
],
|
150
|
+
)
|
151
|
+
|
152
|
+
@classmethod
|
153
|
+
def create(
|
154
|
+
cls,
|
155
|
+
) -> Self:
|
156
|
+
gql_api = gql.get_api()
|
157
|
+
vault_settings = get_app_interface_vault_settings(gql_api.query)
|
158
|
+
secret_reader = create_secret_reader(use_vault=vault_settings.vault)
|
159
|
+
cost_report_settings = get_cost_report_settings(gql_api)
|
160
|
+
secret = secret_reader.read_all_secret(cost_report_settings.credentials)
|
161
|
+
cost_management_api = CostManagementApi(
|
162
|
+
base_url=secret["api_base_url"],
|
163
|
+
token_url=secret["token_url"],
|
164
|
+
client_id=secret["client_id"],
|
165
|
+
client_secret=secret["client_secret"],
|
166
|
+
scope=secret["scope"].split(" "),
|
167
|
+
)
|
168
|
+
return cls(
|
169
|
+
gql_api=gql_api,
|
170
|
+
cost_management_api=cost_management_api,
|
171
|
+
cost_management_console_base_url=secret["console_base_url"],
|
172
|
+
)
|
@@ -0,0 +1,57 @@
|
|
1
|
+
from typing import Any, List, Self
|
2
|
+
|
3
|
+
from reconcile.utils.oauth2_backend_application_session import (
|
4
|
+
OAuth2BackendApplicationSession,
|
5
|
+
)
|
6
|
+
from tools.cli_commands.cost_report.response import ReportCostResponse
|
7
|
+
|
8
|
+
REQUEST_TIMEOUT = 60
|
9
|
+
|
10
|
+
|
11
|
+
class CostManagementApi:
|
12
|
+
def __init__(
|
13
|
+
self,
|
14
|
+
base_url: str,
|
15
|
+
token_url: str,
|
16
|
+
client_id: str,
|
17
|
+
client_secret: str,
|
18
|
+
scope: List[str] | None = None,
|
19
|
+
request_timeout: int | None = REQUEST_TIMEOUT,
|
20
|
+
) -> None:
|
21
|
+
self.base_url = base_url
|
22
|
+
self.request_timeout = request_timeout
|
23
|
+
self.session = OAuth2BackendApplicationSession(
|
24
|
+
client_id=client_id,
|
25
|
+
client_secret=client_secret,
|
26
|
+
token_url=token_url,
|
27
|
+
scope=scope,
|
28
|
+
)
|
29
|
+
|
30
|
+
def __enter__(self) -> Self:
|
31
|
+
return self
|
32
|
+
|
33
|
+
def __exit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None:
|
34
|
+
self.cleanup()
|
35
|
+
|
36
|
+
def cleanup(self) -> None:
|
37
|
+
self.session.close()
|
38
|
+
|
39
|
+
def get_aws_costs_report(self, app: str) -> ReportCostResponse:
|
40
|
+
params = {
|
41
|
+
"cost_type": "calculated_amortized_cost",
|
42
|
+
"delta": "cost",
|
43
|
+
"filter[resolution]": "monthly",
|
44
|
+
"filter[tag:app]": app,
|
45
|
+
"filter[time_scope_units]": "month",
|
46
|
+
"filter[time_scope_value]": "-2",
|
47
|
+
"group_by[service]": "*",
|
48
|
+
}
|
49
|
+
response = self.session.request(
|
50
|
+
method="GET",
|
51
|
+
url=f"{self.base_url}/reports/aws/costs/",
|
52
|
+
headers={"Content-Type": "application/json"},
|
53
|
+
params=params,
|
54
|
+
timeout=self.request_timeout,
|
55
|
+
)
|
56
|
+
response.raise_for_status()
|
57
|
+
return ReportCostResponse.parse_obj(response.json())
|
@@ -0,0 +1,29 @@
|
|
1
|
+
from decimal import Decimal
|
2
|
+
from typing import List
|
3
|
+
|
4
|
+
from pydantic import BaseModel
|
5
|
+
|
6
|
+
|
7
|
+
class ServiceReport(BaseModel):
|
8
|
+
service: str
|
9
|
+
delta_value: Decimal
|
10
|
+
delta_percent: float | None
|
11
|
+
total: Decimal
|
12
|
+
|
13
|
+
|
14
|
+
class ChildAppReport(BaseModel):
|
15
|
+
name: str
|
16
|
+
total: Decimal
|
17
|
+
|
18
|
+
|
19
|
+
class Report(BaseModel):
|
20
|
+
app_name: str
|
21
|
+
child_apps: List[ChildAppReport]
|
22
|
+
child_apps_total: Decimal
|
23
|
+
date: str
|
24
|
+
parent_app_name: str | None
|
25
|
+
services: List[ServiceReport]
|
26
|
+
services_delta_percent: float | None
|
27
|
+
services_delta_value: Decimal
|
28
|
+
services_total: Decimal
|
29
|
+
total: Decimal
|
@@ -0,0 +1,48 @@
|
|
1
|
+
from decimal import Decimal
|
2
|
+
from typing import List
|
3
|
+
|
4
|
+
from pydantic import BaseModel
|
5
|
+
|
6
|
+
|
7
|
+
class DeltaResponse(BaseModel):
|
8
|
+
value: Decimal
|
9
|
+
percent: float | None
|
10
|
+
|
11
|
+
|
12
|
+
class MoneyResponse(BaseModel):
|
13
|
+
value: Decimal
|
14
|
+
units: str
|
15
|
+
|
16
|
+
|
17
|
+
class CostTotalResponse(BaseModel):
|
18
|
+
total: MoneyResponse
|
19
|
+
|
20
|
+
|
21
|
+
class TotalMetaResponse(BaseModel):
|
22
|
+
cost: CostTotalResponse
|
23
|
+
|
24
|
+
|
25
|
+
class ReportMetaResponse(BaseModel):
|
26
|
+
delta: DeltaResponse
|
27
|
+
total: TotalMetaResponse
|
28
|
+
|
29
|
+
|
30
|
+
class ServiceCostValueResponse(BaseModel):
|
31
|
+
delta_value: Decimal
|
32
|
+
delta_percent: float | None
|
33
|
+
cost: CostTotalResponse
|
34
|
+
|
35
|
+
|
36
|
+
class ServiceCostResponse(BaseModel):
|
37
|
+
service: str
|
38
|
+
values: List[ServiceCostValueResponse]
|
39
|
+
|
40
|
+
|
41
|
+
class CostResponse(BaseModel):
|
42
|
+
date: str
|
43
|
+
services: List[ServiceCostResponse]
|
44
|
+
|
45
|
+
|
46
|
+
class ReportCostResponse(BaseModel):
|
47
|
+
meta: ReportMetaResponse
|
48
|
+
data: List[CostResponse]
|