dbt-platform-helper 12.0.2__py3-none-any.whl → 12.2.0__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.

Potentially problematic release.


This version of dbt-platform-helper might be problematic. Click here for more details.

Files changed (36) hide show
  1. dbt_platform_helper/COMMANDS.md +7 -8
  2. dbt_platform_helper/commands/application.py +1 -0
  3. dbt_platform_helper/commands/codebase.py +63 -228
  4. dbt_platform_helper/commands/conduit.py +34 -409
  5. dbt_platform_helper/commands/secrets.py +1 -1
  6. dbt_platform_helper/constants.py +12 -1
  7. dbt_platform_helper/domain/codebase.py +222 -0
  8. dbt_platform_helper/domain/conduit.py +172 -0
  9. dbt_platform_helper/domain/database_copy.py +1 -1
  10. dbt_platform_helper/exceptions.py +61 -0
  11. dbt_platform_helper/providers/__init__.py +0 -0
  12. dbt_platform_helper/providers/cloudformation.py +105 -0
  13. dbt_platform_helper/providers/copilot.py +144 -0
  14. dbt_platform_helper/providers/ecs.py +78 -0
  15. dbt_platform_helper/providers/secrets.py +85 -0
  16. dbt_platform_helper/templates/addons/svc/prometheus-policy.yml +2 -0
  17. dbt_platform_helper/templates/pipelines/environments/manifest.yml +0 -1
  18. dbt_platform_helper/utils/application.py +1 -4
  19. dbt_platform_helper/utils/aws.py +132 -0
  20. dbt_platform_helper/utils/files.py +70 -0
  21. dbt_platform_helper/utils/git.py +13 -0
  22. dbt_platform_helper/utils/validation.py +121 -3
  23. {dbt_platform_helper-12.0.2.dist-info → dbt_platform_helper-12.2.0.dist-info}/METADATA +2 -1
  24. {dbt_platform_helper-12.0.2.dist-info → dbt_platform_helper-12.2.0.dist-info}/RECORD +27 -29
  25. {dbt_platform_helper-12.0.2.dist-info → dbt_platform_helper-12.2.0.dist-info}/WHEEL +1 -1
  26. dbt_platform_helper/templates/env/overrides/.gitignore +0 -12
  27. dbt_platform_helper/templates/env/overrides/README.md +0 -11
  28. dbt_platform_helper/templates/env/overrides/bin/override.ts +0 -9
  29. dbt_platform_helper/templates/env/overrides/cdk.json +0 -20
  30. dbt_platform_helper/templates/env/overrides/log_resource_policy.json +0 -68
  31. dbt_platform_helper/templates/env/overrides/package-lock.json +0 -4307
  32. dbt_platform_helper/templates/env/overrides/package.json +0 -27
  33. dbt_platform_helper/templates/env/overrides/stack.ts +0 -51
  34. dbt_platform_helper/templates/env/overrides/tsconfig.json +0 -32
  35. {dbt_platform_helper-12.0.2.dist-info → dbt_platform_helper-12.2.0.dist-info}/LICENSE +0 -0
  36. {dbt_platform_helper-12.0.2.dist-info → dbt_platform_helper-12.2.0.dist-info}/entry_points.txt +0 -0
@@ -1,453 +1,78 @@
1
- import json
2
- import random
3
- import string
4
- import subprocess
5
- import time
6
-
7
1
  import click
8
- from botocore.exceptions import ClientError
9
- from cfn_tools import dump_yaml
10
- from cfn_tools import load_yaml
11
2
 
12
- from dbt_platform_helper.utils.application import Application
3
+ from dbt_platform_helper.constants import CONDUIT_ADDON_TYPES
4
+ from dbt_platform_helper.domain.conduit import Conduit
5
+ from dbt_platform_helper.exceptions import AddonNotFoundError
6
+ from dbt_platform_helper.exceptions import AddonTypeMissingFromConfigError
7
+ from dbt_platform_helper.exceptions import CreateTaskTimeoutError
8
+ from dbt_platform_helper.exceptions import InvalidAddonTypeError
9
+ from dbt_platform_helper.exceptions import NoClusterError
10
+ from dbt_platform_helper.exceptions import ParameterNotFoundError
11
+ from dbt_platform_helper.providers.secrets import SecretNotFoundError
13
12
  from dbt_platform_helper.utils.application import load_application
