dbt-platform-helper 13.1.0__py3-none-any.whl → 15.16.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.
Files changed (95) hide show
  1. dbt_platform_helper/COMMANDS.md +107 -27
  2. dbt_platform_helper/commands/application.py +5 -6
  3. dbt_platform_helper/commands/codebase.py +31 -10
  4. dbt_platform_helper/commands/conduit.py +3 -5
  5. dbt_platform_helper/commands/config.py +20 -311
  6. dbt_platform_helper/commands/copilot.py +18 -391
  7. dbt_platform_helper/commands/database.py +17 -9
  8. dbt_platform_helper/commands/environment.py +20 -14
  9. dbt_platform_helper/commands/generate.py +0 -3
  10. dbt_platform_helper/commands/internal.py +140 -0
  11. dbt_platform_helper/commands/notify.py +58 -78
  12. dbt_platform_helper/commands/pipeline.py +23 -19
  13. dbt_platform_helper/commands/secrets.py +39 -93
  14. dbt_platform_helper/commands/version.py +7 -12
  15. dbt_platform_helper/constants.py +52 -7
  16. dbt_platform_helper/domain/codebase.py +89 -39
  17. dbt_platform_helper/domain/conduit.py +335 -76
  18. dbt_platform_helper/domain/config.py +381 -0
  19. dbt_platform_helper/domain/copilot.py +398 -0
  20. dbt_platform_helper/domain/copilot_environment.py +8 -8
  21. dbt_platform_helper/domain/database_copy.py +2 -2
  22. dbt_platform_helper/domain/maintenance_page.py +254 -430
  23. dbt_platform_helper/domain/notify.py +64 -0
  24. dbt_platform_helper/domain/pipelines.py +43 -35
  25. dbt_platform_helper/domain/plans.py +41 -0
  26. dbt_platform_helper/domain/secrets.py +279 -0
  27. dbt_platform_helper/domain/service.py +570 -0
  28. dbt_platform_helper/domain/terraform_environment.py +14 -13
  29. dbt_platform_helper/domain/update_alb_rules.py +412 -0
  30. dbt_platform_helper/domain/versioning.py +249 -0
  31. dbt_platform_helper/{providers → entities}/platform_config_schema.py +75 -82
  32. dbt_platform_helper/entities/semantic_version.py +83 -0
  33. dbt_platform_helper/entities/service.py +339 -0
  34. dbt_platform_helper/platform_exception.py +4 -0
  35. dbt_platform_helper/providers/autoscaling.py +24 -0
  36. dbt_platform_helper/providers/aws/__init__.py +0 -0
  37. dbt_platform_helper/providers/aws/exceptions.py +70 -0
  38. dbt_platform_helper/providers/aws/interfaces.py +13 -0
  39. dbt_platform_helper/providers/aws/opensearch.py +23 -0
  40. dbt_platform_helper/providers/aws/redis.py +21 -0
  41. dbt_platform_helper/providers/aws/sso_auth.py +75 -0
  42. dbt_platform_helper/providers/cache.py +40 -4
  43. dbt_platform_helper/providers/cloudformation.py +1 -1
  44. dbt_platform_helper/providers/config.py +137 -19
  45. dbt_platform_helper/providers/config_validator.py +112 -51
  46. dbt_platform_helper/providers/copilot.py +24 -16
  47. dbt_platform_helper/providers/ecr.py +89 -7
  48. dbt_platform_helper/providers/ecs.py +228 -36
  49. dbt_platform_helper/providers/environment_variable.py +24 -0
  50. dbt_platform_helper/providers/files.py +1 -1
  51. dbt_platform_helper/providers/io.py +36 -4
  52. dbt_platform_helper/providers/kms.py +22 -0
  53. dbt_platform_helper/providers/load_balancers.py +402 -42
  54. dbt_platform_helper/providers/logs.py +72 -0
  55. dbt_platform_helper/providers/parameter_store.py +134 -0
  56. dbt_platform_helper/providers/s3.py +21 -0
  57. dbt_platform_helper/providers/schema_migrations/__init__.py +0 -0
  58. dbt_platform_helper/providers/schema_migrations/schema_v0_to_v1_migration.py +43 -0
  59. dbt_platform_helper/providers/schema_migrator.py +77 -0
  60. dbt_platform_helper/providers/secrets.py +5 -5
  61. dbt_platform_helper/providers/slack_channel_notifier.py +62 -0
  62. dbt_platform_helper/providers/terraform_manifest.py +121 -19
  63. dbt_platform_helper/providers/version.py +106 -23
  64. dbt_platform_helper/providers/version_status.py +27 -0
  65. dbt_platform_helper/providers/vpc.py +36 -5
  66. dbt_platform_helper/providers/yaml_file.py +58 -2
  67. dbt_platform_helper/templates/environment-pipelines/main.tf +4 -3
  68. dbt_platform_helper/templates/svc/overrides/cfn.patches.yml +5 -0
  69. dbt_platform_helper/utilities/decorators.py +103 -0
  70. dbt_platform_helper/utils/application.py +119 -22
  71. dbt_platform_helper/utils/aws.py +39 -150
  72. dbt_platform_helper/utils/deep_merge.py +10 -0
  73. dbt_platform_helper/utils/git.py +1 -14
  74. dbt_platform_helper/utils/validation.py +1 -1
  75. {dbt_platform_helper-13.1.0.dist-info → dbt_platform_helper-15.16.0.dist-info}/METADATA +11 -20
  76. dbt_platform_helper-15.16.0.dist-info/RECORD +118 -0
  77. {dbt_platform_helper-13.1.0.dist-info → dbt_platform_helper-15.16.0.dist-info}/WHEEL +1 -1
  78. platform_helper.py +3 -1
  79. terraform/elasticache-redis/plans.yml +85 -0
  80. terraform/opensearch/plans.yml +71 -0
  81. terraform/postgres/plans.yml +128 -0
  82. dbt_platform_helper/addon-plans.yml +0 -224
  83. dbt_platform_helper/providers/aws.py +0 -37
  84. dbt_platform_helper/providers/opensearch.py +0 -36
  85. dbt_platform_helper/providers/redis.py +0 -34
  86. dbt_platform_helper/providers/semantic_version.py +0 -126
  87. dbt_platform_helper/templates/svc/manifest-backend.yml +0 -69
  88. dbt_platform_helper/templates/svc/manifest-public.yml +0 -109
  89. dbt_platform_helper/utils/cloudfoundry.py +0 -14
  90. dbt_platform_helper/utils/files.py +0 -53
  91. dbt_platform_helper/utils/manifests.py +0 -18
  92. dbt_platform_helper/utils/versioning.py +0 -238
  93. dbt_platform_helper-13.1.0.dist-info/RECORD +0 -96
  94. {dbt_platform_helper-13.1.0.dist-info → dbt_platform_helper-15.16.0.dist-info}/entry_points.txt +0 -0
  95. {dbt_platform_helper-13.1.0.dist-info → dbt_platform_helper-15.16.0.dist-info/licenses}/LICENSE +0 -0
