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
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 types import TracebackType
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
- def transaction(self, key: str, value: Any) -> "_TransactionContext":
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
- return _TransactionContext(self, key, value)
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
- class _TransactionContext:
420
- """A context manager to set a key in the state if no exception occurs."""
443
+ @dataclass
444
+ class TransactionStateObj:
445
+ """Represents a transistion state object with a key and a value."""
421
446
 
422
- def __init__(
423
- self,
424
- state: State,
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 __enter__(self) -> bool:
433
- """Return True if the key exists in the state, False otherwise.
451
+ def __post_init__(self) -> None:
452
+ self._init_value = self.value
434
453
 
435
- Cache the previous value to avoid unnecessary updates.
436
- """
437
- self._previous_value = None
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
- def __exit__(
445
- self,
446
- exc_type: type[BaseException] | None,
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]