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.
Files changed (42) hide show
  1. {qontract_reconcile-0.10.1rc696.dist-info → qontract_reconcile-0.10.1rc702.dist-info}/METADATA +1 -1
  2. {qontract_reconcile-0.10.1rc696.dist-info → qontract_reconcile-0.10.1rc702.dist-info}/RECORD +42 -18
  3. reconcile/aws_account_manager/__init__.py +0 -0
  4. reconcile/aws_account_manager/integration.py +342 -0
  5. reconcile/aws_account_manager/merge_request_manager.py +111 -0
  6. reconcile/aws_account_manager/reconciler.py +353 -0
  7. reconcile/aws_account_manager/utils.py +38 -0
  8. reconcile/aws_saml_idp/integration.py +2 -0
  9. reconcile/aws_version_sync/integration.py +12 -11
  10. reconcile/aws_version_sync/merge_request_manager/merge_request_manager.py +39 -112
  11. reconcile/cli.py +79 -0
  12. reconcile/gql_definitions/aws_account_manager/__init__.py +0 -0
  13. reconcile/gql_definitions/aws_account_manager/aws_accounts.py +163 -0
  14. reconcile/gql_definitions/cost_report/__init__.py +0 -0
  15. reconcile/gql_definitions/cost_report/app_names.py +68 -0
  16. reconcile/gql_definitions/cost_report/settings.py +77 -0
  17. reconcile/gql_definitions/fragments/aws_account_managed.py +49 -0
  18. reconcile/queries.py +7 -1
  19. reconcile/templating/lib/merge_request_manager.py +8 -82
  20. reconcile/templating/renderer.py +2 -2
  21. reconcile/typed_queries/cost_report/__init__.py +0 -0
  22. reconcile/typed_queries/cost_report/app_names.py +22 -0
  23. reconcile/typed_queries/cost_report/settings.py +15 -0
  24. reconcile/utils/aws_api_typed/api.py +49 -6
  25. reconcile/utils/aws_api_typed/iam.py +22 -7
  26. reconcile/utils/aws_api_typed/organization.py +78 -30
  27. reconcile/utils/aws_api_typed/service_quotas.py +79 -0
  28. reconcile/utils/aws_api_typed/support.py +79 -0
  29. reconcile/utils/merge_request_manager/merge_request_manager.py +102 -0
  30. reconcile/utils/oauth2_backend_application_session.py +102 -0
  31. reconcile/utils/state.py +42 -38
  32. tools/cli_commands/cost_report/__init__.py +0 -0
  33. tools/cli_commands/cost_report/command.py +172 -0
  34. tools/cli_commands/cost_report/cost_management_api.py +57 -0
  35. tools/cli_commands/cost_report/model.py +29 -0
  36. tools/cli_commands/cost_report/response.py +48 -0
  37. tools/cli_commands/cost_report/view.py +333 -0
  38. tools/qontract_cli.py +10 -2
  39. tools/test/test_qontract_cli.py +20 -0
  40. {qontract_reconcile-0.10.1rc696.dist-info → qontract_reconcile-0.10.1rc702.dist-info}/WHEEL +0 -0
  41. {qontract_reconcile-0.10.1rc696.dist-info → qontract_reconcile-0.10.1rc702.dist-info}/entry_points.txt +0 -0
  42. {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() # type: ignore
2595
+ @state.command(name="get")
2588
2596
  @click.argument("integration")
2589
2597
  @click.argument("key")
2590
2598
  @click.pass_context
2591
- def get(ctx, integration, key):
2599
+ def state_get(ctx, integration, key):
2592
2600
  state = init_state(integration=integration)
2593
2601
  value = state.get(key)
2594
2602
  print(value)
@@ -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()