@@ -1,9 +1,32 @@
1
1
  import random
2
2
  import string
3
- import time
4
- from typing import List
3
+ import subprocess
4
+ from typing import Any
5
+ from typing import Optional
6
+
7
+ from botocore.exceptions import ClientError
5
8
 
6
9
  from dbt_platform_helper.platform_exception import PlatformException
10
+ from dbt_platform_helper.platform_exception import ValidationException
11
+ from dbt_platform_helper.providers.vpc import Vpc
12
+ from dbt_platform_helper.utilities.decorators import retry
13
+ from dbt_platform_helper.utilities.decorators import wait_until
14
+
15
+
16
+ class ECSException(PlatformException):
17
+ pass
18
+
19
+
20
+ class ECSAgentNotRunningException(ECSException):
21
+ def __init__(self):
22
+ super().__init__("""ECS exec agent never reached "RUNNING" status""")
23
+
24
+
25
+ class NoClusterException(ECSException):
26
+ def __init__(self, application_name: str, environment: str):
27
+ super().__init__(
28
+ f"""No ECS cluster found for "{application_name}" in "{environment}" environment."""
29
+ )
7
30
 
8
31
 
9
32
  class ECS:
@@ -13,7 +36,49 @@ class ECS:
13
36
  self.application_name = application_name