14
- from dbt_platform_helper.utils.aws import (
15
- get_postgres_connection_data_updated_with_master_secret,
16
- )
17
13
  from dbt_platform_helper.utils.click import ClickDocOptCommand
18
- from dbt_platform_helper.utils.messages import abort_with_error
19
14
  from dbt_platform_helper.utils.versioning import (
20
15
  check_platform_helper_version_needs_update,
21
16
  )
22
17
 
23
-
24
- class ConduitError(Exception):
25
- pass
26
-
27
-
28
- class InvalidAddonTypeConduitError(ConduitError):
29
- def __init__(self, addon_type):
30
- self.addon_type = addon_type
31
-
32
-
33
- class NoClusterConduitError(ConduitError):
34
- pass
35
-
36
-
37
- class SecretNotFoundConduitError(ConduitError):
38
- pass
39
-
40
-
41
- class CreateTaskTimeoutConduitError(ConduitError):
42
- pass
43
-
44
-
45
- class ParameterNotFoundConduitError(ConduitError):
46
- pass
47
-
48
-
49
- class AddonNotFoundConduitError(ConduitError):
50
- pass
51
-
52
-
53
- CONDUIT_DOCKER_IMAGE_LOCATION = "public.ecr.aws/uktrade/tunnel"
54
- CONDUIT_ADDON_TYPES = [
55
- "opensearch",
56
- "postgres",
57
- "redis",
58
- ]
59
18
  CONDUIT_ACCESS_OPTIONS = ["read", "write", "admin"]
60
19
 
61
20
 
