qontract-reconcile 0.10.1rc1127__py3-none-any.whl → 0.10.1rc1129__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: qontract-reconcile
3
- Version: 0.10.1rc1127
3
+ Version: 0.10.1rc1129
4
4
  Summary: Collection of tools to reconcile services with their desired state as defined in the app-interface DB.
5
5
  Home-page: https://github.com/app-sre/qontract-reconcile
6
6
  Author: Red Hat App-SRE Team
@@ -128,7 +128,7 @@ reconcile/aus/healthchecks.py,sha256=jR9c-syh9impnkV0fd6XW3Bnk7iRN5zv8oCRYM-yIRY
128
128
  reconcile/aus/metrics.py,sha256=nKT4m2zGT-QOMR0c-z-npVNKWsNMubzdffpU_f9n4II,3927
129
129
  reconcile/aus/models.py,sha256=MSKX7SY0GDw7BgpIuKeLT3i_v8E1LFz4M0DQAL7JWUM,7783
130
130
  reconcile/aus/node_pool_spec.py,sha256=FkMggklG-4BgQwud2Swp2m3AAAKzZmeaXgohl9uwxZ8,1138
131
- reconcile/aus/ocm_addons_upgrade_scheduler_org.py,sha256=t2_J7CuovTm6FGvwWI6HsyiftPbt3ZRWEKjsGmIRcEI,10207
131
+ reconcile/aus/ocm_addons_upgrade_scheduler_org.py,sha256=-xliq44ev35P6YzwrGLppReRUWrKDTNptNjaivwICIc,10263
132
132
  reconcile/aus/ocm_upgrade_scheduler.py,sha256=2uPn13y3QGCHLoKwCc1Z7q9wQsoQf_F1HATMYUbl53s,3695
133
133
  reconcile/aus/ocm_upgrade_scheduler_org.py,sha256=QeZAQ1wdDPcwZ77b3Xmt4yBV60rWi3qd8h-vGwnwoCs,3948
134
134
  reconcile/aus/upgrades.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
@@ -194,8 +194,8 @@ reconcile/endpoints_discovery/merge_request_manager.py,sha256=wUMsumxv8RnWaRatta
194
194
  reconcile/external_resources/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
195
195
  reconcile/external_resources/aws.py,sha256=7W-6d-lXO6JGwaxtO1Uc3Lw0p8csJ1EVgz__O1YWUOc,4793
196
196
  reconcile/external_resources/factories.py,sha256=nhdTqf1WEfRfgd5-70KAUJVz0ZvZ19C3Pz7wmotSdrs,4857
197
- reconcile/external_resources/integration.py,sha256=y1gJ16woMBC3J9qniMmS5y3lCkAs7V_ETZRUwjKqaO0,6628
198
- reconcile/external_resources/integration_secrets_sync.py,sha256=cMEZhgCvABAMf-DWF051L6CRnJQdfbsISA_b1xuS940,1670
197
+ reconcile/external_resources/integration.py,sha256=Ne_D5eXtR2a4kFg2Us5h7Zc_WIRgOBxaKbVPic0c6uw,6619
198
+ reconcile/external_resources/integration_secrets_sync.py,sha256=dX09O3r6KURziUYYfiki10orNjOGVma-XojhVqd0ww4,1667
199
199
  reconcile/external_resources/manager.py,sha256=cs6QEirz9EaLiuxybZ_1ugUn61vNWlKAC4NKouqpd5I,16148
200
200
  reconcile/external_resources/meta.py,sha256=noaytFzmShpzLA_ebGh7wuP45mOfHIOnnoUxivjDa1I,672
201
201
  reconcile/external_resources/metrics.py,sha256=jsN3IF78Su23d3Qp4BTKXkgZPN6AKjdS8sZFEXmYCek,863
@@ -616,7 +616,7 @@ reconcile/typed_queries/clusters_with_peering.py,sha256=lIai7SJJD0bqIJbe7virgrbY
616
616
  reconcile/typed_queries/dynatrace.py,sha256=8vXDXDIDf9_vN_efYwysDr4gLN7SCx4I2bOoNxQhbio,312
617
617
  reconcile/typed_queries/dynatrace_environments.py,sha256=VV_7KzKG9wqGDV9wZLbcCJtfuzPhTV1wdg0YwAOaq3A,413
618
618
  reconcile/typed_queries/dynatrace_token_provider_token_specs.py,sha256=x41KG6JRDNYw5QGJYtIFNwSeejUUgxrL-agS8qFf6q0,433
619
- reconcile/typed_queries/external_resources.py,sha256=h1uzZzmtEGzoqSFhDMSAdxauGJoGy0stPuWbA0rkVKE,1503
619
+ reconcile/typed_queries/external_resources.py,sha256=AT5md8nH5gX56UrdWnU4T3_aVF_FanHorXNFkU6s6KY,1573
620
620
  reconcile/typed_queries/get_state_aws_account.py,sha256=CSJjVPWsUZ2rkGIt8ehoQt7hokFqrUDgG9HFlg2lVD8,492
621
621
  reconcile/typed_queries/github_orgs.py,sha256=UZhoPl8qvA_tcO7CZlN8GuMKckt3ywd47Suu61rgHsc,258
622
622
  reconcile/typed_queries/gitlab_instances.py,sha256=ZVQHy2W9xIp53f5qYkjKLHLHgOVtQpxTfcmM1C2046g,291
@@ -838,10 +838,11 @@ tools/app_interface_metrics_exporter.py,sha256=zkwkxdAUAxjdc-pzx2_oJXG25fo0Fnyd5
838
838
  tools/app_interface_reporter.py,sha256=oZPib4HPq0aZ2Zui1QGJGk6qQdfpeihujGDBnSdKyGE,17627
839
839
  tools/glitchtip_access_reporter.py,sha256=oPBnk_YoDuljU3v0FaChzOwwnk4vap1xEE67QEjzdqs,2948
840
840
  tools/glitchtip_access_revalidation.py,sha256=8kbBJk04mkq28kWoRDDkfCGIF3GRg3pJrFAh1sW0dbk,2821