14
37
  self.env = env
15
38
 
16
- def get_cluster_arn(self) -> str:
39
+ def start_ecs_task(
40
+ self,
41
+ cluster_name: str,
42
+ container_name: str,
43
+ task_def_arn: str,
44
+ vpc_config: Vpc,
45
+ env_vars: list[dict] = None,
46
+ ):
47
+ container_override = {"name": container_name}
48
+ if env_vars:
49
+ container_override["environment"] = env_vars
50
+
51
+ response = self.ecs_client.run_task(
52
+ taskDefinition=task_def_arn,
53
+ cluster=cluster_name,
54
+ capacityProviderStrategy=[
55
+ {"capacityProvider": "FARGATE", "weight": 1, "base": 0},
56
+ ],
57
+ enableExecuteCommand=True,
58
+ networkConfiguration={
59
+ "awsvpcConfiguration": {
60
+ "subnets": vpc_config.public_subnets,
61
+ "securityGroups": vpc_config.security_groups,
62
+ "assignPublicIp": "ENABLED",
63
+ }
64
+ },
65
+ overrides={"containerOverrides": [container_override]},
66
+ )
67
+
68
+ return response.get("tasks", [{}])[0].get("taskArn")
69
+
70
+ def get_cluster_arn_by_name(self, cluster_name: str) -> str:
71
+ clusters = self.ecs_client.describe_clusters(
72
+ clusters=[
73
+ cluster_name,
74
+ ],
75
+ )["clusters"]
76
+ if len(clusters) == 1 and "clusterArn" in clusters[0]:
77
+ return clusters[0]["clusterArn"]
78
+
79
+ raise NoClusterException(self.application_name, self.env)
80
+
81
+ def get_cluster_arn_by_copilot_tag(self) -> str:
17
82
  """Returns the ARN of the ECS cluster for the given application and
18
83
  environment."""
19
84
  for cluster_arn in self.ecs_client.list_clusters()["clusterArns"]:
@@ -45,58 +110,185 @@ class ECS:
45
110
  random_id = "".join(random.choices(string.ascii_lowercase + string.digits, k=12))
46
111
  return f"conduit-{self.application_name}-{self.env}-{addon_name}-{random_id}"
47
112
 
48
- def get_ecs_task_arns(self, cluster_arn: str, task_name: str):
49
- """Gets the ECS task ARNs for a given task name and cluster ARN."""
50
- tasks = self.ecs_client.list_tasks(
51
- cluster=cluster_arn,
52
- desiredStatus="RUNNING",
53
- family=f"copilot-{task_name}",
54
- )
113
+ def get_ecs_task_arns(
114
+ self,
115
+ cluster: str,
116
+ max_results: int = 100,
117
+ desired_status: str = "RUNNING",
118
+ service_name: Optional[str] = None,
119
+ started_by: Optional[str] = None,
120
+ task_def_family: Optional[str] = None,
121
+ ) -> list[str]:
122
+ """Returns the ECS task ARNs based on the parameters provided."""
123
+
124
+ params = {
125
+ "cluster": cluster,
126
+ "maxResults": max_results,
127
+ "desiredStatus": desired_status,
128
+ }
129
+
130
+ if service_name:
131
+ params["serviceName"] = service_name
132
+ if started_by:
133
+ params["startedBy"] = started_by
134
+ if task_def_family:
135
+ params["family"] = task_def_family
136
+
137
+ tasks = self.ecs_client.list_tasks(**params)
55
138
 
56
139
  if not tasks["taskArns"]:
57
140
  return []
58
141
 
59
142
  return tasks["taskArns"]
60
143
 
