dbt-platform-helper 15.3.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 (50) hide show
  1. dbt_platform_helper/COMMANDS.md +36 -11
  2. dbt_platform_helper/commands/application.py +2 -1
  3. dbt_platform_helper/commands/conduit.py +1 -1
  4. dbt_platform_helper/commands/environment.py +12 -1
  5. dbt_platform_helper/commands/generate.py +0 -2
  6. dbt_platform_helper/commands/internal.py +140 -0
  7. dbt_platform_helper/commands/pipeline.py +15 -3
  8. dbt_platform_helper/commands/secrets.py +37 -89
  9. dbt_platform_helper/commands/version.py +3 -2
  10. dbt_platform_helper/constants.py +38 -2
  11. dbt_platform_helper/domain/conduit.py +22 -9
  12. dbt_platform_helper/domain/config.py +30 -1
  13. dbt_platform_helper/domain/database_copy.py +1 -1
  14. dbt_platform_helper/domain/maintenance_page.py +27 -3
  15. dbt_platform_helper/domain/pipelines.py +36 -60
  16. dbt_platform_helper/domain/secrets.py +279 -0
  17. dbt_platform_helper/domain/service.py +570 -0
  18. dbt_platform_helper/domain/terraform_environment.py +7 -29
  19. dbt_platform_helper/domain/update_alb_rules.py +412 -0
  20. dbt_platform_helper/domain/versioning.py +124 -13
  21. dbt_platform_helper/entities/platform_config_schema.py +31 -11
  22. dbt_platform_helper/entities/semantic_version.py +2 -0
  23. dbt_platform_helper/entities/service.py +339 -0
  24. dbt_platform_helper/providers/autoscaling.py +24 -0
  25. dbt_platform_helper/providers/aws/exceptions.py +5 -0
  26. dbt_platform_helper/providers/aws/sso_auth.py +14 -0
  27. dbt_platform_helper/providers/config.py +17 -2
  28. dbt_platform_helper/providers/config_validator.py +87 -2
  29. dbt_platform_helper/providers/ecs.py +131 -11
  30. dbt_platform_helper/providers/environment_variable.py +2 -2
  31. dbt_platform_helper/providers/io.py +9 -2
  32. dbt_platform_helper/providers/load_balancers.py +122 -16
  33. dbt_platform_helper/providers/logs.py +72 -0
  34. dbt_platform_helper/providers/parameter_store.py +97 -10
  35. dbt_platform_helper/providers/s3.py +21 -0
  36. dbt_platform_helper/providers/terraform_manifest.py +97 -13
  37. dbt_platform_helper/providers/vpc.py +36 -5
  38. dbt_platform_helper/providers/yaml_file.py +35 -0
  39. dbt_platform_helper/templates/environment-pipelines/main.tf +3 -2
  40. dbt_platform_helper/templates/svc/overrides/cfn.patches.yml +5 -0
  41. dbt_platform_helper/utils/application.py +104 -21
  42. dbt_platform_helper/utils/aws.py +11 -10
  43. dbt_platform_helper/utils/deep_merge.py +10 -0
  44. dbt_platform_helper/utils/git.py +1 -1
  45. {dbt_platform_helper-15.3.0.dist-info → dbt_platform_helper-15.16.0.dist-info}/METADATA +8 -17
  46. {dbt_platform_helper-15.3.0.dist-info → dbt_platform_helper-15.16.0.dist-info}/RECORD +50 -41
  47. {dbt_platform_helper-15.3.0.dist-info → dbt_platform_helper-15.16.0.dist-info}/WHEEL +1 -1
  48. platform_helper.py +2 -0
  49. {dbt_platform_helper-15.3.0.dist-info → dbt_platform_helper-15.16.0.dist-info}/entry_points.txt +0 -0
  50. {dbt_platform_helper-15.3.0.dist-info → dbt_platform_helper-15.16.0.dist-info/licenses}/LICENSE +0 -0
@@ -1,7 +1,10 @@
1
1
  import random
2
2
  import string