841
- tools/qontract_cli.py,sha256=7cPjwPMHOUcz0JADrD4K7_k-g0kbnqplkmFgPZZ84gU,130565
841
+ tools/qontract_cli.py,sha256=_OqTpZ57TGdWR_0Oc9sT83JVilLKbAz5NbPm1EyL34I,133385
842
842
  tools/sd_app_sre_alert_report.py,sha256=e9vAdyenUz2f5c8-z-5WY0wv-SJ9aePKDH2r4IwB6pc,5063
843
843
  tools/template_validation.py,sha256=qpKYaTgk0GOPGa2Ct5_5sKdwIHtCAKIBGzsMPuJU5fw,3371
844
844
  tools/cli_commands/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
845
+ tools/cli_commands/erv2.py,sha256=vHYBYkTaS3h2qEStuAE6iThCt54LD2o3-0bJLcYODKY,16393
845
846
  tools/cli_commands/gpg_encrypt.py,sha256=x02JOMn834z89YSNvr5B-oJky7rR1C0begCkPh45eHk,4958
846
847
  tools/cli_commands/systems_and_tools.py,sha256=EMHOF1AtUDaoSk0bbjl6oUKYAz4rTZjIBaF-6E6GspM,16816
847
848
  tools/cli_commands/cost_report/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
@@ -865,12 +866,13 @@ tools/sre_checkpoints/util.py,sha256=zEDbGr18ZeHNQwW8pUsr2JRjuXIPz--WAGJxZo9sv_Y
865
866
  tools/test/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
866
867
  tools/test/conftest.py,sha256=YLtiauk_StNFE-lirLnfG_BpJmlB2NGMZISE9A4zwvk,2421
867
868
  tools/test/test_app_interface_metrics_exporter.py,sha256=SX7qL3D1SIRKFo95FoQztvftCWEEf-g1mfXOtgCog-g,1271
869
+ tools/test/test_erv2.py,sha256=EAS7QuJkHisRVO9bMGxm662L5B6i66wF_mT9PAjVzrU,3128
868
870
  tools/test/test_qontract_cli.py,sha256=_D61RFGAN5x44CY1tYbouhlGXXABwYfxKSWSQx3Jrss,4941
869
871
  tools/test/test_saas_promotion_state.py,sha256=dy4kkSSAQ7bC0Xp2CociETGN-2aABEfL6FU5D9Jl00Y,6056
870
872
  tools/test/test_sd_app_sre_alert_report.py,sha256=v363r9zM7__0kR5K6mvJoGFcM9BvE33fWAayrqkpojA,2116
871
873
  tools/test/test_sre_checkpoints.py,sha256=SKqPPTl9ua0RFdSSofnoQX-JZE6dFLO3LRhfQzqtfh8,2607
872
- qontract_reconcile-0.10.1rc1127.dist-info/METADATA,sha256=yNBdxEIOUgziXqZSgMl69r9F3yGcnT_a9xEPJSvCyE8,2213
873
- qontract_reconcile-0.10.1rc1127.dist-info/WHEEL,sha256=eOLhNAGa2EW3wWl_TU484h7q1UNgy0JXjjoqKoxAAQc,92
874
- qontract_reconcile-0.10.1rc1127.dist-info/entry_points.txt,sha256=GKQqCl2j2X1BJQ69een6rHcR26PmnxnONLNOQB-nRjY,491
875
- qontract_reconcile-0.10.1rc1127.dist-info/top_level.txt,sha256=l5ISPoXzt0SdR4jVdkfa7RPSKNc8zAHYWAnR-Dw8Ey8,24
876
- qontract_reconcile-0.10.1rc1127.dist-info/RECORD,,
874
+ qontract_reconcile-0.10.1rc1129.dist-info/METADATA,sha256=Z1h9eqAPOgiPFSvnkYjuHeRDc1Xe5s3E1tX1VHVx1sc,2213
875
+ qontract_reconcile-0.10.1rc1129.dist-info/WHEEL,sha256=eOLhNAGa2EW3wWl_TU484h7q1UNgy0JXjjoqKoxAAQc,92
876
+ qontract_reconcile-0.10.1rc1129.dist-info/entry_points.txt,sha256=GKQqCl2j2X1BJQ69een6rHcR26PmnxnONLNOQB-nRjY,491
877
+ qontract_reconcile-0.10.1rc1129.dist-info/top_level.txt,sha256=l5ISPoXzt0SdR4jVdkfa7RPSKNc8zAHYWAnR-Dw8Ey8,24
878
+ qontract_reconcile-0.10.1rc1129.dist-info/RECORD,,
@@ -251,7 +251,9 @@ def calculate_diff(
251
251
  addon_id,
252
252
  )
253
253
  for current in addon_current_state:
254
- if addon_id == current.addon_id and current.schedule_type == "automatic":
254
+ if addon_id == current.addon_id and (
255
+ current.schedule_type == "automatic" or current.state == "completed"
256
+ ):
255
257
  diffs.append(
256
258
  aus.UpgradePolicyHandler(
257
259
  action="delete",
@@ -84,7 +84,7 @@ def create_er_manager(
84
84
  ) -> ExternalResourcesManager:
85
85
  vault_settings = get_app_interface_vault_settings()
86
86
  secret_reader = create_secret_reader(use_vault=vault_settings.vault)
87
- er_settings = get_settings()[0]
87
+ er_settings = get_settings()
88
88
  m_inventory = load_module_inventory(get_modules())
89
89
  namespaces = [ns for ns in get_namespaces() if ns.external_resources]
90
90
  er_inventory = ExternalResourcesInventory(namespaces)
@@ -139,7 +139,7 @@ def run(
139
139
  ) -> None:
140
140
  vault_settings = get_app_interface_vault_settings()
141
141
  secret_reader = create_secret_reader(use_vault=vault_settings.vault)
142
- er_settings = get_settings()[0]
142
+ er_settings = get_settings()
143
143
 
144
144
  if not workers_cluster:
145
145
  workers_cluster = er_settings.workers_cluster.name
@@ -173,7 +173,7 @@ def run(
173
173
  def early_exit_desired_state(*args: Any, **kwargs: Any) -> dict[str, Any]:
174
174
  vault_settings = get_app_interface_vault_settings()
175
175
  secret_reader = create_secret_reader(use_vault=vault_settings.vault)
176
- er_settings = get_settings()[0]
176
+ er_settings = get_settings()
177
177
 
178
178
  with get_aws_api(
179
179
  query_func=gql.get_api().query,
@@ -25,7 +25,7 @@ def run(dry_run: bool, thread_pool_size: int) -> None:
25
25
  """
26
26
  vault_settings = get_app_interface_vault_settings()
27
27
  secret_reader = create_secret_reader(use_vault=vault_settings.vault)
28
- er_settings = get_settings()[0]
28
+ er_settings = get_settings()
29
29
 
30
30
  namespaces = [ns for ns in get_namespaces() if ns.external_resources]
31
31
  er_inventory = ExternalResourcesInventory(namespaces)
@@ -28,13 +28,13 @@ def get_namespaces(query_func: Callable | None = None) -> list[NamespaceV1]:
28
28
  return list(data.namespaces or [])
29
29
 
30
30
 
31
- def get_settings(
32
- query_func: Callable | None = None,
33
- ) -> list[ExternalResourcesSettingsV1]:
31
+ def get_settings(query_func: Callable | None = None) -> ExternalResourcesSettingsV1:
34
32
  if not query_func:
35
33
  query_func = gql.get_api().query
36
34
  data = query_settings(query_func=query_func)
37
- return list(data.settings or [])
35
+ if not data.settings:
36
+ raise ValueError("No external resources settings found.")
37
+ return data.settings[0]
38
38
 
39
39
 
40
40
  def get_modules(
@@ -0,0 +1,473 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import os
5
+ import sys
6
+ from collections.abc import Iterator
7
+ from contextlib import contextmanager
8
+ from difflib import get_close_matches
9
+ from enum import Enum
10
+ from pathlib import Path
11
+ from subprocess import CalledProcessError, run
12
+ from typing import Protocol
13
+
14
+ from pydantic import BaseModel
15
+ from rich import print as rich_print
16
+ from rich.console import Console
17
+ from rich.progress import Progress, SpinnerColumn, TextColumn, TimeElapsedColumn
18
+ from rich.prompt import Confirm, IntPrompt
19
+
20
+ from reconcile.external_resources.integration import get_aws_api
21
+ from reconcile.external_resources.manager import setup_factories
22
+ from reconcile.external_resources.meta import FLAG_RESOURCE_MANAGED_BY_ERV2
23
+ from reconcile.external_resources.model import (
24
+ ExternalResourceKey,
25
+ ExternalResourceModuleConfiguration,
26
+ ExternalResourcesInventory,
27
+ load_module_inventory,
28
+ )
29
+ from reconcile.external_resources.state import (
30
+ ExternalResourcesStateDynamoDB,
31
+ ResourceStatus,
32
+ )
33
+ from reconcile.typed_queries.external_resources import (
34
+ get_modules,
35
+ get_namespaces,
36
+ get_settings,
37
+ )
38
+ from reconcile.utils import gql
39
+ from reconcile.utils.exceptions import FetchResourceError
40
+ from reconcile.utils.secret_reader import SecretReaderBase
41
+
42
+
43
+ def progress_spinner() -> Progress:
44
+ """Display shiny progress spinner."""
45
+ console = Console(record=True)
46
+ return Progress(
47
+ SpinnerColumn(),
48
+ TextColumn("[progress.description]{task.description}"),
49
+ TimeElapsedColumn(),
50
+ console=console,
51
+ )
52
+
53
+
54
+ @contextmanager
55
+ def pause_progress_spinner(progress: Progress | None) -> Iterator:
56
+ """Pause the progress spinner."""
57
+ if progress:
58
+ progress.stop()
59
+ UP = "\x1b[1A"
60
+ CLEAR = "\x1b[2K"
61
+ for task in progress.tasks:
62
+ if task.finished:
63
+ continue
64
+ print(UP + CLEAR + UP)
65
+ yield
66
+ if progress:
67
+ progress.start()
68
+
69
+
70
+ @contextmanager
71
+ def task(progress: Progress | None, description: str) -> Iterator:
72
+ """Display a task in the progress spinner."""
73
+ if progress:
74
+ task = progress.add_task(description, total=1)
75
+ yield
76
+ if progress:
77
+ progress.advance(task)
78
+
79
+
80
+ class Erv2Cli:
81
+ def __init__(
82
+ self,
83
+ provision_provider: str,
84
+ provisioner: str,
85
+ provider: str,
86
+ identifier: str,
87
+ secret_reader: SecretReaderBase,
88
+ temp_dir: Path | None = None,
89
+ progress_spinner: Progress | None = None,
90
+ ) -> None:
91
+ self._provision_provider = provision_provider
92
+ self._provisioner = provisioner
93
+ self._provider = provider
94
+ self._identifier = identifier
95
+ self._temp_dir = temp_dir
96
+ self.progress_spinner = progress_spinner
97
+
98
+ namespaces = [ns for ns in get_namespaces() if ns.external_resources]
99
+ er_inventory = ExternalResourcesInventory(namespaces)
100
+
101
+ try:
102
+ spec = er_inventory.get_inventory_spec(
103
+ provision_provider=provision_provider,
104
+ provisioner=provisioner,
105
+ provider=provider,
106
+ identifier=identifier,
107
+ )
108
+ except FetchResourceError:
109
+ rich_print(
110
+ f"[b red]Resource {provision_provider}/{provisioner}/{provider}/{identifier} not found[/]. Ensure `managed_by_erv2: true` is set!"
111
+ )
112
+ sys.exit(1)
113
+
114
+ self._secret_reader = secret_reader
115
+ self._er_settings = get_settings()
116
+ m_inventory = load_module_inventory(get_modules())
117
+ factories = setup_factories(
118
+ self._er_settings, m_inventory, er_inventory, self._secret_reader
119
+ )
120
+ f = factories.get_factory(spec.provision_provider)
121
+ self._resource = f.create_external_resource(spec)
122
+ f.validate_external_resource(self._resource)
123
+ self._module_configuration = (
124
+ ExternalResourceModuleConfiguration.resolve_configuration(
125
+ m_inventory.get_from_spec(spec), spec
126
+ )
127
+ )
128
+
129
+ @property
130
+ def input_data(self) -> str:
131
+ return self._resource.json(exclude={"data": {FLAG_RESOURCE_MANAGED_BY_ERV2}})
132
+
133
+ @property
134
+ def image(self) -> str:
135
+ return self._module_configuration.image_version
136
+
137
+ @property
138
+ def temp(self) -> Path:
139
+ if not self._temp_dir:
140
+ raise ValueError("Temp directory is not set!")
141
+ return self._temp_dir
142
+
143
+ def reconcile(self) -> None:
144
+ with get_aws_api(
145
+ query_func=gql.get_api().query,
146
+ account_name=self._er_settings.state_dynamodb_account.name,
147
+ region=self._er_settings.state_dynamodb_region,
148
+ secret_reader=self._secret_reader,
149
+ ) as aws_api:
150
+ state_manager = ExternalResourcesStateDynamoDB(
151
+ aws_api=aws_api,
152
+ table_name=self._er_settings.state_dynamodb_table,
153
+ )
154
+ key = ExternalResourceKey(
155
+ provision_provider=self._provision_provider,
156
+ provisioner_name=self._provisioner,
157
+ provider=self._provider,
158
+ identifier=self._identifier,
159
+ )
160
+ current_state = state_manager.get_external_resource_state(key)
161
+ if current_state.resource_status != ResourceStatus.NOT_EXISTS:
162
+ state_manager.update_resource_status(
163
+ key, ResourceStatus.RECONCILIATION_REQUESTED
164
+ )
165
+ else:
166
+ rich_print("[b red]External Resource does not exist")
167
+
168
+ def build_cdktf(self, credentials: Path) -> None:
169
+ """Run the CDKTF container and return the generated CDKTF json."""
170
+ input_file = self.temp / "input.json"
171
+ input_file.write_text(self.input_data)
172
+
173
+ # delete previous ERv2 container
174
+ run(["docker", "rm", "-f", "erv2"], capture_output=True, check=True)
175
+
176
+ try:
177
+ cdktf_outdir = "/tmp/cdktf.out"
178
+
179
+ # run cdktf synth
180
+ with task(self.progress_spinner, "-- Running CDKTF synth"):
181
+ run(
182
+ [
183
+ "docker",
184
+ "run",
185
+ "--name",
186
+ "erv2",
187
+ "--mount",
188
+ f"type=bind,source={input_file!s},target=/inputs/input.json",
189
+ "--mount",
190
+ f"type=bind,source={credentials!s},target=/credentials",
191
+ "-e",
192
+ "AWS_SHARED_CREDENTIALS_FILE=/credentials",
193
+ "--entrypoint",
194
+ "cdktf",
195
+ self.image,
196
+ "synth",
197
+ "--output",
198
+ cdktf_outdir,
199
+ ],
200
+ check=True,
201
+ capture_output=True,
202
+ )
203
+
204
+ # # get the cdk.tf.json
205
+ with task(self.progress_spinner, "-- Copying the generated cdk.tf.json"):
206
+ run(
207
+ [
208
+ "docker",
209
+ "cp",
210
+ f"erv2:{cdktf_outdir}/stacks/CDKTF/cdk.tf.json",
211
+ str(self.temp),
212
+ ],
213
+ check=True,
214
+ capture_output=True,
215
+ )
216
+ except CalledProcessError as e:
217
+ print(e.stderr.decode("utf-8"))
218
+ print(e.stdout.decode("utf-8"))
219
+ raise
220
+
221
+
222
+ class TfRun(Protocol):
223
+ def __call__(self, path: Path, cmd: list[str]) -> str: ...
224
+
225
+
226
+ def tf_run(path: Path, cmd: list[str]) -> str:
227
+ env = os.environ.copy()
228
+ env["TF_CLI_ARGS"] = "-no-color"
229
+ try:
230
+ return run(
231
+ ["terraform", *cmd],
232
+ cwd=path,
233
+ check=True,
234
+ capture_output=True,
235
+ env=env,
236
+ ).stdout.decode("utf-8")
237
+ except CalledProcessError as e:
238
+ print(e.stderr.decode("utf-8"))
239
+ print(e.stdout.decode("utf-8"))
240
+ raise
241
+
242
+
243
+ class TfAction(Enum):
244
+ CREATE = "create"
245
+ DESTROY = "delete"
246
+
247
+
248
+ class TfResource(BaseModel):
249
+ address: str
250
+
251
+ @property
252
+ def id(self) -> str:
253
+ return self.address.split(".")[1]
254
+
255
+ @property
256
+ def type(self) -> str:
257
+ return self.address.split(".")[0]
258
+
259
+ def __str__(self) -> str:
260
+ return self.address
261
+
262
+ def __repr__(self) -> str:
263
+ return str(self)
264
+
265
+
266
+ class TfResourceList(BaseModel):
267
+ resources: list[TfResource]
268
+
269
+ def __iter__(self) -> Iterator[TfResource]: # type: ignore
270
+ return iter(self.resources)
271
+
272
+ def _get_resource_by_address(self, address: str) -> TfResource | None:
273
+ for resource in self.resources:
274
+ if resource.address == address:
275
+ return resource
276
+ return None
277
+
278
+ def _get_resources_by_type(self, type: str) -> list[TfResource]:
279
+ results = [resource for resource in self.resources if resource.type == type]
280
+ if not results:
281
+ raise KeyError(f"Resource type '{type}' not found!")
282
+ return results
283
+
284
+ def __getitem__(self, tf_resource: TfResource) -> list[TfResource]:
285
+ """Get a resource by searching the resource list.
286
+
287
+ self holds the source resources (terraform-resources).
288
+ The tf_resource is the destination resource (ERv2).
289
+ """
290
+ if resource := self._get_resource_by_address(tf_resource.address):
291
+ # exact match by AWS address
292
+ return [resource]
293
+
294
+ # a resource with the same ID does not exist
295
+ # let's try to find the resource by the AWS type
296
+ results = self._get_resources_by_type(tf_resource.type)
297
+ if len(results) == 1:
298
+ # there is just one resource with the same type
299
+ # this must be the searched resource.
300
+ return results
301
+
302
+ # ok, now it's getting tricky. Let's use difflib and let the user decide.
303
+ possible_matches_ids = get_close_matches(
304
+ tf_resource.id, [r.id for r in results]
305
+ )
306
+ return [r for r in results if r.id in possible_matches_ids]
307
+
308
+ def __len__(self) -> int:
309
+ return len(self.resources)
310
+
311
+
312
+ class TerraformCli:
313
+ def __init__(
314
+ self,
315
+ path: Path,
316
+ dry_run: bool = True,
317
+ tf_run: TfRun = tf_run,
318
+ progress_spinner: Progress | None = None,
319
+ ) -> None:
320
+ self._path = path
321
+ self._dry_run = dry_run
322
+ self._tf_run = tf_run
323
+ self.progress_spinner = progress_spinner
324
+ self.initialized = False
325
+
326
+ def init(self) -> None:
327
+ """Initialize the terraform modules."""
328
+ self._tf_init()
329
+ self._tf_plan()
330
+ self._tf_state_pull()
331
+ self.initialized = True
332
+
333
+ @property
334
+ def state_file(self) -> Path:
335
+ return self._path / "state.json"
336
+
337
+ def _tf_init(self) -> None:
338
+ with task(self.progress_spinner, "-- Running terraform init"):
339
+ self._tf_run(self._path, ["init"])
340
+
341
+ def _tf_plan(self) -> None:
342
+ with task(self.progress_spinner, "-- Running terraform plan"):
343
+ self._tf_run(self._path, ["plan", "-out=plan.out"])
344
+
345
+ def _tf_state_pull(self) -> None:
346
+ with task(self.progress_spinner, "-- Retrieving the terraform state"):
347
+ self.state_file.write_text(self._tf_run(self._path, ["state", "pull"]))
348
+
349
+ def _tf_state_push(self) -> None:
350
+ with task(
351
+ self.progress_spinner,
352
+ f"-- Uploading the terraform state {'[b red](DRY-RUN)' if self._dry_run else ''}",
353
+ ):
354
+ if not self._dry_run:
355
+ self._tf_run(self._path, ["state", "push", str(self.state_file)])
356
+
357
+ def upload_state(self) -> None:
358
+ self._tf_state_push()
359
+
360
+ def resource_changes(self, action: TfAction) -> TfResourceList:
361
+ """Get the resource changes."""
362
+ plan = json.loads(self._tf_run(self._path, ["show", "-json", "plan.out"]))
363
+ return TfResourceList(
364
+ resources=[
365
+ TfResource(address=r["address"])
366
+ for r in plan["resource_changes"]
367
+ if action.value.lower() in r["change"]["actions"]
368
+ ]
369
+ )
370
+
371
+ def move_resource(
372
+ self, source_state_file: Path, source: TfResource, destination: TfResource
373
+ ) -> None:
374
+ """Move the resource from source state file to destination state file."""
375
+ if self.progress_spinner:
376
+ self.progress_spinner.log(
377
+ f"-- Moving {destination} {'[b red](DRY-RUN)' if self._dry_run else ''}"
378
+ )
379
+ if not self._dry_run:
380
+ self._tf_run(
381
+ self._path,
382
+ [
383
+ "state",
384
+ "mv",
385
+ "-lock=false",
386
+ f"-state={source_state_file!s}",
387
+ f"-state-out={self.state_file!s}",
388
+ f"{source.address}",
389
+ f"{destination.address}",
390
+ ],
391
+ )
392
+
393
+ def migrate_resources(self, source: TerraformCli) -> None:
394
+ """Migrate the resources from source."""
395
+ # if not self.initialized or not source.initialized:
396
+ # raise ValueError("Terraform must be initialized before!")
397
+
398
+ source_resources = source.resource_changes(TfAction.DESTROY)
399
+ destination_resources = self.resource_changes(TfAction.CREATE)
400
+
401
+ if not source_resources or not destination_resources:
402
+ raise ValueError("No resource changes found!")
403
+
404
+ if len(source_resources) != len(destination_resources):
405
+ with pause_progress_spinner(self.progress_spinner):
406
+ rich_print(
407
+ "[b red]The number of changes (ERv2 vs terraform-resource) does not match! Please review them carefully![/]"
408
+ )
409
+ rich_print("ERv2:")
410
+ rich_print(
411
+ "\n".join([
412
+ f" {i}: {r.address}"
413
+ for i, r in enumerate(destination_resources, start=1)
414
+ ])
415
+ )
416
+ rich_print("Terraform:")
417
+ rich_print(
418
+ "\n".join([
419
+ f" {i}: {r.address}"
420
+ for i, r in enumerate(source_resources, start=1)
421
+ ])
422
+ )
423
+ if not Confirm.ask("Would you like to continue anyway?", default=False):
424
+ return
425
+
426
+ for destination_resource in destination_resources:
427
+ possible_source_resouces = source_resources[destination_resource]
428
+ if not possible_source_resouces:
429
+ raise ValueError(
430
+ f"Source resource for {destination_resource} not found!"
431
+ )
432
+ elif len(possible_source_resouces) == 1:
433
+ # just one resource found.
434
+ source_resource = possible_source_resouces[0]
435
+ else:
436
+ # more than one resource found. Let the user decide.
437
+ with pause_progress_spinner(self.progress_spinner):
438
+ rich_print(
439
+ f"[b red]{destination_resource.address} not found![/] Please select the related source ID manually!"
440
+ )
441
+ for i, r in enumerate(possible_source_resouces, start=1):
442
+ print(f"{i}: {r.address}")
443
+
444
+ index = IntPrompt.ask(
445
+ ":boom: Enter the number",
446
+ choices=[
447
+ str(i) for i in range(1, len(possible_source_resouces) + 1)
448
+ ],
449
+ show_choices=False,
450
+ )
451
+ source_resource = possible_source_resouces[index - 1]
452
+
453
+ self.move_resource(
454
+ source_state_file=source.state_file,
455
+ source=source_resource,
456
+ destination=destination_resource,
457
+ )
458
+
459
+ if not self._dry_run:
460
+ if self.progress_spinner:
461
+ self.progress_spinner.stop()
462
+ if not Confirm.ask(
463
+ "\nEverything ok? Would you like to upload the modified terraform states",
464
+ default=False,
465
+ ):
466
+ return
467
+
468
+ if self.progress_spinner:
469
+ self.progress_spinner.start()
470
+
471
+ # finally push the terraform states
472
+ self.upload_state()
473
+ source.upload_state()
tools/qontract_cli.py CHANGED
@@ -14,7 +14,9 @@ from datetime import (
14
14
  timedelta,
15
15
  )
16
16
  from operator import itemgetter
17
+ from pathlib import Path
17
18
  from statistics import median
19
+ from textwrap import dedent
18
20
  from typing import Any
19
21
 
20
22
  import boto3
@@ -23,10 +25,9 @@ import click.core
23
25
  import requests
24
26
  import yaml
25
27
  from rich import box
26
- from rich.console import (
27
- Console,
28
- Group,
29
- )
28
+ from rich import print as rich_print
29
+ from rich.console import Console, Group
30
+ from rich.prompt import Confirm
30
31
  from rich.table import Table
31
32
  from rich.tree import Tree
32
33
 
@@ -59,25 +60,11 @@ from reconcile.change_owners.change_owners import (
59
60
  )
60
61
  from reconcile.checkpoint import report_invalid_metadata
61
62
  from reconcile.cli import (
63
+ TERRAFORM_VERSION,
64
+ TERRAFORM_VERSION_REGEX,
62
65
  config_file,
63
66
  use_jump_host,
64
67
  )
65
- from reconcile.external_resources.integration import (
66
- get_aws_api,
67
- )
68
- from reconcile.external_resources.manager import (
69
- setup_factories,
70
- )
71
- from reconcile.external_resources.meta import FLAG_RESOURCE_MANAGED_BY_ERV2
72
- from reconcile.external_resources.model import (
73
- ExternalResourceKey,
74
- ExternalResourcesInventory,
75
- load_module_inventory,
76
- )
77
- from reconcile.external_resources.state import (
78
- ExternalResourcesStateDynamoDB,
79
- ResourceStatus,
80
- )
81
68
  from reconcile.gql_definitions.advanced_upgrade_service.aus_clusters import (
82
69
  query as aus_clusters_query,
83
70
  )
@@ -99,11 +86,6 @@ from reconcile.typed_queries.app_quay_repos_escalation_policies import (
99
86
  get_apps_quay_repos_escalation_policies,
100
87
  )
101
88
  from reconcile.typed_queries.clusters import get_clusters
102
- from reconcile.typed_queries.external_resources import (
103
- get_modules,
104
- get_namespaces,
105
- get_settings,
106
- )
107
89
  from reconcile.typed_queries.saas_files import get_saas_files
108
90
  from reconcile.typed_queries.slo_documents import get_slo_documents
109
91
  from reconcile.typed_queries.status_board import get_status_board
@@ -168,6 +150,12 @@ from reconcile.utils.state import init_state
168
150
  from reconcile.utils.terraform_client import TerraformClient as Terraform
169
151
  from tools.cli_commands.cost_report.aws import AwsCostReportCommand
170
152
  from tools.cli_commands.cost_report.openshift import OpenShiftCostReportCommand
153
+ from tools.cli_commands.erv2 import (
154
+ Erv2Cli,
155
+ TerraformCli,
156
+ progress_spinner,
157
+ task,
158
+ )
171
159
  from tools.cli_commands.gpg_encrypt import (
172
160
  GPGEncryptCommand,
173
161
  GPGEncryptCommandData,
@@ -3945,85 +3933,185 @@ def remove(ctx, sso_client_vault_secret_path: str):
3945
3933
 
3946
3934
 
3947
3935
  @root.group()
3936
+ @click.option(
3937
+ "--provision-provider",
3938
+ required=True,
3939
+ help="externalResources.provider",
3940
+ default="aws",
3941
+ )
3942
+ @click.option(
3943
+ "--provisioner",
3944
+ required=True,
3945
+ help="externalResources.provisioner.name. E.g. app-sre-stage",
3946
+ prompt=True,
3947
+ )
3948
+ @click.option(
3949
+ "--provider",
3950
+ required=True,
3951
+ help="externalResources.resources.provider. E.g. rds, msk, ...",
3952
+ prompt=True,
3953
+ )
3954
+ @click.option(
3955
+ "--identifier",
3956
+ required=True,
3957
+ help="externalResources.resources.identifier. E.g. erv2-example",
3958
+ prompt=True,
3959
+ )
3948
3960
  @click.pass_context
3949
- def external_resources(ctx):
3961
+ def external_resources(
3962
+ ctx, provision_provider: str, provisioner: str, provider: str, identifier: str
3963
+ ):
3950
3964
  """External resources commands"""
3965
+ ctx.obj["provision_provider"] = provision_provider
3966
+ ctx.obj["provisioner"] = provisioner
3967
+ ctx.obj["provider"] = provider
3968
+ ctx.obj["identifier"] = identifier
3969
+ vault_settings = get_app_interface_vault_settings()
3970
+ ctx.obj["secret_reader"] = create_secret_reader(use_vault=vault_settings.vault)
3951
3971
 
3952
3972
 
3953
3973
  @external_resources.command()
3954
- @click.argument("provision-provider", required=True)
3955
- @click.argument("provisioner", required=True)
3956
- @click.argument("provider", required=True)
3957
- @click.argument("identifier", required=True)
3958
3974
  @click.pass_context
3959
- def get_input(
3960
- ctx, provision_provider: str, provisioner: str, provider: str, identifier: str
3961
- ):
3975
+ def get_input(ctx):
3962
3976
  """Gets the input data for an external resource asset. Input data is what is used
3963
- in the Reconciliation Job to manage the resource.
3964
-
3965
- e.g: qontract-reconcile --config=<config> external-resources get-input aws app-sre-stage rds dashdotdb-stage
3966
- """
3967
- namespaces = [ns for ns in get_namespaces() if ns.external_resources]
3968
- er_inventory = ExternalResourcesInventory(namespaces)
3969
-
3970
- spec = er_inventory.get_inventory_spec(
3971
- provision_provider=provision_provider,
3972
- provisioner=provisioner,
3973
- provider=provider,
3974
- identifier=identifier,
3977
+ in the Reconciliation Job to manage the resource."""
3978
+ erv2cli = Erv2Cli(
3979
+ provision_provider=ctx.obj["provision_provider"],
3980
+ provisioner=ctx.obj["provisioner"],
3981
+ provider=ctx.obj["provider"],
3982
+ identifier=ctx.obj["identifier"],
3983
+ secret_reader=ctx.obj["secret_reader"],
3975
3984
  )
3976
- vault_settings = get_app_interface_vault_settings()
3977
- secret_reader = create_secret_reader(use_vault=vault_settings.vault)
3978
- er_settings = get_settings()[0]
3979
- m_inventory = load_module_inventory(get_modules())
3980
- factories = setup_factories(er_settings, m_inventory, er_inventory, secret_reader)
3981
- f = factories.get_factory(spec.provision_provider)
3982
- resource = f.create_external_resource(spec)
3983
- f.validate_external_resource(resource)
3984
- print(resource.json(exclude={"data": {FLAG_RESOURCE_MANAGED_BY_ERV2}}))
3985
+ print(erv2cli.input_data)
3985
3986
 
3986
3987
 
3987
3988
  @external_resources.command()
3988
- @click.argument("provision-provider", required=True)
3989
- @click.argument("provisioner", required=True)
3990
- @click.argument("provider", required=True)
3991
- @click.argument("identifier", required=True)
3992
3989
  @click.pass_context
3993
- def request_reconciliation(
3994
- ctx, provision_provider: str, provisioner: str, provider: str, identifier: str
3995
- ):
3990
+ def request_reconciliation(ctx):
3996
3991
  """Marks a resource as it needs to get reconciled. The itegration will reconcile the resource at
3997
- its next iteration.
3992
+ its next iteration."""
3993
+ erv2cli = Erv2Cli(
3994
+ provision_provider=ctx.obj["provision_provider"],
3995
+ provisioner=ctx.obj["provisioner"],
3996
+ provider=ctx.obj["provider"],
3997
+ identifier=ctx.obj["identifier"],
3998
+ secret_reader=ctx.obj["secret_reader"],
3999
+ )
4000
+ erv2cli.reconcile()
4001
+
4002
+
4003
+ @external_resources.command()
4004
+ @binary(["terraform"])
4005
+ @binary_version("terraform", ["version"], TERRAFORM_VERSION_REGEX, TERRAFORM_VERSION)
4006
+ @click.option(
4007
+ "--dry-run/--no-dry-run",
4008
+ help="Enable/Disable dry-run. Default: dry-run enabled!",
4009
+ default=True,
4010
+ )
4011
+ @click.option(
4012
+ "--skip-build/--no-skip-build",
4013
+ help="Skip/Do not skip the terraform and CDKTF builds. Default: build everything!",
4014
+ default=False,
4015
+ )
4016
+ @click.pass_context
4017
+ def migrate(ctx, dry_run: bool, skip_build: bool) -> None:
4018
+ """Migrate an existing external resource managed by terraform-resources to ERv2.
4019
+
3998
4020
 
3999
- e.g: e.g: qontract-reconcile --config=<config> external-resources request-reconciliation aws app-sre-stage rds dashdotdb-stage
4021
+ E.g: qontract-reconcile --config=<config> external-resources migrate aws app-sre-stage rds dashdotdb-stage
4000
4022
  """
4001
- er_settings = get_settings()[0]
4002
- vault_settings = get_app_interface_vault_settings()
4003
- secret_reader = create_secret_reader(use_vault=vault_settings.vault)
4004
- with get_aws_api(
4005
- query_func=gql.get_api().query,
4006
- account_name=er_settings.state_dynamodb_account.name,
4007
- region=er_settings.state_dynamodb_region,
4008
- secret_reader=secret_reader,
4009
- ) as aws_api:
4010
- state_manager = ExternalResourcesStateDynamoDB(
4011
- aws_api=aws_api,
4012
- table_name=er_settings.state_dynamodb_table,
4013
- )
4014
- key = ExternalResourceKey(
4015
- provision_provider=provision_provider,
4016
- provisioner_name=provisioner,
4017
- provider=provider,
4018
- identifier=identifier,
4023
+ if ctx.obj["provider"] == "rds":
4024
+ # The "random_password" is not an AWS resource. It's just in the outputs and can't be migrated :(
4025
+ raise NotImplementedError("RDS migration is not supported yet!")
4026
+
4027
+ if not Confirm.ask(
4028
+ dedent("""
4029
+ Please disable terraform-resources: [i blue]https://app-interface.unleash.devshift.net/projects/default/features/terraform-resources[/]
4030
+ in Unleash before proceeding!
4031
+
4032
+ Do you want to proceed?"""),
4033
+ default=True,
4034
+ ):
4035
+ sys.exit(0)
4036
+
4037
+ # use a temporary directory in $HOME. The MacOS colima default configuration allows docker mounts from $HOME.
4038
+ tempdir = Path.home() / ".erv2-migration"
4039
+ rich_print(f"Using temporary directory: [b]{tempdir}[/]")
4040
+ tempdir.mkdir(exist_ok=True)
4041
+ temp_erv2 = Path(tempdir) / "erv2"
4042
+ temp_erv2.mkdir(exist_ok=True)
4043
+ temp_tfr = tempdir / "terraform-resources"
4044
+ temp_tfr.mkdir(exist_ok=True)
4045
+
4046
+ with progress_spinner() as progress:
4047
+ with task(progress, "Preparing AWS credentials for CDKTF and local terraform"):
4048
+ # prepare AWS credentials for CDKTF and local terraform
4049
+ credentials_file = tempdir / "credentials"
4050
+ credentials_file.write_text(
4051
+ ctx.obj["secret_reader"].read_with_parameters(
4052
+ path=f"app-sre/external-resources/{ctx.obj['provisioner']}",
4053
+ field="credentials",
4054
+ format=None,
4055
+ version=None,
4056
+ )
4057
+ )
4058
+ os.environ["AWS_SHARED_CREDENTIALS_FILE"] = str(credentials_file)
4059
+
4060
+ erv2cli = Erv2Cli(
4061
+ provision_provider=ctx.obj["provision_provider"],
4062
+ provisioner=ctx.obj["provisioner"],
4063
+ provider=ctx.obj["provider"],
4064
+ identifier=ctx.obj["identifier"],
4065
+ secret_reader=ctx.obj["secret_reader"],
4066
+ temp_dir=temp_erv2,
4067
+ progress_spinner=progress,
4019
4068
  )
4020
- current_state = state_manager.get_external_resource_state(key)
4021
- if current_state.resource_status != ResourceStatus.NOT_EXISTS:
4022
- state_manager.update_resource_status(
4023
- key, ResourceStatus.RECONCILIATION_REQUESTED
4069
+
4070
+ with task(progress, "(erv2) Building the terraform configuration"):
4071
+ if not skip_build:
4072
+ # build the CDKTF output
4073
+ erv2cli.build_cdktf(credentials_file)
4074
+ erv2_tf_cli = TerraformCli(
4075
+ temp_erv2, dry_run=dry_run, progress_spinner=progress
4024
4076
  )
4025
- else:
4026
- logging.info("External Resource does not exist")
4077
+ if not skip_build:
4078
+ erv2_tf_cli.init()
4079
+
4080
+ with task(
4081
+ progress, "(terraform-resources) Building the terraform configuration"
4082
+ ):
4083
+ # build the terraform-resources output
4084
+ conf_tf = temp_tfr / "conf.tf.json"
4085
+ if not skip_build:
4086
+ tfr.run(
4087
+ dry_run=True,
4088
+ print_to_file=str(conf_tf),
4089
+ account_name=[ctx.obj["provisioner"]],
4090
+ )
4091
+ # remove comments
4092
+ conf_tf.write_text(
4093
+ "\n".join(
4094
+ line
4095
+ for line in conf_tf.read_text().splitlines()
4096
+ if not line.startswith("#")
4097
+ )
4098
+ )
4099
+ tfr_tf_cli = TerraformCli(
4100
+ temp_tfr, dry_run=dry_run, progress_spinner=progress
4101
+ )
4102
+ if not skip_build:
4103
+ tfr_tf_cli.init()
4104
+
4105
+ with progress_spinner() as progress:
4106
+ # start a new spinner instance for clean output
4107
+ erv2_tf_cli.progress_spinner = progress
4108
+ with task(
4109
+ progress,
4110
+ "Migrating the resources from terraform-resources to ERv2",
4111
+ ):
4112
+ erv2_tf_cli.migrate_resources(source=tfr_tf_cli)
4113
+
4114
+ rich_print(f"[b red]Please remove the temporary directory ({tempdir}) manually!")
4027
4115
 
4028
4116
 
4029
4117
  if __name__ == "__main__":
@@ -0,0 +1,80 @@
1
+ import pytest
2
+
3
+ from tools.cli_commands.erv2 import TfResource, TfResourceList
4
+
5
+
6
+ def test_erv2_model_tfresource_list() -> None:
7
+ # terraform_resource_list
8
+ terraform_resource_list = TfResourceList(
9
+ resources=[
10
+ TfResource(address="postfix-module.user-1"),
11
+ TfResource(address="postfix-module.user-2"),
12
+ TfResource(address="prefix-module.user-1"),
13
+ TfResource(address="prefix-module.user-2"),
14
+ TfResource(address="exact-module.identifier"),
15
+ TfResource(address="something.else"),
16
+ # real life examples
17
+ TfResource(
18
+ address="aws_secretsmanager_secret.AmazonMSK_playground-msk-stage-playground-msk-stage"
19
+ ),
20
+ TfResource(
21
+ address="aws_secretsmanager_secret.AmazonMSK_playground-msk-stage-playground-msk-stage-another-user"
22
+ ),
23
+ TfResource(address="aws_secretsmanager_secret.playground-user"),
24
+ TfResource(address="aws_secretsmanager_secret.foobar-playground-user"),
25
+ ]
26
+ )
27
+ assert len(terraform_resource_list) == 10
28
+ # exact match
29
+ assert terraform_resource_list[TfResource(address="exact-module.identifier")] == [
30
+ TfResource(address="exact-module.identifier")
31
+ ]
32
+ # with postfix
33
+ assert terraform_resource_list[
34
+ TfResource(address="postfix-module.user-1-postfix")
35
+ ] == [TfResource(address="postfix-module.user-1")]
36
+ # with prefix
37
+ assert terraform_resource_list[
38
+ TfResource(address="prefix-module.prefix-user-1")
39
+ ] == [TfResource(address="prefix-module.user-1")]
40
+
41
+ # real life examples
42
+ assert terraform_resource_list[
43
+ TfResource(
44
+ address="aws_secretsmanager_secret.AmazonMSK_playground-msk-stage-playground-msk-stage-secret"
45
+ )
46
+ ] == [
47
+ TfResource(
48
+ address="aws_secretsmanager_secret.AmazonMSK_playground-msk-stage-playground-msk-stage"
49
+ ),
50
+ TfResource(
51
+ address="aws_secretsmanager_secret.AmazonMSK_playground-msk-stage-playground-msk-stage-another-user"
52
+ ),
53
+ ]
54
+ assert terraform_resource_list[
55
+ TfResource(
56
+ address="aws_secretsmanager_secret.AmazonMSK_playground-msk-stage-playground-msk-stage-another-user-secret"
57
+ )
58
+ ] == [
59
+ TfResource(
60
+ address="aws_secretsmanager_secret.AmazonMSK_playground-msk-stage-playground-msk-stage"
61
+ ),
62
+ TfResource(
63
+ address="aws_secretsmanager_secret.AmazonMSK_playground-msk-stage-playground-msk-stage-another-user"
64
+ ),
65
+ ]
66
+
67
+ assert terraform_resource_list[
68
+ TfResource(address="aws_secretsmanager_secret.secret-foobar-playground-user")
69
+ ] == [
70
+ TfResource(address="aws_secretsmanager_secret.playground-user"),
71
+ TfResource(address="aws_secretsmanager_secret.foobar-playground-user"),
72
+ ]
73
+ with pytest.raises(KeyError):
74
+ # unknown resource type
75
+ terraform_resource_list[TfResource(address="not.found")]
76
+
77
+ assert (
78
+ terraform_resource_list[TfResource(address="aws_secretsmanager_secret.found")]
79
+ == []
80
+ )