62
- def normalise_secret_name(addon_name: str) -> str:
63
- return addon_name.replace("-", "_").upper()
64
-
65
-
66
- def get_addon_type(app: Application, env: str, addon_name: str) -> str:
67
- session = app.environments[env].session
68
- ssm_client = session.client("ssm")
69
- addon_type = None
70
-
71
- try:
72
- addon_config = json.loads(
73
- ssm_client.get_parameter(
74
- Name=f"/copilot/applications/{app.name}/environments/{env}/addons"
75
- )["Parameter"]["Value"]
76
- )
77
- except ssm_client.exceptions.ParameterNotFound:
78
- raise ParameterNotFoundConduitError
79
-
80
- if addon_name not in addon_config.keys():
81
- raise AddonNotFoundConduitError
82
-
83
- for name, config in addon_config.items():
84
- if name == addon_name:
85
- addon_type = config["type"]
86
-
87
- if not addon_type or addon_type not in CONDUIT_ADDON_TYPES:
88
- raise InvalidAddonTypeConduitError(addon_type)
89
-
90
- if "postgres" in addon_type:
91
- addon_type = "postgres"
92
-
93
- return addon_type
94
-
95
-
96
- def get_parameter_name(
97
- app: Application, env: str, addon_type: str, addon_name: str, access: str
98
- ) -> str:
99
- if addon_type == "postgres":
100
- return f"/copilot/{app.name}/{env}/conduits/{normalise_secret_name(addon_name)}_{access.upper()}"
101
- elif addon_type == "redis" or addon_type == "opensearch":
102
- return f"/copilot/{app.name}/{env}/conduits/{normalise_secret_name(addon_name)}_ENDPOINT"
103
- else:
104
- return f"/copilot/{app.name}/{env}/conduits/{normalise_secret_name(addon_name)}"
105
-
106
-
107
- def get_or_create_task_name(
108
- app: Application, env: str, addon_name: str, parameter_name: str
109
- ) -> str:
110
- ssm = app.environments[env].session.client("ssm")
111
-
112
- try:
113
- return ssm.get_parameter(Name=parameter_name)["Parameter"]["Value"]
114
- except ssm.exceptions.ParameterNotFound:
115
- random_id = "".join(random.choices(string.ascii_lowercase + string.digits, k=12))
116
- return f"conduit-{app.name}-{env}-{addon_name}-{random_id}"
117
-
118
-
119
- def get_cluster_arn(app: Application, env: str) -> str:
120
- ecs_client = app.environments[env].session.client("ecs")
121
-
122
- for cluster_arn in ecs_client.list_clusters()["clusterArns"]:
123
- tags_response = ecs_client.list_tags_for_resource(resourceArn=cluster_arn)
124
- tags = tags_response["tags"]
125
-
126
- app_key_found = False
127
- env_key_found = False
128
- cluster_key_found = False
129
-
130
- for tag in tags:
131
- if tag["key"] == "copilot-application" and tag["value"] == app.name:
132
- app_key_found = True
133
- if tag["key"] == "copilot-environment" and tag["value"] == env:
134
- env_key_found = True
135
- if tag["key"] == "aws:cloudformation:logical-id" and tag["value"] == "Cluster":
136
- cluster_key_found = True
137
-
138
- if app_key_found and env_key_found and cluster_key_found:
139
- return cluster_arn
140
-
141
- raise NoClusterConduitError
142
-
143
-
144
- def get_connection_secret_arn(app: Application, env: str, secret_name: str) -> str:
145
- secrets_manager = app.environments[env].session.client("secretsmanager")
146
- ssm = app.environments[env].session.client("ssm")
147
-
148
- try:
149
- return ssm.get_parameter(Name=secret_name, WithDecryption=False)["Parameter"]["ARN"]
150
- except ssm.exceptions.ParameterNotFound:
151
- pass
152
-
153
- try:
154
- return secrets_manager.describe_secret(SecretId=secret_name)["ARN"]
155
- except secrets_manager.exceptions.ResourceNotFoundException:
156
- pass
157
-
158
- raise SecretNotFoundConduitError(secret_name)
159
-
160
-
161
- def create_postgres_admin_task(
162
- app: Application, env: str, secret_name: str, task_name: str, addon_type: str, addon_name: str
163
- ):
164
- session = app.environments[env].session
165
- read_only_secret_name = secret_name + "_READ_ONLY_USER"
166
- master_secret_name = (
167
- f"/copilot/{app.name}/{env}/secrets/{normalise_secret_name(addon_name)}_RDS_MASTER_ARN"
168
- )
169
- master_secret_arn = session.client("ssm").get_parameter(
170
- Name=master_secret_name, WithDecryption=True
171
- )["Parameter"]["Value"]
172
- connection_string = json.dumps(
173
- get_postgres_connection_data_updated_with_master_secret(
174
- session, read_only_secret_name, master_secret_arn
175
- )
176
- )
177
-
178
- subprocess.call(
179
- f"copilot task run --app {app.name} --env {env} "
180
- f"--task-group-name {task_name} "
181
- f"--image {CONDUIT_DOCKER_IMAGE_LOCATION}:{addon_type} "
182
- f"--env-vars CONNECTION_SECRET='{connection_string}' "
183
- "--platform-os linux "
184
- "--platform-arch arm64",
185
- shell=True,
186
- )
187
-
188
-
189
- def create_addon_client_task(
190
- app: Application,
191
- env: str,
192
- addon_type: str,
193
- addon_name: str,
194
- task_name: str,
195
- access: str,
196
- ):
197
- secret_name = f"/copilot/{app.name}/{env}/secrets/{normalise_secret_name(addon_name)}"
198
- session = app.environments[env].session
199
-
200
- if addon_type == "postgres":
201
- if access == "read":
202
- secret_name += "_READ_ONLY_USER"
203
- elif access == "write":
204
- secret_name += "_APPLICATION_USER"
205
- elif access == "admin":
206
- create_postgres_admin_task(app, env, secret_name, task_name, addon_type, addon_name)
207
- return
208
- elif addon_type == "redis" or addon_type == "opensearch":
209
- secret_name += "_ENDPOINT"
210
-
211
- role_name = f"{addon_name}-{app.name}-{env}-conduitEcsTask"
212
-
213
- try:
214
- session.client("iam").get_role(RoleName=role_name)
215
- execution_role = f"--execution-role {role_name} "
216
- except ClientError as ex:
217
- execution_role = ""
218
- # We cannot check for botocore.errorfactory.NoSuchEntityException as botocore generates that class on the fly as part of errorfactory.
219
- # factory. Checking the error code is the recommended way of handling these exceptions.
220
- if ex.response.get("Error", {}).get("Code", None) != "NoSuchEntity":
221
- abort_with_error(
222
- f"cannot obtain Role {role_name}: {ex.response.get('Error', {}).get('Message', '')}"
223
- )
224
-
225
- subprocess.call(
226
- f"copilot task run --app {app.name} --env {env} "
227
- f"--task-group-name {task_name} "
228
- f"{execution_role}"
229
- f"--image {CONDUIT_DOCKER_IMAGE_LOCATION}:{addon_type} "
230
- f"--secrets CONNECTION_SECRET={get_connection_secret_arn(app, env, secret_name)} "
231
- "--platform-os linux "
232
- "--platform-arch arm64",
233
- shell=True,
234
- )
235
-
236
-
237
- def addon_client_is_running(app: Application, env: str, cluster_arn: str, task_name: str) -> bool:
238
- ecs_client = app.environments[env].session.client("ecs")
239
-
240
- tasks = ecs_client.list_tasks(
241
- cluster=cluster_arn,
242
- desiredStatus="RUNNING",
243
- family=f"copilot-{task_name}",
244
- )
245
-
246
- if not tasks["taskArns"]:
247
- return False
248
-
249
- described_tasks = ecs_client.describe_tasks(cluster=cluster_arn, tasks=tasks["taskArns"])
250
-
251
- # The ExecuteCommandAgent often takes longer to start running than the task and without the
252
- # agent it's not possible to exec into a task.
253
- for task in described_tasks["tasks"]:
254
- for container in task["containers"]:
255
- for agent in container["managedAgents"]:
256
- if agent["name"] == "ExecuteCommandAgent" and agent["lastStatus"] == "RUNNING":
257
- return True
258
-
259
- return False
260
-
261
-
262
- def connect_to_addon_client_task(app: Application, env: str, cluster_arn: str, task_name: str):
263
- tries = 0
264
- running = False
265
-
266
- while tries < 15 and not running:
267
- tries += 1
268
-
269
- if addon_client_is_running(app, env, cluster_arn, task_name):
270
- running = True
271
- subprocess.call(
272
- "copilot task exec "
273
- f"--app {app.name} --env {env} "
274
- f"--name {task_name} "
275
- f"--command bash",
276
- shell=True,
277
- )
278
-
279
- time.sleep(1)
280
-
281
- if not running:
282
- raise CreateTaskTimeoutConduitError
283
-
284
-
285
- def add_stack_delete_policy_to_task_role(app: Application, env: str, task_name: str):
286
- session = app.environments[env].session
287
- cloudformation_client = session.client("cloudformation")
288
- iam_client = session.client("iam")
289
-
290
- conduit_stack_name = f"task-{task_name}"
291
- conduit_stack_resources = cloudformation_client.list_stack_resources(
292
- StackName=conduit_stack_name
293
- )["StackResourceSummaries"]
294
-
295
- for resource in conduit_stack_resources:
296
- if resource["LogicalResourceId"] == "DefaultTaskRole":
297
- task_role_name = resource["PhysicalResourceId"]
298
- iam_client.put_role_policy(
299
- RoleName=task_role_name,
300
- PolicyName="DeleteCloudFormationStack",
301
- PolicyDocument=json.dumps(
302
- {
303
- "Version": "2012-10-17",
304
- "Statement": [
305
- {
306
- "Action": ["cloudformation:DeleteStack"],
307
- "Effect": "Allow",
308
- "Resource": f"arn:aws:cloudformation:*:*:stack/{conduit_stack_name}/*",
309
- },
310
- ],
311
- },
312
- ),
313
- )
314
-
315
-
316
- def update_conduit_stack_resources(
317
- app: Application,
318
- env: str,
319
- addon_type: str,
320
- addon_name: str,
321
- task_name: str,
322
- parameter_name: str,
323
- access: str,
324
- ):
325
- session = app.environments[env].session
326
- cloudformation_client = session.client("cloudformation")
327
-
328
- conduit_stack_name = f"task-{task_name}"
329
- template = cloudformation_client.get_template(StackName=conduit_stack_name)
330
- template_yml = load_yaml(template["TemplateBody"])
331
- template_yml["Resources"]["LogGroup"]["DeletionPolicy"] = "Retain"
332
- template_yml["Resources"]["TaskNameParameter"] = load_yaml(
333
- f"""
334
- Type: AWS::SSM::Parameter
335
- Properties:
336
- Name: {parameter_name}
337
- Type: String
338
- Value: {task_name}
339
- """
340
- )
341
-
342
- iam_client = session.client("iam")
343
- log_filter_role_arn = iam_client.get_role(RoleName="CWLtoSubscriptionFilterRole")["Role"]["Arn"]
344
-
345
- ssm_client = session.client("ssm")
346
- destination_log_group_arns = json.loads(
347
- ssm_client.get_parameter(Name="/copilot/tools/central_log_groups")["Parameter"]["Value"]
348
- )
349
-
350
- destination_arn = destination_log_group_arns["dev"]
351
- if env.lower() in ("prod", "production"):
352
- destination_arn = destination_log_group_arns["prod"]
353
-
354
- template_yml["Resources"]["SubscriptionFilter"] = load_yaml(
355
- f"""
356
- Type: AWS::Logs::SubscriptionFilter
357
- DeletionPolicy: Retain
358
- Properties:
359
- RoleArn: {log_filter_role_arn}
360
- LogGroupName: /copilot/{task_name}
361
- FilterName: /copilot/conduit/{app.name}/{env}/{addon_type}/{addon_name}/{task_name.rsplit("-", 1)[1]}/{access}
362
- FilterPattern: ''
363
- DestinationArn: {destination_arn}
364
- """
365
- )
366
-
367
- params = []
368
- if "Parameters" in template_yml:
369
- for param in template_yml["Parameters"]:
370
- params.append({"ParameterKey": param, "UsePreviousValue": True})
371
-
372
- cloudformation_client.update_stack(
373
- StackName=conduit_stack_name,
374
- TemplateBody=dump_yaml(template_yml),
375
- Parameters=params,
376
- Capabilities=["CAPABILITY_IAM"],
377
- )
378
-
379
-
380
- def start_conduit(
381
- application: Application,
382
- env: str,
383
- addon_type: str,
384
- addon_name: str,
385
- access: str = "read",
386
- ):
387
- cluster_arn = get_cluster_arn(application, env)
388
- parameter_name = get_parameter_name(application, env, addon_type, addon_name, access)
389
- task_name = get_or_create_task_name(application, env, addon_name, parameter_name)
390
-
391
- if not addon_client_is_running(application, env, cluster_arn, task_name):
392
- create_addon_client_task(application, env, addon_type, addon_name, task_name, access)
393
- add_stack_delete_policy_to_task_role(application, env, task_name)
394
- update_conduit_stack_resources(
395
- application, env, addon_type, addon_name, task_name, parameter_name, access
396
- )
397
-
398
- connect_to_addon_client_task(application, env, cluster_arn, task_name)
399
-
400
-
401
21
  @click.command(cls=ClickDocOptCommand)