3
3
  import subprocess
4
- from typing import List
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
7
10
  from dbt_platform_helper.platform_exception import ValidationException
@@ -39,7 +42,7 @@ class ECS:
39
42
  container_name: str,
40
43
  task_def_arn: str,
41
44
  vpc_config: Vpc,
42
- env_vars: List[dict] = None,
45
+ env_vars: list[dict] = None,
43
46
  ):
44
47
  container_override = {"name": container_name}
45
48
  if env_vars:
@@ -107,13 +110,31 @@ class ECS:
107
110
  random_id = "".join(random.choices(string.ascii_lowercase + string.digits, k=12))
108
111
  return f"conduit-{self.application_name}-{self.env}-{addon_name}-{random_id}"
109
112
 
110
- def get_ecs_task_arns(self, cluster_arn: str, task_def_family: str):
111
- """Gets the ECS task ARNs for a given task name and cluster ARN."""
112
- tasks = self.ecs_client.list_tasks(
113
- cluster=cluster_arn,
114
- desiredStatus="RUNNING",
115
- family=task_def_family,
116
- )
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)
117
138
 
118
139
  if not tasks["taskArns"]:
119
140
  return []
@@ -137,7 +158,7 @@ class ECS:
137
158
  exceptions_to_catch=(ECSException,),
138
159
  message_on_false="ECS Agent Not running",
139
160
  )
140
- def ecs_exec_is_available(self, cluster_arn: str, task_arns: List[str]) -> bool:
161
+ def ecs_exec_is_available(self, cluster_arn: str, task_arns: list[str]) -> bool:
141
162
  """
142
163
  Checks if the ExecuteCommandAgent is running on the specified ECS task.
143
164
 
@@ -168,7 +189,106 @@ class ECS:
168
189
  message_on_false="ECS task did not register in time",
169
190
  )
170
191
  def wait_for_task_to_register(self, cluster_arn: str, task_family: str) -> list[str]:
171
- task_arns = self.get_ecs_task_arns(cluster_arn, task_family)
192
+ task_arns = self.get_ecs_task_arns(cluster=cluster_arn, task_def_family=task_family)
172
193
  if task_arns:
173
194
  return task_arns
174
195
  return False
196
+
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.
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", [])
212
+
213
+ if not deployments:
214
+ return None, f"No deployments found for '{service_name}'"
215
+
216
+ return deployments[0].get("status"), deployments[0].get("statusReason")
217
+
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."""
222
+
223
+ response = self.ecs_client.describe_tasks(cluster=cluster_name, tasks=task_ids)
224
+
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}")
@@ -7,12 +7,12 @@ from dbt_platform_helper.platform_exception import PlatformException
7
7
  class EnvironmentVariableProvider:
8
8
 
9
9
  @staticmethod
10
- def get(env_var: str) -> Optional[str]:
10
+ def get(env_var: str, default=None) -> Optional[str]:
11
11
  """Returns the stripped value or None if not set or empty."""
12
12
  value = os.environ.get(env_var)
13
13
  if value and value.strip():
14
14
  return value.strip()
15
- return None
15
+ return default
16
16
 
17
17
  @staticmethod
18
18
  def get_required(env_var: str) -> str:
@@ -24,8 +24,12 @@ class ClickIOProvider:
24
24
  def info(self, message: str, **kwargs):
25
25
  click.secho(message, **kwargs)
26
26
 
27
- def input(self, message: str) -> str:
28
- 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
+ )
29
33
 
30
34
  def confirm(self, message: str) -> bool:
31
35
  try:
@@ -37,6 +41,9 @@ class ClickIOProvider:
37
41
  click.secho(f"Error: {message}", err=True, fg="red")
38
42
  exit(1)
39
43
 
44
+ def deploy_error(self, message: str):
45
+ click.secho(message, fg="red")
46
+
40
47
  # TODO: DBTP-1979: messages will be a ValidationMessages class rather than a free-rein dictionary
41
48
  def process_messages(self, messages: dict):
42
49
  if not messages:
@@ -1,7 +1,14 @@
1
+ import json
2
+ from typing import Dict
3
+ from typing import List
4
+
1
5
  from boto3 import Session
2
6
 
7
+ from dbt_platform_helper.constants import MANAGED_BY_PLATFORM_TERRAFORM
8
+ from dbt_platform_helper.constants import ROUTED_TO_PLATFORM_MODES
3
9
  from dbt_platform_helper.platform_exception import PlatformException
4
10
  from dbt_platform_helper.providers.io import ClickIOProvider
11
+ from dbt_platform_helper.providers.parameter_store import ParameterStore
5
12
  from dbt_platform_helper.utils.aws import get_aws_session_or_abort
6
13
 
7
14
 
@@ -12,12 +19,24 @@ def normalise_to_cidr(ip: str):
12
19
  return f"{ip}/{SINGLE_IPV4_CIDR_PREFIX_LENGTH}"
13
20
 
14
21
 
22
+ class ALBDataNormaliser:
23
+
24
+ @staticmethod
25
+ def tags_to_dict(tags: List[Dict[str, str]]) -> Dict[str, str]:
26
+ return {tag.get("Key", ""): tag.get("Value", "") for tag in tags}
27
+
28
+ @staticmethod
29
+ def conditions_to_dict(conditions: List[Dict[str, List[str]]]) -> Dict[str, List[str]]:
30
+ return {condition.get("Field", ""): condition.get("Values", "") for condition in conditions}
31
+
32
+
15
33
  class LoadBalancerProvider:
16
34
 
17
35
  def __init__(self, session: Session = None, io: ClickIOProvider = ClickIOProvider()):
18
36
  self.session = session
19
37
  self.evlb_client = self._get_client("elbv2")
20
38
  self.rg_tagging_client = self._get_client("resourcegroupstaggingapi")
39
+ self.parameter_store_provider = ParameterStore(self._get_client("ssm"))
21
40
  self.io = io
22
41
 
23
42
  def _get_client(self, client: str):
@@ -26,21 +45,44 @@ class LoadBalancerProvider:
26
45
  return self.session.client(client)
27
46
 
28
47
  def find_target_group(self, app: str, env: str, svc: str) -> str:
48
+
49
+ # TODO once copilot is gone this is no longer needed
50
+ try:
51
+ result = self.parameter_store_provider.get_ssm_parameter_by_name(
52
+ f"/platform/applications/{app}/environments/{env}"
53
+ )["Value"]
54
+ env_config = json.loads(result)
55
+ service_deployment_mode = env_config["service_deployment_mode"]
56
+ except Exception:
57
+ service_deployment_mode = "copilot"
58
+
59
+ if service_deployment_mode in ROUTED_TO_PLATFORM_MODES:
60
+ application_key = "application"
61
+ environment_key = "environment"
62
+ service_key = "service"
63
+ else:
64
+ application_key = "copilot-application"
65
+ environment_key = "copilot-environment"
66
+ service_key = "copilot-service"
29
67
  target_group_arn = None
30
68
 
31
69
  paginator = self.rg_tagging_client.get_paginator("get_resources")
