qontract-reconcile 0.10.1rc801__py3-none-any.whl → 0.10.1rc803__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.1rc801.dist-info → qontract_reconcile-0.10.1rc803.dist-info}/METADATA +1 -1
  2. {qontract_reconcile-0.10.1rc801.dist-info → qontract_reconcile-0.10.1rc803.dist-info}/RECORD +25 -12
  3. reconcile/gql_definitions/cost_report/cost_namespaces.py +84 -0
  4. reconcile/statuspage/__init__.py +0 -0
  5. reconcile/statuspage/atlassian.py +425 -0
  6. reconcile/statuspage/integration.py +22 -0
  7. reconcile/statuspage/integrations/__init__.py +0 -0
  8. reconcile/statuspage/integrations/components.py +77 -0
  9. reconcile/statuspage/integrations/maintenances.py +73 -0
  10. reconcile/statuspage/page.py +114 -0
  11. reconcile/statuspage/state.py +47 -0
  12. reconcile/statuspage/status.py +97 -0
  13. reconcile/typed_queries/cost_report/cost_namespaces.py +40 -0
  14. tools/cli_commands/cost_report/{command.py → aws.py} +53 -75
  15. tools/cli_commands/cost_report/cost_management_api.py +28 -4
  16. tools/cli_commands/cost_report/model.py +7 -8
  17. tools/cli_commands/cost_report/openshift.py +179 -0
  18. tools/cli_commands/cost_report/response.py +25 -4
  19. tools/cli_commands/cost_report/util.py +59 -0
  20. tools/cli_commands/cost_report/view.py +121 -56
  21. tools/qontract_cli.py +10 -2
  22. tools/test/test_qontract_cli.py +32 -6
  23. {qontract_reconcile-0.10.1rc801.dist-info → qontract_reconcile-0.10.1rc803.dist-info}/WHEEL +0 -0
  24. {qontract_reconcile-0.10.1rc801.dist-info → qontract_reconcile-0.10.1rc803.dist-info}/entry_points.txt +0 -0
  25. {qontract_reconcile-0.10.1rc801.dist-info → qontract_reconcile-0.10.1rc803.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,77 @@
1
+ import logging
2
+ import sys
3
+
4
+ from reconcile.statuspage.atlassian import AtlassianStatusPageProvider
5
+ from reconcile.statuspage.integration import get_binding_state, get_status_pages
6
+ from reconcile.statuspage.page import StatusPage
7
+ from reconcile.utils.runtime.integration import (
8
+ NoParams,
9
+ QontractReconcileIntegration,
10
+ )
11
+ from reconcile.utils.semver_helper import make_semver
12
+
13
+ QONTRACT_INTEGRATION = "status-page-components"
14
+ QONTRACT_INTEGRATION_VERSION = make_semver(0, 1, 0)
15
+
16
+
17
+ class StatusPageComponentsIntegration(QontractReconcileIntegration[NoParams]):
18
+ def __init__(self) -> None:
19
+ super().__init__(NoParams())
20
+
21
+ @property
22
+ def name(self) -> str:
23
+ return QONTRACT_INTEGRATION
24
+
25
+ def reconcile(
26
+ self,
27
+ dry_run: bool,
28
+ desired_state: StatusPage,
29
+ current_state: StatusPage,
30
+ provider: AtlassianStatusPageProvider,
31
+ ) -> None:
32
+ """
33
+ Reconcile the desired state with the current state of a status page.
34
+ """
35
+ #
36
+ # D E L E T E
37
+ #
38
+ desired_component_names = {c.name for c in desired_state.components}
39
+ current_component_names = {c.name for c in current_state.components}
40
+ component_names_to_delete = current_component_names - desired_component_names
41
+ for component_name in component_names_to_delete:
42
+ logging.info(
43
+ f"delete component {component_name} from page {desired_state.name}"
44
+ )
45
+ provider.delete_component(dry_run, component_name)
46
+
47
+ #
48
+ # C R E A T E OR U P D A T E
49
+ #
50
+ for desired in desired_state.components:
51
+ provider.apply_component(dry_run, desired)
52
+
53
+ def run(self, dry_run: bool = False) -> None:
54
+ binding_state = get_binding_state(self.name, self.secret_reader)
55
+ pages = get_status_pages()
56
+
57
+ error = False
58
+ for p in pages:
59
+ try:
60
+ desired_state = StatusPage.init_from_page(p)
61
+ page_provider = AtlassianStatusPageProvider.init_from_page(
62
+ page=p,
63
+ token=self.secret_reader.read_secret(p.credentials),
64
+ component_binding_state=binding_state,
65
+ )
66
+ self.reconcile(
67
+ dry_run,
68
+ desired_state=desired_state,
69
+ current_state=page_provider.get_current_page(),
70
+ provider=page_provider,
71
+ )
72
+ except Exception:
73
+ logging.exception(f"failed to reconcile statuspage {p.name}")
74
+ error = True
75
+
76
+ if error:
77
+ sys.exit(1)
@@ -0,0 +1,73 @@
1
+ import logging
2
+ import sys
3
+
4
+ from reconcile.statuspage.atlassian import AtlassianStatusPageProvider
5
+ from reconcile.statuspage.integration import get_binding_state, get_status_pages
6
+ from reconcile.statuspage.page import StatusMaintenance
7
+ from reconcile.utils.differ import diff_iterables
8
+ from reconcile.utils.runtime.integration import (
9
+ NoParams,
10
+ QontractReconcileIntegration,
11
+ )
12
+ from reconcile.utils.semver_helper import make_semver
13
+
14
+ QONTRACT_INTEGRATION = "status-page-maintenances"
15
+ QONTRACT_INTEGRATION_VERSION = make_semver(0, 1, 0)
16
+
17
+
18
+ class StatusPageMaintenancesIntegration(QontractReconcileIntegration[NoParams]):
19
+ @property
20
+ def name(self) -> str:
21
+ return QONTRACT_INTEGRATION
22
+
23
+ def reconcile(
24
+ self,
25
+ dry_run: bool,
26
+ desired_state: list[StatusMaintenance],
27
+ current_state: list[StatusMaintenance],
28
+ provider: AtlassianStatusPageProvider,
29
+ ) -> None:
30
+ diff = diff_iterables(
31
+ current=current_state, desired=desired_state, key=lambda x: x.name
32
+ )
33
+ for a in diff.add.values():
34
+ logging.info(f"Create StatusPage Maintenance: {a.name}")
35
+ if not dry_run:
36
+ provider.create_maintenance(a)
37
+ for c in diff.change.values():
38
+ raise NotImplementedError(
39
+ f"Update StatusPage Maintenance is not supported at this time: {c.desired.name}"
40
+ )
41
+ for d in diff.delete.values():
42
+ raise NotImplementedError(
43
+ f"Delete StatusPage Maintenance is not supported at this time: {d.name}"
44
+ )
45
+
46
+ def run(self, dry_run: bool = False) -> None:
47
+ binding_state = get_binding_state(self.name, self.secret_reader)
48
+ pages = get_status_pages()
49
+
50
+ error = False
51
+ for p in pages:
52
+ try:
53
+ desired_state = [
54
+ StatusMaintenance.init_from_maintenance(m)
55
+ for m in p.maintenances or []
56
+ ]
57
+ page_provider = AtlassianStatusPageProvider.init_from_page(
58
+ page=p,
59
+ token=self.secret_reader.read_secret(p.credentials),
60
+ component_binding_state=binding_state,
61
+ )
62
+ self.reconcile(
63
+ dry_run=dry_run,
64
+ desired_state=desired_state,
65
+ current_state=page_provider.maintenances,
66
+ provider=page_provider,
67
+ )
68
+ except Exception:
69
+ logging.exception(f"failed to reconcile statuspage {p.name}")
70
+ error = True
71
+
72
+ if error:
73
+ sys.exit(1)
@@ -0,0 +1,114 @@
1
+ from typing import Optional, Self
2
+
3
+ from pydantic import BaseModel
4
+
5
+ from reconcile.gql_definitions.statuspage.statuspages import (
6
+ MaintenanceV1,
7
+ StatusPageComponentV1,
8
+ StatusPageV1,
9
+ )
10
+ from reconcile.statuspage.status import (
11
+ StatusProvider,
12
+ build_status_provider_config,
13
+ )
14
+
15
+
16
+ class StatusComponent(BaseModel):
17
+ """
18
+ Represents a status page component from the desired state.
19
+ """
20
+
21
+ name: str
22
+ display_name: str
23
+ description: Optional[str]
24
+ group_name: Optional[str]
25
+ status_provider_configs: list[StatusProvider]
26
+ """
27
+ Status provider configs hold different ways for a component to determine its status
28
+ """
29
+
30
+ def status_management_enabled(self) -> bool:
31
+ """
32
+ Determines if this component has any status configurations available for
33
+ it to be able to manage its status.
34
+ """
35
+ return bool(self.status_provider_configs)
36
+
37
+ def desired_component_status(self) -> Optional[str]:
38
+ if self.status_management_enabled():
39
+ for provider in self.status_provider_configs:
40
+ status = provider.get_status()
41
+ if status:
42
+ return status
43
+ return "operational"
44
+ return None
45
+
46
+ class Config:
47
+ arbitrary_types_allowed = True
48
+
49
+ @classmethod
50
+ def init_from_page_component(cls, component: StatusPageComponentV1) -> Self:
51
+ status_configs = [
52
+ build_status_provider_config(cfg) for cfg in component.status_config or []
53
+ ]
54
+ return cls(
55
+ name=component.name,
56
+ display_name=component.display_name,
57
+ description=component.description,
58
+ group_name=component.group_name,
59
+ status_provider_configs=[c for c in status_configs if c is not None],
60
+ )
61
+
62
+
63
+ class StatusPage(BaseModel):
64
+ """
65
+ Represents the desired state of a status page and its components.
66
+ """
67
+
68
+ name: str
69
+ """
70
+ The name of the status page.
71
+ """
72
+
73
+ components: list[StatusComponent]
74
+ """
75
+ The desired components of the status page are represented in this list.
76
+ Important note: the actual status page might have more components than
77
+ this desired state does. People can still manage components manually.
78
+ """
79
+
80
+ @classmethod
81
+ def init_from_page(
82
+ cls,
83
+ page: StatusPageV1,
84
+ ) -> Self:
85
+ """
86
+ Translate a desired state status page into a status page object.
87
+ """
88
+ return cls(
89
+ name=page.name,
90
+ components=[
91
+ StatusComponent.init_from_page_component(component=c)
92
+ for c in page.components or []
93
+ ],
94
+ )
95
+
96
+
97
+ class StatusMaintenance(BaseModel):
98
+ """
99
+ Represents the desired state of a status maintenance.
100
+ """
101
+
102
+ name: str
103
+ message: str
104
+ schedule_start: str
105
+ schedule_end: str
106
+
107
+ @classmethod
108
+ def init_from_maintenance(cls, maintenance: MaintenanceV1) -> Self:
109
+ return cls(
110
+ name=maintenance.name,
111
+ message=maintenance.message.rstrip("\n"),
112
+ schedule_start=maintenance.scheduled_start,
113
+ schedule_end=maintenance.scheduled_end,
114
+ )
@@ -0,0 +1,47 @@
1
+ from typing import (
2
+ Optional,
3
+ Protocol,
4
+ )
5
+
6
+ from reconcile.utils.state import State
7
+
8
+ # This module manages the binding state of components in the desired state
9
+ # and their representation on an actual status page.
10
+ #
11
+ # This state management is required to map component identities between app-interface
12
+ # and the status page provider.
13
+
14
+
15
+ class ComponentBindingState(Protocol):
16
+ def get_id_for_component_name(self, component_name: str) -> Optional[str]: ...
17
+
18
+ def get_name_for_component_id(self, component_id: str) -> Optional[str]: ...
19
+
20
+ def bind_component(self, component_name: str, component_id: str) -> None: ...
21
+
22
+ def forget_component(self, component_name: str) -> None: ...
23
+
24
+
25
+ class S3ComponentBindingState(ComponentBindingState):
26
+ def __init__(self, state: State):
27
+ self.state = state
28
+ self._update_cache()
29
+
30
+ def _update_cache(self) -> None:
31
+ self.name_to_id_cache: dict[str, str] = self.state.get_all("")
32
+ self.id_to_name_cache: dict[str, str] = {
33
+ v: k for k, v in self.name_to_id_cache.items()
34
+ }
35
+
36
+ def get_id_for_component_name(self, component_name: str) -> Optional[str]:
37
+ return self.name_to_id_cache.get(component_name)
38
+
39
+ def get_name_for_component_id(self, component_id: str) -> Optional[str]:
40
+ return self.id_to_name_cache.get(component_id)
41
+
42
+ def bind_component(self, component_name: str, component_id: str) -> None:
43
+ self.state.add(component_name, component_id, force=True)
44
+ self._update_cache()
45
+
46
+ def forget_component(self, component_name: str) -> None:
47
+ self.state.rm(component_name)
@@ -0,0 +1,97 @@
1
+ from abc import (
2
+ ABC,
3
+ abstractmethod,
4
+ )
5
+ from datetime import (
6
+ datetime,
7
+ timezone,
8
+ )
9
+ from typing import Optional
10
+
11
+ from dateutil.parser import isoparse
12
+ from pydantic import BaseModel
13
+
14
+ from reconcile.gql_definitions.statuspage.statuspages import (
15
+ ManualStatusProviderV1,
16
+ StatusProviderV1,
17
+ )
18
+
19
+ # This module defines the interface for status providers for components on status
20
+ # pages. A status provider is responsible for determining the status of a component.
21
+ # This status will then be used to update the status page component.
22
+
23
+
24
+ class StatusProvider(ABC):
25
+ """
26
+ The generic static provider interface that can be used to determine the
27
+ status of a component.
28
+ """
29
+
30
+ @abstractmethod
31
+ def get_status(self) -> Optional[str]: ...
32
+
33
+
34
+ class ManualStatusProvider(StatusProvider, BaseModel):
35
+ """
36
+ This status provider is used to manually define the status of a component.
37
+ An optional time windows can be defined in which the status is applied to
38
+ the component.
39
+ """
40
+
41
+ start: Optional[datetime] = None
42
+ """
43
+ The optional start time of the time window in which the manually
44
+ defined status is active.
45
+ """
46
+
47
+ end: Optional[datetime] = None
48
+ """
49
+ The optional end time of the time window in which the manually
50
+ defined status is active.
51
+ """
52
+
53
+ component_status: str
54
+ """
55
+ The status to be used for the component if the
56
+ time window is active or if no time window is defined.
57
+ """
58
+
59
+ def get_status(self) -> Optional[str]:
60
+ if self._is_active():
61
+ return self.component_status
62
+ return None
63
+
64
+ def _is_active(self) -> bool:
65
+ """
66
+ Returns true if the config is active in regards to the start and
67
+ end time window. If no time window is defined, the config is always
68
+ active.
69
+ """
70
+ if self.start and self.end and self.end < self.start:
71
+ raise ValueError(
72
+ "manual component status time window is invalid: end before start"
73
+ )
74
+ now = datetime.now(timezone.utc)
75
+ if self.start and now < self.start:
76
+ return False
77
+ if self.end and self.end < now:
78
+ return False
79
+ return True
80
+
81
+
82
+ def build_status_provider_config(
83
+ cfg: StatusProviderV1,
84
+ ) -> Optional[StatusProvider]:
85
+ """
86
+ Translates a status provider config from the desired state into
87
+ provider specific implementation that provides the status resolution logic.
88
+ """
89
+ if isinstance(cfg, ManualStatusProviderV1):
90
+ start = isoparse(cfg.manual.q_from) if cfg.manual.q_from else None
91
+ end = isoparse(cfg.manual.until) if cfg.manual.until else None
92
+ return ManualStatusProvider(
93
+ component_status=cfg.manual.component_status,
94
+ start=start,
95
+ end=end,
96
+ )
97
+ return None
@@ -0,0 +1,40 @@
1
+ from pydantic import BaseModel
2
+
3
+ from reconcile.gql_definitions.cost_report.cost_namespaces import query
4
+ from reconcile.utils.gql import GqlApi
5
+
6
+
7
+ class CostNamespace(BaseModel):
8
+ name: str
9
+ app_name: str
10
+ cluster_name: str
11
+ cluster_external_id: str | None
12
+
13
+ class Config:
14
+ frozen = True
15
+
16
+
17
+ def get_cost_namespaces(
18
+ gql_api: GqlApi,
19
+ ) -> list[CostNamespace]:
20
+ variables = {
21
+ "filter": {
22
+ "cluster": {
23
+ "filter": {
24
+ "enableCostReport": True,
25
+ },
26
+ },
27
+ },
28
+ }
29
+ namespaces = query(gql_api.query, variables=variables).namespaces or []
30
+ return [
31
+ CostNamespace(
32
+ name=namespace.name,
33
+ app_name=namespace.app.name,
34
+ cluster_name=namespace.cluster.name,
35
+ cluster_external_id=namespace.cluster.spec.external_id
36
+ if namespace.cluster.spec
37
+ else None,
38
+ )
39
+ for namespace in namespaces
40
+ ]
@@ -1,7 +1,6 @@
1
- from collections import defaultdict
2
- from collections.abc import Iterable, Mapping, MutableMapping
1
+ from collections.abc import Iterable, Mapping
3
2
  from decimal import Decimal
4
- from typing import List, Self, Tuple
3
+ from typing import Self
5
4
 
6
5
  from sretoolbox.utils import threaded
7
6
 
@@ -13,27 +12,31 @@ from reconcile.typed_queries.cost_report.settings import get_cost_report_setting
13
12
  from reconcile.utils import gql
14
13
  from reconcile.utils.secret_reader import create_secret_reader
15
14
  from tools.cli_commands.cost_report.cost_management_api import CostManagementApi
16
- from tools.cli_commands.cost_report.model import ChildAppReport, Report, ServiceReport
15
+ from tools.cli_commands.cost_report.model import ChildAppReport, Report, ReportItem
17
16
  from tools.cli_commands.cost_report.response import ReportCostResponse
18
- from tools.cli_commands.cost_report.view import render_report
17
+ from tools.cli_commands.cost_report.util import process_reports
18
+ from tools.cli_commands.cost_report.view import render_aws_cost_report
19
19
 
20
20
  THREAD_POOL_SIZE = 10
21
21
 
22
22
 
23
- class CostReportCommand:
23
+ class AwsCostReportCommand:
24
24
  def __init__(
25
25
  self,
26
26
  gql_api: gql.GqlApi,
27
27
  cost_management_api: CostManagementApi,
28
28
  cost_management_console_base_url: str,
29
+ thread_pool_size: int = THREAD_POOL_SIZE,
29
30
  ) -> None:
30
31
  self.gql_api = gql_api
31
32
  self.cost_management_api = cost_management_api
32
33
  self.cost_management_console_base_url = cost_management_console_base_url
34
+ self.thread_pool_size = thread_pool_size
33
35
 
34
36
  def execute(self) -> str:
35
37
  apps = self.get_apps()
36
- reports = self.get_reports(apps)
38
+ responses = self.get_reports(apps)
39
+ reports = self.process_reports(apps, responses)
37
40
  return self.render(reports)
38
41
 
39
42
  def get_apps(self) -> list[App]:
@@ -42,74 +45,44 @@ class CostReportCommand:
42
45
  """
43
46
  return get_app_names(self.gql_api)
44
47
 
45
- def _fetch_report(self, app: App) -> Tuple[str, ReportCostResponse]:
48
+ def _get_report(self, app: App) -> tuple[str, ReportCostResponse]:
46
49
  return app.name, self.cost_management_api.get_aws_costs_report(app.name)
47
50
 
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]:
51
+ def get_reports(
52
+ self,
53
+ apps: Iterable[App],
54
+ ) -> Mapping[str, ReportCostResponse]:
53
55
  """
54
- Fetch reports from cost management API and build reports with parent-child app tree.
56
+ Fetch reports from cost management API
55
57
  """
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
- )
58
+ results = threaded.run(self._get_report, apps, self.thread_pool_size)
59
+ return dict(results)
79
60
 
80
- def _dfs_reports(
61
+ def process_reports(
81
62
  self,
82
- app_name: str,
83
- parent_app_name: str | None,
84
- child_apps_by_parent: Mapping[str | None, list[str]],
63
+ apps: Iterable[App],
85
64
  responses: Mapping[str, ReportCostResponse],
86
- reports: MutableMapping[str, Report],
87
- ) -> None:
65
+ ) -> dict[str, Report]:
88
66
  """
89
- Depth-first search to build the reports. Build leaf nodes first to ensure total is calculated correctly.
67
+ Build reports with parent-child app tree.
90
68
  """
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,
69
+ return process_reports(
70
+ apps,
71
+ responses,
72
+ report_builder=self._build_report,
73
+ )
74
+
75
+ def render(self, reports: Mapping[str, Report]) -> str:
76
+ return render_aws_cost_report(
104
77
  reports=reports,
105
- response=responses[app_name],
78
+ cost_management_console_base_url=self.cost_management_console_base_url,
106
79
  )
107
80
 
108
81
  @staticmethod
109
82
  def _build_report(
110
83
  app_name: str,
111
84
  parent_app_name: str | None,
112
- child_apps: List[str],
85
+ child_apps: list[str],
113
86
  reports: Mapping[str, Report],
114
87
  response: ReportCostResponse,
115
88
  ) -> Report:
@@ -123,30 +96,34 @@ class CostReportCommand:
123
96
  child_apps_total = Decimal(
124
97
  sum(child_app.total for child_app in child_app_reports)
125
98
  )
126
- services_total = response.meta.total.cost.total.value
127
- total = services_total + child_apps_total
99
+
100
+ items = [
101
+ ReportItem(
102
+ name=service.service,
103
+ delta_value=value.delta_value,
104
+ delta_percent=value.delta_percent,
105
+ total=value.cost.total.value,
106
+ )
107
+ for data in response.data
108
+ for service in data.services
109
+ if len(service.values) == 1 and (value := service.values[0]) is not None
110
+ ]
111
+
112
+ items_total = response.meta.total.cost.total.value
113
+ total = items_total + child_apps_total
128
114
  date = next((d for data in response.data if (d := data.date)), "")
115
+
129
116
  return Report(
130
117
  app_name=app_name,
131
118
  child_apps=child_app_reports,
132
119
  child_apps_total=child_apps_total,
133
120
  date=date,
134
121
  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,
122
+ items_delta_value=response.meta.delta.value,
123
+ items_delta_percent=response.meta.delta.percent,
124
+ items_total=items_total,
138
125
  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
- ],
126
+ items=items,
150
127
  )
151
128
 
152
129
  @classmethod
@@ -169,4 +146,5 @@ class CostReportCommand:
169
146
  gql_api=gql_api,
170
147
  cost_management_api=cost_management_api,
171
148
  cost_management_console_base_url=secret["console_base_url"],
149
+ thread_pool_size=THREAD_POOL_SIZE,
172
150
  )