402
22
  @click.argument("addon_name", type=str, required=True)
403
- @click.option("--app", help="AWS application name", required=True)
404
- @click.option("--env", help="AWS environment name", required=True)
23
+ @click.option("--app", help="Application name", required=True)
24
+ @click.option("--env", help="Environment name", required=True)
405
25
  @click.option(
406
26
  "--access",
407
27
  default="read",
408
28
  type=click.Choice(CONDUIT_ACCESS_OPTIONS),
409
- help="Allow write or admin access to database addons",
29
+ help="Allow read, write or admin access to the database addons.",
410
30
  )
411
31
  def conduit(addon_name: str, app: str, env: str, access: str):
412
- """Create a conduit connection to an addon."""
32
+ """Opens a shell for a given addon_name create a conduit connection to
33
+ interact with postgres, opensearch or redis."""
413
34
  check_platform_helper_version_needs_update()
414
35
  application = load_application(app)
415
36
 
416
37
  try:
417
- addon_type = get_addon_type(application, env, addon_name)
418
- except ParameterNotFoundConduitError:
38
+ Conduit(application).start(env, addon_name, access)
39
+ except NoClusterError:
40
+ # TODO: Set exception message in the exceptions and just output the message in the command code, should be able to catch all errors in one block
41
+ click.secho(f"""No ECS cluster found for "{app}" in "{env}" environment.""", fg="red")
42
+ exit(1)
43
+ except SecretNotFoundError as err:
419
44
  click.secho(
420
- f"""No parameter called "/copilot/applications/{app}/environments/{env}/addons". Try deploying the "{app}" "{env}" environment.""",
45
+ f"""No secret called "{err}" for "{app}" in "{env}" environment.""",
421
46
  fg="red",
422
47
  )
