qontract-reconcile 0.10.1rc1149__py3-none-any.whl → 0.10.1rc1151__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 (25) hide show
  1. {qontract_reconcile-0.10.1rc1149.dist-info → qontract_reconcile-0.10.1rc1151.dist-info}/METADATA +1 -1
  2. {qontract_reconcile-0.10.1rc1149.dist-info → qontract_reconcile-0.10.1rc1151.dist-info}/RECORD +25 -16
  3. reconcile/gql_definitions/cost_report/cost_namespaces.py +2 -0
  4. reconcile/typed_queries/cost_report/cost_namespaces.py +7 -4
  5. tools/cli_commands/cost_report/aws.py +12 -25
  6. tools/cli_commands/cost_report/cost_management_api.py +79 -6
  7. tools/cli_commands/cost_report/model.py +21 -0
  8. tools/cli_commands/cost_report/openshift.py +7 -20
  9. tools/cli_commands/cost_report/openshift_cost_optimization.py +187 -0
  10. tools/cli_commands/cost_report/response.py +56 -1
  11. tools/cli_commands/cost_report/util.py +13 -0
  12. tools/cli_commands/cost_report/view.py +128 -2
  13. tools/cli_commands/test/__init__.py +0 -0
  14. tools/cli_commands/test/conftest.py +332 -0
  15. tools/cli_commands/test/test_aws_cost_report.py +258 -0
  16. tools/cli_commands/test/test_cost_management_api.py +326 -0
  17. tools/cli_commands/test/test_gpg_encrypt.py +235 -0
  18. tools/cli_commands/test/test_openshift_cost_optimization_report.py +255 -0
  19. tools/cli_commands/test/test_openshift_cost_report.py +295 -0
  20. tools/cli_commands/test/test_util.py +70 -0
  21. tools/qontract_cli.py +67 -24
  22. tools/test/test_qontract_cli.py +24 -0
  23. {qontract_reconcile-0.10.1rc1149.dist-info → qontract_reconcile-0.10.1rc1151.dist-info}/WHEEL +0 -0
  24. {qontract_reconcile-0.10.1rc1149.dist-info → qontract_reconcile-0.10.1rc1151.dist-info}/entry_points.txt +0 -0
  25. {qontract_reconcile-0.10.1rc1149.dist-info → qontract_reconcile-0.10.1rc1151.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,258 @@
1
+ from collections.abc import Callable
2
+ from decimal import Decimal
3
+ from typing import Any
4
+
5
+ import pytest
6
+ from pytest_mock import MockerFixture
7
+
8
+ from reconcile.typed_queries.cost_report.app_names import App
9
+ from tools.cli_commands.cost_report.aws import AwsCostReportCommand
10
+ from tools.cli_commands.cost_report.model import ChildAppReport, Report, ReportItem
11
+ from tools.cli_commands.cost_report.response import AwsReportCostResponse
12
+ from tools.cli_commands.test.conftest import (
13
+ COST_REPORT_SECRET,
14
+ )
15
+
16
+ COST_MANAGEMENT_CONSOLE_BASE_URL = (
17
+ "https://console.redhat.com/openshift/cost-management"
18
+ )
19
+
20
+
21
+ @pytest.fixture
22
+ def mock_gql(mocker: MockerFixture) -> Any:
23
+ return mocker.patch("tools.cli_commands.cost_report.aws.gql")
24
+
25
+
26
+ @pytest.fixture
27
+ def mock_cost_management_api(mocker: MockerFixture) -> Any:
28
+ return mocker.patch(
29
+ "tools.cli_commands.cost_report.aws.CostManagementApi",
30
+ autospec=True,
31
+ )
32
+
33
+
34
+ @pytest.fixture
35
+ def mock_fetch_cost_report_secret(mocker: MockerFixture) -> Any:
36
+ return mocker.patch(
37
+ "tools.cli_commands.cost_report.aws.fetch_cost_report_secret",
38
+ return_value=COST_REPORT_SECRET,
39
+ )
40
+
41
+
42
+ def test_aws_cost_report_create(
43
+ mock_gql: Any,
44
+ mock_cost_management_api: Any,
45
+ mock_fetch_cost_report_secret: Any,
46
+ ) -> None:
47
+ cost_report_command = AwsCostReportCommand.create()
48
+
49
+ assert isinstance(cost_report_command, AwsCostReportCommand)
50
+ assert cost_report_command.gql_api == mock_gql.get_api.return_value
51
+ assert (
52
+ cost_report_command.cost_management_console_base_url
53
+ == COST_MANAGEMENT_CONSOLE_BASE_URL
54
+ )
55
+ assert (
56
+ cost_report_command.cost_management_api
57
+ == mock_cost_management_api.create_from_secret.return_value
58
+ )
59
+ assert cost_report_command.thread_pool_size == 10
60
+ mock_cost_management_api.create_from_secret.assert_called_once_with(
61
+ COST_REPORT_SECRET
62
+ )
63
+ mock_fetch_cost_report_secret.assert_called_once_with(mock_gql.get_api.return_value)
64
+
65
+
66
+ @pytest.fixture
67
+ def aws_cost_report_command(
68
+ mock_gql: Any,
69
+ mock_cost_management_api: Any,
70
+ mock_fetch_cost_report_secret: Any,
71
+ ) -> AwsCostReportCommand:
72
+ return AwsCostReportCommand.create()
73
+
74
+
75
+ @pytest.fixture
76
+ def mock_get_app_names(mocker: MockerFixture) -> Any:
77
+ return mocker.patch("tools.cli_commands.cost_report.aws.get_app_names")
78
+
79
+
80
+ PARENT_APP = App(name="parent", parent_app_name=None)
81
+ CHILD_APP = App(name="child", parent_app_name="parent")
82
+
83
+
84
+ def test_aws_cost_report_execute(
85
+ aws_cost_report_command: AwsCostReportCommand,
86
+ mock_get_app_names: Any,
87
+ fx: Callable,
88
+ ) -> None:
89
+ expected_output = fx("empty_aws_cost_report.md")
90
+ mock_get_app_names.return_value = []
91
+
92
+ output = aws_cost_report_command.execute()
93
+
94
+ assert output.rstrip() == expected_output.rstrip()
95
+
96
+
97
+ def test_aws_cost_report_get_apps(
98
+ aws_cost_report_command: AwsCostReportCommand,
99
+ mock_get_app_names: Any,
100
+ ) -> None:
101
+ expected_apps = [PARENT_APP, CHILD_APP]
102
+ mock_get_app_names.return_value = expected_apps
103
+
104
+ apps = aws_cost_report_command.get_apps()
105
+
106
+ assert apps == expected_apps
107
+
108
+
109
+ def aws_report_cost_response_builder(
110
+ delta_value: int,
111
+ delta_percent: int,
112
+ total: int,
113
+ service: str,
114
+ ) -> AwsReportCostResponse:
115
+ return AwsReportCostResponse.parse_obj({
116
+ "meta": {
117
+ "delta": {
118
+ "value": delta_value,
119
+ "percent": delta_percent,
120
+ },
121
+ "total": {
122
+ "cost": {
123
+ "total": {
124
+ "value": total,
125
+ "units": "USD",
126
+ }
127
+ }
128
+ },
129
+ },
130
+ "data": [
131
+ {
132
+ "date": "2024-02",
133
+ "services": [
134
+ {
135
+ "service": service,
136
+ "values": [
137
+ {
138
+ "delta_value": delta_value,
139
+ "delta_percent": delta_percent,
140
+ "cost": {
141
+ "total": {
142
+ "value": total,
143
+ "units": "USD",
144
+ }
145
+ },
146
+ }
147
+ ],
148
+ },
149
+ ],
150
+ }
151
+ ],
152
+ })
153
+
154
+
155
+ PARENT_APP_COST_RESPONSE = aws_report_cost_response_builder(
156
+ delta_value=100,
157
+ delta_percent=10,
158
+ total=1000,
159
+ service="service1",
160
+ )
161
+
162
+ CHILD_APP_COST_RESPONSE = aws_report_cost_response_builder(
163
+ delta_value=200,
164
+ delta_percent=20,
165
+ total=2000,
166
+ service="service2",
167
+ )
168
+
169
+ PARENT_APP_REPORT = Report(
170
+ app_name="parent",
171
+ parent_app_name=None,
172
+ child_apps=[
173
+ ChildAppReport(name="child", total=Decimal(2000)),
174
+ ],
175
+ child_apps_total=Decimal(2000),
176
+ date="2024-02",
177
+ items=[
178
+ ReportItem(
179
+ name="service1",
180
+ delta_value=Decimal(100),
181
+ delta_percent=10,
182
+ total=Decimal(1000),
183
+ )
184
+ ],
185
+ items_total=Decimal(1000),
186
+ items_delta_value=Decimal(100),
187
+ items_delta_percent=10,
188
+ total=Decimal(3000),
189
+ )
190
+
191
+ CHILD_APP_REPORT = Report(
192
+ app_name="child",
193
+ parent_app_name="parent",
194
+ child_apps=[],
195
+ child_apps_total=Decimal(0),
196
+ date="2024-02",
197
+ items=[
198
+ ReportItem(
199
+ name="service2",
200
+ delta_value=Decimal(200),
201
+ delta_percent=20,
202
+ total=Decimal(2000),
203
+ )
204
+ ],
205
+ items_total=Decimal(2000),
206
+ items_delta_value=Decimal(200),
207
+ items_delta_percent=20,
208
+ total=Decimal(2000),
209
+ )
210
+
211
+
212
+ def test_aws_cost_report_get_reports(
213
+ aws_cost_report_command: AwsCostReportCommand,
214
+ mock_cost_management_api: Any,
215
+ ) -> None:
216
+ mocked_api = mock_cost_management_api.create_from_secret.return_value
217
+ mocked_api.get_aws_costs_report.return_value = PARENT_APP_COST_RESPONSE
218
+
219
+ reports = aws_cost_report_command.get_reports([PARENT_APP])
220
+
221
+ assert reports == {
222
+ "parent": PARENT_APP_COST_RESPONSE,
223
+ }
224
+ mocked_api.get_aws_costs_report.assert_called_once_with("parent")
225
+
226
+
227
+ def test_aws_cost_report_process_reports(
228
+ aws_cost_report_command: AwsCostReportCommand,
229
+ ) -> None:
230
+ expected_reports = {
231
+ "parent": PARENT_APP_REPORT,
232
+ "child": CHILD_APP_REPORT,
233
+ }
234
+
235
+ reports = aws_cost_report_command.process_reports(
236
+ [PARENT_APP, CHILD_APP],
237
+ {
238
+ "parent": PARENT_APP_COST_RESPONSE,
239
+ "child": CHILD_APP_COST_RESPONSE,
240
+ },
241
+ )
242
+
243
+ assert reports == expected_reports
244
+
245
+
246
+ def test_aws_cost_report_render(
247
+ aws_cost_report_command: AwsCostReportCommand,
248
+ fx: Callable,
249
+ ) -> None:
250
+ expected_output = fx("aws_cost_report.md")
251
+ reports = {
252
+ "parent": PARENT_APP_REPORT,
253
+ "child": CHILD_APP_REPORT,
254
+ }
255
+
256
+ output = aws_cost_report_command.render(reports)
257
+
258
+ assert output == expected_output
@@ -0,0 +1,326 @@
1
+ from collections.abc import Callable
2
+ from decimal import Decimal
3
+ from typing import Any
4
+
5
+ import pytest
6
+ import requests
7
+ from pytest_httpserver import HTTPServer
8
+ from pytest_mock import MockerFixture
9
+ from requests import HTTPError
10
+
11
+ from tools.cli_commands.cost_report.cost_management_api import CostManagementApi
12
+ from tools.cli_commands.cost_report.response import (
13
+ AwsReportCostResponse,
14
+ CostResponse,
15
+ CostTotalResponse,
16
+ DeltaResponse,
17
+ MoneyResponse,
18
+ OpenShiftCostResponse,
19
+ OpenShiftReportCostResponse,
20
+ ProjectCostResponse,
21
+ ProjectCostValueResponse,
22
+ ReportMetaResponse,
23
+ ServiceCostResponse,
24
+ ServiceCostValueResponse,
25
+ TotalMetaResponse,
26
+ )
27
+ from tools.cli_commands.test.conftest import (
28
+ COST_MANAGEMENT_API_HOST,
29
+ COST_REPORT_SECRET,
30
+ OPENSHIFT_COST_OPTIMIZATION_RESPONSE,
31
+ )
32
+
33
+
34
+ @pytest.fixture
35
+ def mock_session(mocker: MockerFixture) -> Any:
36
+ return mocker.patch(
37
+ "tools.cli_commands.cost_report.cost_management_api.OAuth2BackendApplicationSession",
38
+ autospec=True,
39
+ )
40
+
41
+
42
+ @pytest.fixture
43
+ def base_url(httpserver: HTTPServer) -> str:
44
+ return httpserver.url_for("/")
45
+
46
+
47
+ TOKEN_URL = "token_url"
48
+ CLIENT_ID = COST_REPORT_SECRET["client_id"]
49
+ CLIENT_SECRET = COST_REPORT_SECRET["client_secret"]
50
+ SCOPE = ["scope"]
51
+
52
+
53
+ def test_cost_management_api_create_from_secret(
54
+ mock_session: Any,
55
+ ) -> None:
56
+ api = CostManagementApi.create_from_secret(COST_REPORT_SECRET)
57
+
58
+ assert api.host == COST_MANAGEMENT_API_HOST
59
+ assert api.base_url == COST_REPORT_SECRET["api_base_url"]
60
+ assert api.session == mock_session.return_value
61
+ mock_session.assert_called_once_with(
62
+ client_id=CLIENT_ID,
63
+ client_secret=CLIENT_SECRET,
64
+ token_url=TOKEN_URL,
65
+ scope=SCOPE,
66
+ )
67
+
68
+
69
+ def test_cost_management_api_init(mock_session: Any, base_url: str) -> None:
70
+ with CostManagementApi(
71
+ base_url=base_url,
72
+ token_url=TOKEN_URL,
73
+ client_id=CLIENT_ID,
74
+ client_secret=CLIENT_SECRET,
75
+ scope=SCOPE,
76
+ ) as api:
77
+ pass
78
+
79
+ assert api.base_url == base_url
80
+ assert api.session == mock_session.return_value
81
+
82
+ mock_session.assert_called_once_with(
83
+ client_id=CLIENT_ID,
84
+ client_secret=CLIENT_SECRET,
85
+ token_url=TOKEN_URL,
86
+ scope=SCOPE,
87
+ )
88
+ assert mock_session.return_value.mount.call_count == 2
89
+ mock_session.return_value.close.assert_called_once_with()
90
+
91
+
92
+ @pytest.fixture
93
+ def cost_management_api(mock_session: Any, base_url: str) -> CostManagementApi:
94
+ # swap to requests.request to skip oauth2 logic
95
+ mock_session.return_value.request.side_effect = requests.request
96
+ return CostManagementApi(
97
+ base_url=base_url,
98
+ token_url=TOKEN_URL,
99
+ client_id=CLIENT_ID,
100
+ client_secret=CLIENT_SECRET,
101
+ scope=SCOPE,
102
+ )
103
+
104
+
105
+ EXPECTED_REPORT_COST_RESPONSE = AwsReportCostResponse(
106
+ meta=ReportMetaResponse(
107
+ delta=DeltaResponse(
108
+ value=Decimal(100),
109
+ percent=10,
110
+ ),
111
+ total=TotalMetaResponse(
112
+ cost=CostTotalResponse(
113
+ total=MoneyResponse(
114
+ value=Decimal(1000),
115
+ units="USD",
116
+ )
117
+ )
118
+ ),
119
+ ),
120
+ data=[
121
+ CostResponse(
122
+ date="2024-02",
123
+ services=[
124
+ ServiceCostResponse(
125
+ service="AmazonEC2",
126
+ values=[
127
+ ServiceCostValueResponse(
128
+ delta_percent=10,
129
+ delta_value=Decimal(200),
130
+ cost=CostTotalResponse(
131
+ total=MoneyResponse(
132
+ value=Decimal(800),
133
+ units="USD",
134
+ )
135
+ ),
136
+ )
137
+ ],
138
+ ),
139
+ ServiceCostResponse(
140
+ service="AmazonS3",
141
+ values=[
142
+ ServiceCostValueResponse(
143
+ delta_percent=-10,
144
+ delta_value=Decimal(-100),
145
+ cost=CostTotalResponse(
146
+ total=MoneyResponse(
147
+ value=Decimal(200),
148
+ units="USD",
149
+ )
150
+ ),
151
+ )
152
+ ],
153
+ ),
154
+ ],
155
+ ),
156
+ ],
157
+ )
158
+
159
+
160
+ def test_get_aws_costs_report(
161
+ cost_management_api: CostManagementApi,
162
+ fx: Callable,
163
+ httpserver: HTTPServer,
164
+ base_url: str,
165
+ ) -> None:
166
+ response_body = fx("aws_cost_report.json")
167
+ httpserver.expect_request(
168
+ "/reports/aws/costs/",
169
+ query_string={
170
+ "cost_type": "calculated_amortized_cost",
171
+ "delta": "cost",
172
+ "filter[resolution]": "monthly",
173
+ "filter[tag:app]": "test",
174
+ "filter[time_scope_units]": "month",
175
+ "filter[time_scope_value]": "-2",
176
+ "group_by[service]": "*",
177
+ },
178
+ ).respond_with_data(response_body)
179
+
180
+ report_cost_response = cost_management_api.get_aws_costs_report(app="test")
181
+
182
+ assert report_cost_response == EXPECTED_REPORT_COST_RESPONSE
183
+
184
+
185
+ def test_get_aws_costs_report_error(
186
+ cost_management_api: CostManagementApi,
187
+ fx: Callable,
188
+ httpserver: HTTPServer,
189
+ ) -> None:
190
+ httpserver.expect_request("/reports/aws/costs/").respond_with_data(status=500)
191
+
192
+ with pytest.raises(HTTPError) as error:
193
+ cost_management_api.get_aws_costs_report(app="test")
194
+
195
+ assert error.value.response.status_code == 500
196
+
197
+
198
+ EXPECTED_OPENSHIFT_REPORT_COST_RESPONSE = OpenShiftReportCostResponse(
199
+ meta=ReportMetaResponse(
200
+ delta=DeltaResponse(
201
+ value=Decimal(100),
202
+ percent=10,
203
+ ),
204
+ total=TotalMetaResponse(
205
+ cost=CostTotalResponse(
206
+ total=MoneyResponse(
207
+ value=Decimal(1000),
208
+ units="USD",
209
+ )
210
+ )
211
+ ),
212
+ ),
213
+ data=[
214
+ OpenShiftCostResponse(
215
+ date="2024-02",
216
+ projects=[
217
+ ProjectCostResponse(
218
+ project="some-project",
219
+ values=[
220
+ ProjectCostValueResponse(
221
+ delta_percent=10,
222
+ delta_value=Decimal(100),
223
+ clusters=["some-cluster"],
224
+ cost=CostTotalResponse(
225
+ total=MoneyResponse(
226
+ value=Decimal(1000),
227
+ units="USD",
228
+ )
229
+ ),
230
+ )
231
+ ],
232
+ ),
233
+ ],
234
+ ),
235
+ ],
236
+ )
237
+
238
+
239
+ def test_get_openshift_costs_report(
240
+ cost_management_api: CostManagementApi,
241
+ fx: Callable,
242
+ httpserver: HTTPServer,
243
+ ) -> None:
244
+ response_body = fx("openshift_cost_report.json")
245
+ project = "some-project"
246
+ cluster = "some-cluster-uuid"
247
+ httpserver.expect_request(
248
+ "/reports/openshift/costs/",
249
+ query_string={
250
+ "delta": "cost",
251
+ "filter[resolution]": "monthly",
252
+ "filter[cluster]": cluster,
253
+ "filter[exact:project]": project,
254
+ "filter[time_scope_units]": "month",
255
+ "filter[time_scope_value]": "-2",
256
+ "group_by[project]": "*",
257
+ },
258
+ ).respond_with_data(response_body)
259
+
260
+ report_cost_response = cost_management_api.get_openshift_costs_report(
261
+ cluster=cluster,
262
+ project=project,
263
+ )
264
+
265
+ assert report_cost_response == EXPECTED_OPENSHIFT_REPORT_COST_RESPONSE
266
+
267
+
268
+ def test_get_openshift_costs_report_error(
269
+ cost_management_api: CostManagementApi,
270
+ fx: Callable,
271
+ httpserver: HTTPServer,
272
+ ) -> None:
273
+ httpserver.expect_request("/reports/openshift/costs/").respond_with_data(status=500)
274
+
275
+ with pytest.raises(HTTPError) as error:
276
+ cost_management_api.get_openshift_costs_report(
277
+ cluster="some-cluster",
278
+ project="some-project",
279
+ )
280
+
281
+ assert error.value.response.status_code == 500
282
+
283
+
284
+ def test_get_openshift_cost_optimization_report(
285
+ cost_management_api: CostManagementApi,
286
+ fx: Callable,
287
+ httpserver: HTTPServer,
288
+ ) -> None:
289
+ response_body = fx("openshift_cost_optimization_report.json")
290
+ project = "some-project"
291
+ cluster = "some-cluster-uuid"
292
+ httpserver.expect_request(
293
+ "/recommendations/openshift",
294
+ query_string={
295
+ "cluster": cluster,
296
+ "project": project,
297
+ "limit": "100",
298
+ "memory-unit": "MiB",
299
+ "cpu-unit": "millicores",
300
+ },
301
+ ).respond_with_data(response_body)
302
+
303
+ report_cost_response = cost_management_api.get_openshift_cost_optimization_report(
304
+ cluster=cluster,
305
+ project=project,
306
+ )
307
+
308
+ assert report_cost_response == OPENSHIFT_COST_OPTIMIZATION_RESPONSE
309
+
310
+
311
+ def test_get_openshift_cost_optimization_report_error(
312
+ cost_management_api: CostManagementApi,
313
+ fx: Callable,
314
+ httpserver: HTTPServer,
315
+ ) -> None:
316
+ httpserver.expect_request("/recommendations/openshift").respond_with_data(
317
+ status=500
318
+ )
319
+
320
+ with pytest.raises(HTTPError) as error:
321
+ cost_management_api.get_openshift_cost_optimization_report(
322
+ cluster="some-cluster",
323
+ project="some-project",
324
+ )
325
+
326
+ assert error.value.response.status_code == 500