61
- def ecs_exec_is_available(self, cluster_arn: str, task_arns: List[str]):
144
+ @retry()
145
+ def exec_task(self, cluster_arn: str, task_arn: str, subprocess_call=subprocess.call):
146
+ result = subprocess_call(
147
+ f"aws ecs execute-command --cluster {cluster_arn} "
148
+ f"--task {task_arn} "
149
+ f"--interactive --command bash ",
150
+ shell=True,
151
+ )
152
+ if result != 0:
153
+ raise PlatformException("Failed to exec into ECS task.")
154
+ return result
155
+
156
+ @wait_until(
157
+ max_attempts=25,
158
+ exceptions_to_catch=(ECSException,),
159
+ message_on_false="ECS Agent Not running",
160
+ )
161
+ def ecs_exec_is_available(self, cluster_arn: str, task_arns: list[str]) -> bool:
62
162
  """
63
163
  Checks if the ExecuteCommandAgent is running on the specified ECS task.
64
164
 
65
165
  Waits for up to 25 attempts, then raises ECSAgentNotRunning if still not
66
166
  running.
67
167
  """
68
- current_attempts = 0
69
- execute_command_agent_status = ""
168
+ if not task_arns:
169
+ raise ValidationException("No task ARNs provided")
170
+ task_details = self.ecs_client.describe_tasks(cluster=cluster_arn, tasks=task_arns)
70
171
 
71
- while execute_command_agent_status != "RUNNING" and current_attempts < 25:
72
- current_attempts += 1
172
+ if not task_details["tasks"]:
173
+ raise ECSException("No ECS tasks returned.")
174
+ container_details = task_details["tasks"][0]["containers"][0]
175
+ if container_details.get("managedAgents", None):
176
+ managed_agents = container_details["managedAgents"]
177
+ else:
178
+ raise ECSException("No managed agent on ecs task.")
73
179
 
74
- task_details = self.ecs_client.describe_tasks(cluster=cluster_arn, tasks=task_arns)
180
+ execute_command_agent = [
181
+ agent for agent in managed_agents if agent["name"] == "ExecuteCommandAgent"
182
+ ]
183
+ if not execute_command_agent:
184
+ raise ECSException("No ExecuteCommandAgent on ecs task.")
185
+ return execute_command_agent[0]["lastStatus"] == "RUNNING"
75
186
 
76
- managed_agents = task_details["tasks"][0]["containers"][0]["managedAgents"]
77
- execute_command_agent_status = [
78
- agent["lastStatus"]
79
- for agent in managed_agents
80
- if agent["name"] == "ExecuteCommandAgent"
81
- ][0]
82
- if execute_command_agent_status != "RUNNING":
83
- time.sleep(1)
187
+ @wait_until(
188
+ max_attempts=20,
189
+ message_on_false="ECS task did not register in time",
190
+ )
191
+ def wait_for_task_to_register(self, cluster_arn: str, task_family: str) -> list[str]:
192
+ task_arns = self.get_ecs_task_arns(cluster=cluster_arn, task_def_family=task_family)
193
+ if task_arns:
194
+ return task_arns
195
+ return False
84
196
 
85
- if execute_command_agent_status != "RUNNING":
86
- raise ECSAgentNotRunningException
197
+ def get_service_deployment_state(
198
+ self, cluster_name: str, service_name: str, start_time: float
199
+ ) -> tuple[Optional[str], Optional[str]]:
200
+ """
201
+ Returns status & statusReason for the deployment of an ECS service.
87
202
 
203
+ statusReason can be:
204
+ PENDING | SUCCESSFUL | STOPPED | STOP_REQUESTED |
205
+ IN_PROGRESS | ROLLBACK_REQUESTED | ROLLBACK_IN_PROGRESS |
206
+ ROLLBACK_SUCCESSFUL | ROLLBACK_FAILED
207
+ """
208
+ resp = self.ecs_client.list_service_deployments(
209
+ cluster=cluster_name, service=service_name, createdAt={"after": start_time}
210
+ )
211
+ deployments = resp.get("serviceDeployments", [])
88
212
 
89
- class ECSException(PlatformException):
90
- pass
213
+ if not deployments:
214
+ return None, f"No deployments found for '{service_name}'"
91
215
 