423
48
  exit(1)
424
- except AddonNotFoundConduitError:
49
+ except CreateTaskTimeoutError:
425
50
  click.secho(
426
- f"""Addon "{addon_name}" does not exist.""",
51
+ f"""Client ({addon_name}) ECS task has failed to start for "{app}" in "{env}" environment.""",
427
52
  fg="red",
428
53
  )
429
54
  exit(1)
430
- except InvalidAddonTypeConduitError as err:
55
+ except ParameterNotFoundError:
431
56
  click.secho(
432
- f"""Addon type "{err.addon_type}" is not supported, we support: {", ".join(CONDUIT_ADDON_TYPES)}.""",
57
+ f"""No parameter called "/copilot/applications/{app}/environments/{env}/addons". Try deploying the "{app}" "{env}" environment.""",
433
58
  fg="red",
434
59
  )
435
60
  exit(1)
436
-
437
- try:
438
- start_conduit(application, env, addon_type, addon_name, access)
439
- except NoClusterConduitError:
440
- click.secho(f"""No ECS cluster found for "{app}" in "{env}" environment.""", fg="red")
61
+ except AddonNotFoundError:
62
+ click.secho(
63
+ f"""Addon "{addon_name}" does not exist.""",
64
+ fg="red",
65
+ )
441
66
  exit(1)
