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
@@ -0,0 +1,333 @@
|
|
1
|
+
from collections.abc import Mapping
|
2
|
+
from decimal import Decimal
|
3
|
+
from typing import Any
|
4
|
+
|
5
|
+
from pydantic import BaseModel
|
6
|
+
|
7
|
+
from tools.cli_commands.cost_report.model import Report
|
8
|
+
|
9
|
+
LAYOUT = """\
|
10
|
+
[TOC]
|
11
|
+
|
12
|
+
{header}
|
13
|
+
{summary}
|
14
|
+
{month_over_month_change}
|
15
|
+
{cost_breakdown}\
|
16
|
+
"""
|
17
|
+
|
18
|
+
HEADER = """\
|
19
|
+
# Cost Report
|
20
|
+
"""
|
21
|
+
|
22
|
+
SUMMARY = """\
|
23
|
+
## Summary
|
24
|
+
|
25
|
+
Total AWS Cost for {date}: {total_cost}
|
26
|
+
|
27
|
+
```json:table
|
28
|
+
{json_table}
|
29
|
+
```
|
30
|
+
"""
|
31
|
+
|
32
|
+
MONTH_OVER_MONTH_CHANGE = """\
|
33
|
+
## Month Over Month Change
|
34
|
+
|
35
|
+
Month over month change for {date}:
|
36
|
+
|
37
|
+
```json:table
|
38
|
+
{json_table}
|
39
|
+
```
|
40
|
+
"""
|
41
|
+
|
42
|
+
COST_BREAKDOWN = """\
|
43
|
+
## Cost Breakdown
|
44
|
+
|
45
|
+
{apps}\
|
46
|
+
"""
|
47
|
+
|
48
|
+
APP = """\
|
49
|
+
### {app_name}
|
50
|
+
|
51
|
+
{cost_details}\
|
52
|
+
"""
|
53
|
+
|
54
|
+
AWS_SERVICES_COST = """\
|
55
|
+
AWS Services Cost: {services_total}, {services_delta_value}{services_delta_percent} \
|
56
|
+
compared to previous month.
|
57
|
+
View in [Cost Management Console]({cost_management_console_url}).
|
58
|
+
|
59
|
+
```json:table
|
60
|
+
{json_table}
|
61
|
+
```
|
62
|
+
"""
|
63
|
+
|
64
|
+
COST_MANAGEMENT_CONSOLE_EXPLORE_URL = (
|
65
|
+
"{base_url}/explorer?"
|
66
|
+
"dateRangeType=previous_month&"
|
67
|
+
"filter[limit]=10&"
|
68
|
+
"filter[offset]=0&"
|
69
|
+
"filter_by[tag:app]={app}"
|
70
|
+
"&group_by[service]=*"
|
71
|
+
"&order_by[cost]=desc&"
|
72
|
+
"perspective=aws"
|
73
|
+
)
|
74
|
+
|
75
|
+
CHILD_APPS_COST = """\
|
76
|
+
Child Apps Cost: {child_apps_total}
|
77
|
+
|
78
|
+
```json:table
|
79
|
+
{json_table}
|
80
|
+
```
|
81
|
+
"""
|
82
|
+
|
83
|
+
TOTAL_COST = """\
|
84
|
+
Total Cost: {total}
|
85
|
+
"""
|
86
|
+
|
87
|
+
|
88
|
+
class TableField(BaseModel):
|
89
|
+
key: str
|
90
|
+
label: str
|
91
|
+
sortable: bool
|
92
|
+
|
93
|
+
|
94
|
+
class JsonTable(BaseModel):
|
95
|
+
filter: bool
|
96
|
+
items: list[Any]
|
97
|
+
fields: list[TableField]
|
98
|
+
|
99
|
+
|
100
|
+
class SummaryItem(BaseModel):
|
101
|
+
name: str
|
102
|
+
child_apps_total: Decimal
|
103
|
+
services_total: Decimal
|
104
|
+
total: Decimal
|
105
|
+
|
106
|
+
|
107
|
+
class MonthOverMonthChangeItem(BaseModel):
|
108
|
+
name: str
|
109
|
+
delta_value: Decimal
|
110
|
+
delta_percent: float | None
|
111
|
+
total: Decimal
|
112
|
+
|
113
|
+
|
114
|
+
def format_cost_value(value: Decimal) -> str:
|
115
|
+
return f"${value:,.2f}"
|
116
|
+
|
117
|
+
|
118
|
+
def format_delta_value(value: Decimal) -> str:
|
119
|
+
if value >= 0:
|
120
|
+
return f"+${value:,.2f}"
|
121
|
+
else:
|
122
|
+
return f"-${abs(value):,.2f}"
|
123
|
+
|
124
|
+
|
125
|
+
def format_delta_percent(value: float | None) -> str:
|
126
|
+
if value is None:
|
127
|
+
return ""
|
128
|
+
return f" ({value:+.2f}%)"
|
129
|
+
|
130
|
+
|
131
|
+
def get_date(reports: Mapping[str, Report]) -> str:
|
132
|
+
return next((d for report in reports.values() if (d := report.date)), "")
|
133
|
+
|
134
|
+
|
135
|
+
def render_summary(reports: Mapping[str, Report]) -> str:
|
136
|
+
root_apps = {
|
137
|
+
name: report
|
138
|
+
for name, report in reports.items()
|
139
|
+
if report.parent_app_name is None
|
140
|
+
}
|
141
|
+
total_cost = round(Decimal(sum(report.total for report in root_apps.values())), 2)
|
142
|
+
summary_items = [
|
143
|
+
SummaryItem(
|
144
|
+
name=name,
|
145
|
+
child_apps_total=round(report.child_apps_total, 2),
|
146
|
+
services_total=round(report.services_total, 2),
|
147
|
+
total=round(report.total, 2),
|
148
|
+
)
|
149
|
+
for name, report in root_apps.items()
|
150
|
+
]
|
151
|
+
json_table = JsonTable(
|
152
|
+
filter=True,
|
153
|
+
items=sorted(summary_items, key=lambda item: item.total, reverse=True),
|
154
|
+
fields=[
|
155
|
+
TableField(key="name", label="Name", sortable=True),
|
156
|
+
TableField(key="services_total", label="Self App ($)", sortable=True),
|
157
|
+
TableField(key="child_apps_total", label="Child Apps ($)", sortable=True),
|
158
|
+
TableField(key="total", label="Total ($)", sortable=True),
|
159
|
+
],
|
160
|
+
)
|
161
|
+
return SUMMARY.format(
|
162
|
+
date=get_date(reports),
|
163
|
+
total_cost=format_cost_value(total_cost),
|
164
|
+
json_table=json_table.json(indent=2),
|
165
|
+
)
|
166
|
+
|
167
|
+
|
168
|
+
def render_month_over_month_change(reports: Mapping[str, Report]) -> str:
|
169
|
+
items = [
|
170
|
+
MonthOverMonthChangeItem(
|
171
|
+
name=name,
|
172
|
+
delta_value=round(report.services_delta_value, 2),
|
173
|
+
delta_percent=(
|
174
|
+
round(report.services_delta_percent, 2)
|
175
|
+
if report.services_delta_percent is not None
|
176
|
+
else None
|
177
|
+
),
|
178
|
+
total=round(report.services_total, 2),
|
179
|
+
)
|
180
|
+
for name, report in reports.items()
|
181
|
+
]
|
182
|
+
json_table = JsonTable(
|
183
|
+
filter=True,
|
184
|
+
items=sorted(items, key=lambda item: item.delta_value, reverse=True),
|
185
|
+
fields=[
|
186
|
+
TableField(key="name", label="Name", sortable=True),
|
187
|
+
TableField(key="delta_value", label="Change ($)", sortable=True),
|
188
|
+
TableField(key="delta_percent", label="Change (%)", sortable=True),
|
189
|
+
TableField(key="total", label="Total ($)", sortable=True),
|
190
|
+
],
|
191
|
+
)
|
192
|
+
return MONTH_OVER_MONTH_CHANGE.format(
|
193
|
+
date=get_date(reports),
|
194
|
+
json_table=json_table.json(indent=2),
|
195
|
+
)
|
196
|
+
|
197
|
+
|
198
|
+
def build_cost_management_console_url(base_url: str, app: str) -> str:
|
199
|
+
return (
|
200
|
+
f"{base_url}/explorer?"
|
201
|
+
"dateRangeType=previous_month&"
|
202
|
+
"filter[limit]=10&"
|
203
|
+
"filter[offset]=0&"
|
204
|
+
f"filter_by[tag:app]={app}&"
|
205
|
+
"group_by[service]=*&"
|
206
|
+
"order_by[cost]=desc&"
|
207
|
+
"perspective=aws"
|
208
|
+
)
|
209
|
+
|
210
|
+
|
211
|
+
def render_aws_services_cost(
|
212
|
+
report: Report,
|
213
|
+
cost_management_console_base_url: str,
|
214
|
+
) -> str:
|
215
|
+
services = [
|
216
|
+
s.copy(
|
217
|
+
update={
|
218
|
+
"delta_value": round(s.delta_value, 2),
|
219
|
+
"delta_percent": round(s.delta_percent, 2)
|
220
|
+
if s.delta_percent is not None
|
221
|
+
else None,
|
222
|
+
"total": round(s.total, 2),
|
223
|
+
}
|
224
|
+
)
|
225
|
+
for s in report.services
|
226
|
+
]
|
227
|
+
json_table = JsonTable(
|
228
|
+
filter=True,
|
229
|
+
items=sorted(services, key=lambda service: service.total, reverse=True),
|
230
|
+
fields=[
|
231
|
+
TableField(key="service", label="Service", sortable=True),
|
232
|
+
TableField(key="delta_value", label="Change ($)", sortable=True),
|
233
|
+
TableField(key="delta_percent", label="Change (%)", sortable=True),
|
234
|
+
TableField(key="total", label="Total ($)", sortable=True),
|
235
|
+
],
|
236
|
+
)
|
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
|
+
|
248
|
+
|
249
|
+
def render_child_apps_cost(report: Report) -> str:
|
250
|
+
child_apps = [
|
251
|
+
app.copy(
|
252
|
+
update={
|
253
|
+
"total": round(app.total, 2),
|
254
|
+
}
|
255
|
+
)
|
256
|
+
for app in report.child_apps
|
257
|
+
]
|
258
|
+
json_table = JsonTable(
|
259
|
+
filter=True,
|
260
|
+
items=sorted(child_apps, key=lambda app: app.total, reverse=True),
|
261
|
+
fields=[
|
262
|
+
TableField(key="name", label="Name", sortable=True),
|
263
|
+
TableField(key="total", label="Total ($)", sortable=True),
|
264
|
+
],
|
265
|
+
)
|
266
|
+
return CHILD_APPS_COST.format(
|
267
|
+
child_apps_total=format_cost_value(report.child_apps_total),
|
268
|
+
json_table=json_table.json(indent=2),
|
269
|
+
)
|
270
|
+
|
271
|
+
|
272
|
+
def render_total_cost(report: Report) -> str:
|
273
|
+
return TOTAL_COST.format(
|
274
|
+
total=format_cost_value(report.total),
|
275
|
+
)
|
276
|
+
|
277
|
+
|
278
|
+
def render_app_cost(
|
279
|
+
name: str,
|
280
|
+
report: Report,
|
281
|
+
cost_management_console_base_url: str,
|
282
|
+
) -> str:
|
283
|
+
cost_details_sections = []
|
284
|
+
if report.services:
|
285
|
+
cost_details_sections.append(
|
286
|
+
render_aws_services_cost(
|
287
|
+
report,
|
288
|
+
cost_management_console_base_url,
|
289
|
+
)
|
290
|
+
)
|
291
|
+
if report.child_apps:
|
292
|
+
cost_details_sections.append(render_child_apps_cost(report))
|
293
|
+
cost_details_sections.append(render_total_cost(report))
|
294
|
+
cost_details = (
|
295
|
+
"\n".join(cost_details_sections) if cost_details_sections else "No data"
|
296
|
+
)
|
297
|
+
return APP.format(
|
298
|
+
app_name=name,
|
299
|
+
cost_details=cost_details,
|
300
|
+
)
|
301
|
+
|
302
|
+
|
303
|
+
def render_cost_breakdown(
|
304
|
+
reports: Mapping[str, Report],
|
305
|
+
cost_management_console_base_url: str,
|
306
|
+
) -> str:
|
307
|
+
apps = "\n".join(
|
308
|
+
render_app_cost(
|
309
|
+
name,
|
310
|
+
report,
|
311
|
+
cost_management_console_base_url,
|
312
|
+
)
|
313
|
+
for name, report in sorted(
|
314
|
+
reports.items(),
|
315
|
+
key=lambda item: item[0].lower(),
|
316
|
+
)
|
317
|
+
)
|
318
|
+
return COST_BREAKDOWN.format(apps=apps)
|
319
|
+
|
320
|
+
|
321
|
+
def render_report(
|
322
|
+
reports: Mapping[str, Report],
|
323
|
+
cost_management_console_base_url: str,
|
324
|
+
) -> str:
|
325
|
+
return LAYOUT.format(
|
326
|
+
header=HEADER,
|
327
|
+
summary=render_summary(reports),
|
328
|
+
month_over_month_change=render_month_over_month_change(reports),
|
329
|
+
cost_breakdown=render_cost_breakdown(
|
330
|
+
reports,
|
331
|
+
cost_management_console_base_url,
|
332
|
+
),
|
333
|
+
)
|
tools/qontract_cli.py
CHANGED
@@ -131,6 +131,7 @@ from reconcile.utils.secret_reader import (
|
|
131
131
|
from reconcile.utils.semver_helper import parse_semver
|
132
132
|
from reconcile.utils.state import init_state
|
133
133
|
from reconcile.utils.terraform_client import TerraformClient as Terraform
|
134
|
+
from tools.cli_commands.cost_report.command import CostReportCommand
|
134
135
|
from tools.cli_commands.gpg_encrypt import (
|
135
136
|
GPGEncryptCommand,
|
136
137
|
GPGEncryptCommandData,
|
@@ -2513,6 +2514,13 @@ def alerts(ctx, file_path):
|
|
2513
2514
|
print_output(ctx.obj["options"], data, columns)
|
2514
2515
|
|
2515
2516
|
|
2517
|
+
@get.command()
|
2518
|
+
@click.pass_context
|
2519
|
+
def cost_report(ctx):
|
2520
|
+
command = CostReportCommand.create()
|
2521
|
+
print(command.execute())
|
2522
|
+
|
2523
|
+
|
2516
2524
|
@root.group(name="set")
|
2517
2525
|
@output
|
2518
2526
|
@click.pass_context
|
@@ -2584,11 +2592,11 @@ def ls(ctx, integration):
|
|
2584
2592
|
)
|
2585
2593
|
|
2586
2594
|
|
2587
|
-
@state.command()
|
2595
|
+
@state.command(name="get")
|
2588
2596
|
@click.argument("integration")
|
2589
2597
|
@click.argument("key")
|
2590
2598
|
@click.pass_context
|
2591
|
-
def
|
2599
|
+
def state_get(ctx, integration, key):
|
2592
2600
|
state = init_state(integration=integration)
|
2593
2601
|
value = state.get(key)
|
2594
2602
|
print(value)
|
tools/test/test_qontract_cli.py
CHANGED
@@ -125,3 +125,23 @@ def test_early_exit_cache_delete(env_vars, mock_queries, mock_early_exit_cache):
|
|
125
125
|
|
126
126
|
assert result.exit_code == 0
|
127
127
|
assert result.output == "deleted\n"
|
128
|
+
|
129
|
+
|
130
|
+
@pytest.fixture
|
131
|
+
def mock_cost_report_command(mocker):
|
132
|
+
return mocker.patch("tools.qontract_cli.CostReportCommand", autospec=True)
|
133
|
+
|
134
|
+
|
135
|
+
def test_get_cost_report(env_vars, mock_queries, mock_cost_report_command):
|
136
|
+
mock_cost_report_command.create.return_value.execute.return_value = "some report"
|
137
|
+
runner = CliRunner()
|
138
|
+
result = runner.invoke(
|
139
|
+
qontract_cli.get,
|
140
|
+
"cost-report",
|
141
|
+
obj={},
|
142
|
+
)
|
143
|
+
|
144
|
+
assert result.exit_code == 0
|
145
|
+
assert result.output == "some report\n"
|
146
|
+
mock_cost_report_command.create.assert_called_once_with()
|
147
|
+
mock_cost_report_command.create.return_value.execute.assert_called_once_with()
|
File without changes
|
File without changes
|
{qontract_reconcile-0.10.1rc696.dist-info → qontract_reconcile-0.10.1rc702.dist-info}/top_level.txt
RENAMED
File without changes
|