216
+ return deployments[0].get("status"), deployments[0].get("statusReason")
92
217
 
93
- class ECSAgentNotRunningException(ECSException):
94
- def __init__(self):
95
- super().__init__("""ECS exec agent never reached "RUNNING" status""")
218
+ def get_container_names_from_ecs_tasks(
219
+ self, cluster_name: str, task_ids: list[str]
220
+ ) -> list[str]:
221
+ """Retrieve container names from each ECS task provided."""
96
222
 
223
+ response = self.ecs_client.describe_tasks(cluster=cluster_name, tasks=task_ids)
97
224
 
98
- class NoClusterException(ECSException):
99
- def __init__(self, application_name: str, environment: str):
100
- super().__init__(
101
- f"""No ECS cluster found for "{application_name}" in "{environment}" environment."""
102
- )
225
+ names = []
226
+ for task in response.get("tasks", []):
227
+ for container in task.get("containers", []):
228
+ if container["name"] not in names:
229
+ names.append(container["name"])
230
+ return names
231
+
232
+ def register_task_definition(
233
+ self,
234
+ service: str,
235
+ task_definition: dict,
236
+ image_tag: Optional[str] = None,
237
+ ) -> str:
238
+ """Register a new task definition revision using provided model and
239
+ containerDefinitions."""
240
+
241
+ for container in task_definition["containerDefinitions"]:
242
+ if container["name"] == service:
243
+ container["image"] = f"{container['image']}:{image_tag}"
244
+ break
245
+
246
+ try:
247
+ task_definition_response = self.ecs_client.register_task_definition(**task_definition)
248
+ return task_definition_response["taskDefinition"]["taskDefinitionArn"]
249
+ except ClientError as err:
250
+ raise PlatformException(f"Error registering task definition: {err}")
251
+
252
+ def update_service(
253
+ self,
254
+ service: str,
255
+ task_def_arn: str,
256
+ environment: str,
257
+ application: str,
258
+ desired_count: int,
259
+ ) -> dict[str, Any]:
260
+ """Update an ECS service and return the response."""
261
+
262
+ try:
263
+ service_response = self.ecs_client.update_service(
264
+ cluster=f"{application}-{environment}-cluster",
265
+ service=f"{application}-{environment}-{service}",
266
+ taskDefinition=task_def_arn,
267
+ desiredCount=desired_count,
268
+ )
269
+ return service_response["service"]
270
+ except ClientError as err:
271
+ raise PlatformException(f"Error updating ECS service: {err}")
272
+
273
+ def describe_service(self, service: str, environment: str, application: str) -> dict[str, Any]:
274
+ """Return information about an ECS service."""
275
+
276
+ try:
277
+ service_response = self.ecs_client.describe_services(
278
+ cluster=f"{application}-{environment}-cluster",
279
+ services=[
280
+ f"{application}-{environment}-{service}",
281
+ ],
282
+ )
283
+ return service_response["services"][0]
284
+ except ClientError as err:
285
+ raise PlatformException(f"Error retrieving ECS service: {err}")
286
+
287
+ def describe_tasks(self, cluster_name: str, task_ids: list[str]) -> list[dict[str, Any]]:
288
+ """Return information about ECS tasks."""
289
+
290
+ try:
291
+ response = self.ecs_client.describe_tasks(cluster=cluster_name, tasks=task_ids)
292
+ return response["tasks"]
293
+ except ClientError as err:
294
+ raise PlatformException(f"Error retrieving ECS tasks: {err}")
@@ -0,0 +1,24 @@
1
+ import os
2
+ from typing import Optional
3
+
4
+ from dbt_platform_helper.platform_exception import PlatformException
5
+
6
+
7
+ class EnvironmentVariableProvider:
8
+
9
+ @staticmethod
10
+ def get(env_var: str, default=None) -> Optional[str]:
11
+ """Returns the stripped value or None if not set or empty."""
12
+ value = os.environ.get(env_var)
13
+ if value and value.strip():
14
+ return value.strip()
15
+ return default
16
+
17
+ @staticmethod
18
+ def get_required(env_var: str) -> str:
19
+ """Returns the stripped value or raises a PlatformException if not set
20
+ or empty."""
21
+ value = os.environ.get(env_var)
22
+ if not value or not value.strip():
23
+ raise PlatformException(f"Environment variable '{env_var}' is not set or is empty")
24
+ return value.strip()
@@ -17,7 +17,7 @@ class FileProvider:
17
17
  file_path.write_text(contents)