442
- except SecretNotFoundConduitError as err:
67
+ except InvalidAddonTypeError as err:
443
68
  click.secho(
444
- f"""No secret called "{err}" for "{app}" in "{env}" environment.""",
69
+ f"""Addon type "{err.addon_type}" is not supported, we support: {", ".join(CONDUIT_ADDON_TYPES)}.""",
445
70
  fg="red",
446
71
  )
447
72
  exit(1)
448
- except CreateTaskTimeoutConduitError:
73
+ except AddonTypeMissingFromConfigError:
449
74
  click.secho(
450
- f"""Client ({addon_name}) ECS task has failed to start for "{app}" in "{env}" environment.""",
75
+ f"""The configuration for the addon {addon_name}, is missconfigured and missing the addon type.""",
451
76
  fg="red",
452
77
  )
453
78
  exit(1)
@@ -102,7 +102,7 @@ def list(app, env):
102
102
  params = dict(Path=path, Recursive=False, WithDecryption=True, MaxResults=10)
103
103
  secrets = []
104
104
 
105
- # TODO: refactor shared code with get_ssm_secret_names
105
+ # TODO: refactor shared code with get_ssm_secret_names - Check if this is still valid
106
106
  while True:
107
107
  response = client.get_parameters_by_path(**params)
108
108
 
@@ -1,5 +1,16 @@
1
1
  PLATFORM_CONFIG_FILE = "platform-config.yml"
2
2
  PLATFORM_HELPER_VERSION_FILE = ".platform-helper-version"
3
+ DEFAULT_TERRAFORM_PLATFORM_MODULES_VERSION = "5"
4
+ PLATFORM_HELPER_CACHE_FILE = ".platform-helper-config-cache.yml"
5
+
6
+ # Keys
3
7
  CODEBASE_PIPELINES_KEY = "codebase_pipelines"
4
8
  ENVIRONMENTS_KEY = "environments"
5
- DEFAULT_TERRAFORM_PLATFORM_MODULES_VERSION = "5"
9
+
10
+ # Conduit
11
+ CONDUIT_ADDON_TYPES = [
12
+ "opensearch",
13
+ "postgres",
14
+ "redis",
15
+ ]
16
+ CONDUIT_DOCKER_IMAGE_LOCATION = "public.ecr.aws/uktrade/tunnel"