32
70
  page_iterator = paginator.paginate(
33
71
  TagFilters=[
34
72
  {
35
- "Key": "copilot-application",
73
+ "Key": application_key,
36
74
  "Values": [
37
75
  app,
38
76
  ],
39
- "Key": "copilot-environment",
77
+ },
78
+ {
79
+ "Key": environment_key,
40
80
  "Values": [
41
81
  env,
42
82
  ],
43
- "Key": "copilot-service",
83
+ },
84
+ {
85
+ "Key": service_key,
44
86
  "Values": [
45
87
  svc,
46
88
  ],
@@ -56,9 +98,9 @@ class LoadBalancerProvider:
56
98
  tags = {tag["Key"]: tag["Value"] for tag in resource["Tags"]}
57
99
 
58
100
  if (
59
- tags.get("copilot-service") == svc
60
- and tags.get("copilot-environment") == env
61
- and tags.get("copilot-application") == app
101
+ tags.get(service_key) == svc
102
+ and tags.get(environment_key) == env
103
+ and tags.get(application_key) == app
62
104
  ):
63
105
  target_group_arn = resource["ResourceARN"]
64
106
 
@@ -69,6 +111,29 @@ class LoadBalancerProvider:
69
111
 
70
112
  return target_group_arn
71
113
 
114
+ def get_target_groups(self, target_group_arns: List[str]) -> List[dict]:
115
+ tgs = []
116
+ paginator = self.evlb_client.get_paginator("describe_target_groups")
117
+ page_iterator = paginator.paginate(TargetGroupArns=target_group_arns)
118
+ for page in page_iterator:
119
+ tgs.extend(page["TargetGroups"])
120
+
121
+ return tgs
122
+
123
+ def get_target_groups_with_tags(
124
+ self, target_group_arns: List[str], normalise: bool = True
125
+ ) -> List[dict]:
126
+ target_groups = self.get_target_groups(target_group_arns)
127
+
128
+ tags = self.get_resources_tag_descriptions(target_groups, "TargetGroupArn")
129
+
130
+ tgs_with_tags = self.merge_in_tags_by_resource_arn(target_groups, tags, "TargetGroupArn")
131
+
132
+ if normalise:
133
+ for tg in tgs_with_tags:
134
+ tg["Tags"] = ALBDataNormaliser.tags_to_dict(tg["Tags"])
135
+ return tgs_with_tags
136
+
72
137
  def get_https_certificate_for_listener(self, listener_arn: str, env: str):
73
138
  certificates = []
74
139
  paginator = self.evlb_client.get_paginator("describe_listener_certificates")
@@ -87,7 +152,7 @@ class LoadBalancerProvider:
87
152
  listener_arn = self.get_https_listener_for_application(app, env)
88
153
  return self.get_https_certificate_for_listener(listener_arn, env)
89
154
 
90
- def get_listeners_for_load_balancer(self, load_balancer_arn):
155
+ def get_listeners_for_load_balancer(self, load_balancer_arn: str) -> List[dict]:
91
156
  listeners = []
92
157
  paginator = self.evlb_client.get_paginator("describe_listeners")
93
158
  page_iterator = paginator.paginate(LoadBalancerArn=load_balancer_arn)
@@ -98,6 +163,7 @@ class LoadBalancerProvider:
98
163
 
99
164
  def get_https_listener_for_application(self, app: str, env: str) -> str:
100
165
  load_balancer_arn = self.get_load_balancer_for_application(app, env)
166
+ self.io.debug(f"Load Balancer ARN: {load_balancer_arn}")
101
167
  listeners = self.get_listeners_for_load_balancer(load_balancer_arn)
102
168
 
103
169
  listener_arn = None
@@ -112,7 +178,7 @@ class LoadBalancerProvider:
112
178
 
113
179
  return listener_arn
114
180
 
115
- def get_load_balancers(self):
181
+ def get_load_balancers(self) -> List[dict]:
116
182
  load_balancers = []
117
183
  paginator = self.evlb_client.get_paginator("describe_load_balancers")
118
184
  page_iterator = paginator.paginate()
@@ -133,7 +199,11 @@ class LoadBalancerProvider:
133
199
  for lb in tag_descriptions:
134
200
  tags = {t["Key"]: t["Value"] for t in lb["Tags"]}
135
201
  # TODO: DBTP-1967: copilot hangover, creates coupling to specific tags could update to check application and environment
136
- if tags.get("copilot-application") == app and tags.get("copilot-environment") == env:
202
+ if (
203
+ tags.get("copilot-application") == app
204
+ and tags.get("copilot-environment") == env
205
+ and tags.get("managed-by", "") == MANAGED_BY_PLATFORM_TERRAFORM
206
+ ):
137
207
  return lb["ResourceArn"]
138
208
 
139
209
  raise LoadBalancerNotFoundException(app, env)
@@ -149,8 +219,9 @@ class LoadBalancerProvider:
149
219
 
150
220
  for rule in rules:
151
221
  for action in rule["Actions"]:
152
- if action["Type"] == "forward" and action["TargetGroupArn"] == target_group_arn:
153
- conditions = rule["Conditions"]
222
+ if "TargetGroupArn" in action:
223
+ if action["Type"] == "forward" and action["TargetGroupArn"] == target_group_arn:
224
+ conditions = rule["Conditions"]
154
225
 
155
226
  if not conditions:
156
227
  raise ListenerRuleConditionsNotFoundException(listener_arn)
@@ -172,7 +243,37 @@ class LoadBalancerProvider:
172
243
  def get_rules_tag_descriptions_by_listener_arn(self, listener_arn: str) -> list:
173
244
  rules = self.get_listener_rules_by_listener_arn(listener_arn)
174
245
 
175
- return self.get_rules_tag_descriptions(rules)
246
+ return self.get_resources_tag_descriptions(rules)
247
+
248
+ def merge_in_tags_by_resource_arn(
249
+ self,
250
+ resources: List[dict],
251
+ tag_descriptions: List[dict],
252
+ resources_identifier: str = "RuleArn",
253
+ ):
254
+ tags_by_resource_arn = {
255
+ rule_tags.get("ResourceArn"): rule_tags for rule_tags in tag_descriptions if rule_tags
256
+ }
257
+ for resource in resources:
258
+ tags = tags_by_resource_arn[resource[resources_identifier]]
259
+ resource.update(tags)
260
+ return resources
261
+
262
+ def get_rules_with_tags_by_listener_arn(
263
+ self, listener_arn: str, normalise: bool = True
264
+ ) -> list:
265
+ rules = self.get_listener_rules_by_listener_arn(listener_arn)
266
+
267
+ tags = self.get_resources_tag_descriptions(rules)
268
+
269
+ rules_with_tags = self.merge_in_tags_by_resource_arn(rules, tags)
270
+
271
+ if normalise:
272
+ for rule in rules_with_tags:
273
+ rule["Conditions"] = ALBDataNormaliser.conditions_to_dict(rule["Conditions"])
274
+ rule["Tags"] = ALBDataNormaliser.tags_to_dict(rule["Tags"])
275
+
276
+ return rules_with_tags
176
277
 
177
278
  def get_listener_rules_by_listener_arn(self, listener_arn: str) -> list:
178
279
  rules = []
@@ -183,13 +284,15 @@ class LoadBalancerProvider:
183
284
 
184
285
  return rules
185
286
 
186
- def get_rules_tag_descriptions(self, rules: list) -> list:
287
+ def get_resources_tag_descriptions(
288
+ self, resources: list, resource_identifier: str = "RuleArn"
289
+ ) -> list:
187
290
  tag_descriptions = []
188
291
  chunk_size = 20
189
292
 
190
- for i in range(0, len(rules), chunk_size):
191
- chunk = rules[i : i + chunk_size]
192
- resource_arns = [r["RuleArn"] for r in chunk]
293
+ for i in range(0, len(resources), chunk_size):
294
+ chunk = resources[i : i + chunk_size]
295
+ resource_arns = [r[resource_identifier] for r in chunk]
193
296
  response = self.evlb_client.describe_tags(
194
297
  ResourceArns=resource_arns
195
298
  ) # describe_tags cannot be paginated - 04/04/2025
@@ -304,6 +407,9 @@ class LoadBalancerProvider:
304
407
 
305
408
  return deleted_rules
306
409
 
410
+ def delete_listener_rule_by_resource_arn(self, resource_arn: str) -> list:
411
+ return self.evlb_client.delete_rule(RuleArn=resource_arn)
412
+
307
413
 
308
414
  class LoadBalancerException(PlatformException):
309
415
  pass
@@ -0,0 +1,72 @@
1
+ import time
2
+ from typing import Any
3
+
4
+ import boto3
5
+ from botocore.exceptions import ClientError
6
+
7
+ from dbt_platform_helper.platform_exception import PlatformException
8
+
9
+
10
+ class LogsProvider:
11
+
12
+ def __init__(self, client: boto3.client):
13
+ self.client = client
14
+
15
+ def check_log_streams_present(self, log_group: str, expected_log_streams: list[str]) -> bool:
16
+ """
17
+ Check whether the logs streams provided exist or not.
18
+
19
+ Retry for up to 1 minute.
20
+ """
21
+
22
+ found_log_streams = set()
23
+ expected_log_streams = set(expected_log_streams)
24
+ timeout_seconds = 60
25
+ poll_interval_seconds = 5
26
+ deadline_seconds = time.monotonic() + timeout_seconds
27
+
28
+ while time.monotonic() < deadline_seconds:
29
+
30
+ remaining_log_streams = expected_log_streams - found_log_streams
31
+ if not remaining_log_streams:
32
+ return True
33
+
34
+ for log_stream in list(remaining_log_streams):
35
+ try:
36
+ response = self.client.describe_log_streams(
37
+ logGroupName=log_group, logStreamNamePrefix=log_stream, limit=1
38
+ )
39
+ except ClientError as e:
40
+ code = e.response.get("Error", {}).get("Code")
41
+ if code == "ResourceNotFoundException":
42
+ continue # Log stream not there yet, keep going
43
+ else:
44
+ raise PlatformException(
45
+ f"Failed to check if log stream '{log_stream}' exists due to an error {e}"
46
+ )
47
+
48
+ for ls in response.get("logStreams", []):
49
+ if ls.get("logStreamName") == log_stream:
50
+ found_log_streams.add(log_stream)
51
+
52
+ if expected_log_streams - found_log_streams:
53
+ time.sleep(poll_interval_seconds)
54
+
55
+ missing_log_streams = expected_log_streams - found_log_streams
56
+ raise PlatformException(
57
+ f"Timed out waiting for the following log streams to create: {missing_log_streams}"
58
+ )
59
+
60
+ def get_log_stream_events(
61
+ self, log_group: str, log_stream: str, limit: int
62
+ ) -> list[dict[str, Any]]:
63
+ """Return events for a specific log stream."""
64
+
65
+ try:
66
+ self.check_log_streams_present(log_group=log_group, expected_log_streams=[log_stream])
67
+ response = self.client.get_log_events(
68
+ logGroupName=log_group, logStreamName=log_stream, limit=limit
69
+ )
70
+ return response["events"]
71
+ except ClientError as err:
72
+ raise PlatformException(f"Error retrieving log stream events: {err}")
@@ -1,46 +1,133 @@
1
+ from dataclasses import dataclass
2
+ from dataclasses import field
3
+ from typing import Literal
4
+ from typing import Optional
5
+ from typing import Union
6
+
1
7
  import boto3
2
8
 
3
9
  from dbt_platform_helper.platform_exception import PlatformException
4
10
 
5
11
 
12
+ @dataclass
13
+ class Parameter:
14
+
15
+ name: str
16
+ value: str
17
+ arn: str = None
18
+ data_type: Literal["text", "aws:ec2:image"] = "text"
19
+ type: Literal["String", "StringList", "SecureString"] = (
20
+ "SecureString" # Returned as 'Type' from AWS
21
+ )
22
+ version: int = None
23
+ tags: Optional[dict[str, str]] = field(default_factory=dict)
24
+
25
+ def tags_to_list(self):
26
+ return [{"Key": tag, "Value": value} for tag, value in self.tags.items()]
27
+
28
+ def __str__(self):
29
+ output = f"Application {self.name} with"
30
+
31
+ return f"{output} no environments"
32
+
33
+ def __eq__(self, other: "Parameter"):
34
+ return str(self.name) == str(other.name)
35
+
36
+
6
37
  class ParameterStore:
7
- def __init__(self, ssm_client: boto3.client):
38
+ def __init__(self, ssm_client: boto3.client, with_model: bool = False):
8
39
  self.ssm_client = ssm_client
40
+ self.with_model = with_model
41
+
42
+ def __fetch_tags(self, parameter: Parameter, normalise=True):
43
+ response = self.ssm_client.list_tags_for_resource(
44
+ ResourceType="Parameter", ResourceId=parameter.name
45
+ )["TagList"]
46
+
47
+ if normalise:
48
+ return {tag["Key"]: tag["Value"] for tag in response}
49
+ else:
50
+ return response
9
51
 
10
- def get_ssm_parameter_by_name(self, parameter_name: str) -> dict:
52
+ def get_ssm_parameter_by_name(
53
+ self, parameter_name: str, add_tags: bool = False
54
+ ) -> Union[dict, Parameter]:
11
55
  """
12
56
  Retrieves the latest version of a parameter from parameter store for a
13
57
  given name/arn.
14
58
 
15
59
  Args:
16
- path (str): The parameter name to retrieve the parameter value for.
60
+ parameter_name (str): The parameter name to retrieve the parameter value for.
61
+ add_tags (bool): Whether to retrieve the tags for the SSM parameters requested
17
62
  Returns:
18
63
  dict: A dictionary representation of your ssm parameter
19
64
  """
65
+ parameter = self.ssm_client.get_parameter(Name=parameter_name, WithDecryption=True)[
66
+ "Parameter"
67
+ ]
68
+
69
+ if not self.with_model:
70
+ return parameter
71
+
72
+ model = Parameter(
73
+ name=parameter["Name"],
74
+ value=parameter["Value"],
75
+ arn=parameter["ARN"],
76
+ data_type=parameter["DataType"],
77
+ type=parameter["Type"],
78
+ version=parameter["Version"],
79
+ )
20
80
 
21
- return self.ssm_client.get_parameter(Name=parameter_name)["Parameter"]
81
+ if add_tags:
82
+ model.tags = self.__fetch_tags(model)
22
83
 
23
- def get_ssm_parameters_by_path(self, path: str) -> list:
84
+ return model
85
+
86
+ def get_ssm_parameters_by_path(
87
+ self, path: str, add_tags: bool = False
88
+ ) -> Union[list[dict], list[Parameter]]:
24
89
  """
25
90
  Retrieves all SSM parameters for a given path from parameter store.
26
91
 
27
92
  Args:
28
93
  path (str): The parameter path to retrieve the parameters for. e.g. /copilot/applications/
94
+ add_tags (bool): Whether to retrieve the tags for the SSM parameters requested
29
95
  Returns:
30
96
  list: A list of dictionaries containing all SSM parameters under the provided path.
31
97
  """
32
98
 
33
99
  parameters = []
34
100
  paginator = self.ssm_client.get_paginator("get_parameters_by_path")
35
- page_iterator = paginator.paginate(Path=path, Recursive=True)
101
+ page_iterator = paginator.paginate(Path=path, Recursive=True, WithDecryption=True)
36
102
 
37
103
  for page in page_iterator:
38
104
  parameters.extend(page.get("Parameters", []))
39
105
 
40
- if parameters:
41
- return parameters
42
- else:
43
- raise ParameterNotFoundForPathException()
106
+ if not self.with_model:
107
+ if parameters:
108
+ return parameters
109
+ else:
110
+ raise ParameterNotFoundForPathException()
111
+
112
+ to_model = lambda parameter: Parameter(
113
+ name=parameter["Name"],
114
+ value=parameter["Value"],
115
+ arn=parameter["ARN"],
116
+ data_type=parameter["DataType"],
117
+ type=parameter["Type"],
118
+ version=parameter["Version"],
119
+ )
120
+
121
+ models = [to_model(param) for param in parameters]
122
+
123
+ if add_tags:
124
+ for model in models:
125
+ model.tags = self.__fetch_tags(model)
126
+
127
+ return models
128
+
129
+ def put_parameter(self, data_dict: dict) -> dict:
130
+ return self.ssm_client.put_parameter(**data_dict)
44
131
 
45
132
 
46
133
  class ParameterNotFoundForPathException(PlatformException):