18
18
 
19
19
  action = "overwritten" if file_exists and overwrite else "created"
20
- return f"File {file_name} {action}"
20
+ return f"File {file_path} {action}"
21
21
 
22
22
  @staticmethod
23
23
  def delete_file(base_path: str, file_name: str):
@@ -1,20 +1,35 @@
1
1
  import click
2
2
 
3
3
  from dbt_platform_helper.platform_exception import PlatformException
4
+ from dbt_platform_helper.providers.environment_variable import (
5
+ EnvironmentVariableProvider,
6
+ )
4
7
 
5
8
 
6
9
  class ClickIOProvider:
10
+ def __init__(self, env_var_provider=EnvironmentVariableProvider()):
11
+ self.env_var_provider = env_var_provider
12
+ self.debug_flag = self.env_var_provider.get("DEBUG")
13
+
7
14
  def warn(self, message: str):
8
15
  click.secho(message, fg="magenta")
9
16
 
17
+ def debug(self, message: str):
18
+ if self.debug_flag and self.debug_flag.strip().upper() == "TRUE":
19
+ click.secho(message, fg="green")
20
+
10
21
  def error(self, message: str):
11
22
  click.secho(f"Error: {message}", fg="red")
12
23
 
13
- def info(self, message: str):
14
- click.secho(message)
24
+ def info(self, message: str, **kwargs):
25
+ click.secho(message, **kwargs)
15
26
 
16
- def input(self, message: str) -> str:
17
- return click.prompt(message)
27
+ def input(
28
+ self, message: str, hide_input=False, confirmation_prompt=False, input_type=str
29
+ ) -> str:
30
+ return click.prompt(
31
+ message, hide_input=hide_input, confirmation_prompt=confirmation_prompt, type=input_type
32
+ )
18
33
 
19
34
  def confirm(self, message: str) -> bool:
20
35
  try:
@@ -26,6 +41,23 @@ class ClickIOProvider:
26
41
  click.secho(f"Error: {message}", err=True, fg="red")
27
42
  exit(1)
28
43
 
44
+ def deploy_error(self, message: str):
45
+ click.secho(message, fg="red")
46
+
47
+ # TODO: DBTP-1979: messages will be a ValidationMessages class rather than a free-rein dictionary
48
+ def process_messages(self, messages: dict):
49
+ if not messages:
50
+ return
51
+
52
+ if messages.get("errors"):
53
+ self.error("\n".join(messages["errors"]))
54
+
55
+ if messages.get("warnings"):
56
+ self.warn("\n".join(messages["warnings"]))
57
+
58
+ if messages.get("info"):
59
+ self.info("\n".join(messages["info"]))
60
+
29
61
 
30
62
  class ClickIOProviderException(PlatformException):
31
63
  pass
@@ -0,0 +1,22 @@
1
+ import boto3
2
+
3
+
4
+ class KMSProvider:
5
+ """A provider class for interacting with the AWS KMS (Key Management
6
+ Service)."""
7
+
8
+ def __init__(self, kms_client: boto3.client):
9
+ self.kms_client = kms_client
10
+
11
+ def describe_key(self, alias_name: str) -> dict:
12
+ """
13
+ Retrieves metadata about a KMS key using its alias.
14
+
15
+ Args:
16
+ alias_name (str): The alias name of the KMS key.
17
+
18
+ Returns:
19
+ dict: A dictionary containing metadata about the specified KMS key.
20
+ """
21
+ # The kms client can take an alias name as the KeyId
22
+ return self.kms_client.describe_key(KeyId=alias_name)