dbt-platform-helper 12.2.4__py3-none-any.whl → 12.4.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 (33) hide show
  1. dbt_platform_helper/COMMANDS.md +6 -1
  2. dbt_platform_helper/commands/codebase.py +9 -80
  3. dbt_platform_helper/commands/conduit.py +25 -45
  4. dbt_platform_helper/commands/config.py +4 -4
  5. dbt_platform_helper/commands/copilot.py +13 -15
  6. dbt_platform_helper/commands/database.py +17 -4
  7. dbt_platform_helper/commands/environment.py +3 -2
  8. dbt_platform_helper/commands/secrets.py +1 -1
  9. dbt_platform_helper/domain/codebase.py +81 -63
  10. dbt_platform_helper/domain/conduit.py +42 -93
  11. dbt_platform_helper/domain/database_copy.py +48 -42
  12. dbt_platform_helper/domain/maintenance_page.py +8 -8
  13. dbt_platform_helper/platform_exception.py +5 -0
  14. dbt_platform_helper/providers/aws.py +32 -0
  15. dbt_platform_helper/providers/cloudformation.py +129 -100
  16. dbt_platform_helper/providers/copilot.py +33 -16
  17. dbt_platform_helper/providers/ecs.py +97 -74
  18. dbt_platform_helper/providers/load_balancers.py +11 -5
  19. dbt_platform_helper/providers/secrets.py +100 -59
  20. dbt_platform_helper/providers/validation.py +19 -0
  21. dbt_platform_helper/utils/application.py +14 -2
  22. dbt_platform_helper/utils/arn_parser.py +1 -1
  23. dbt_platform_helper/utils/aws.py +38 -12
  24. dbt_platform_helper/utils/git.py +2 -2
  25. dbt_platform_helper/utils/validation.py +57 -18
  26. dbt_platform_helper/utils/versioning.py +8 -8
  27. {dbt_platform_helper-12.2.4.dist-info → dbt_platform_helper-12.4.0.dist-info}/METADATA +1 -1
  28. {dbt_platform_helper-12.2.4.dist-info → dbt_platform_helper-12.4.0.dist-info}/RECORD +31 -30
  29. dbt_platform_helper/addons-template-map.yml +0 -29
  30. dbt_platform_helper/exceptions.py +0 -81
  31. {dbt_platform_helper-12.2.4.dist-info → dbt_platform_helper-12.4.0.dist-info}/LICENSE +0 -0
  32. {dbt_platform_helper-12.2.4.dist-info → dbt_platform_helper-12.4.0.dist-info}/WHEEL +0 -0
  33. {dbt_platform_helper-12.2.4.dist-info → dbt_platform_helper-12.4.0.dist-info}/entry_points.txt +0 -0
@@ -8,13 +8,15 @@ from boto3 import Session
8
8
 
9
9
  from dbt_platform_helper.constants import PLATFORM_CONFIG_FILE
10
10
  from dbt_platform_helper.domain.maintenance_page import MaintenancePageProvider
11
- from dbt_platform_helper.exceptions import ApplicationNotFoundError
12
- from dbt_platform_helper.exceptions import AWSException
11
+ from dbt_platform_helper.providers.aws import AWSException
13
12
  from dbt_platform_helper.utils.application import Application
13
+ from dbt_platform_helper.utils.application import ApplicationNotFoundException
14
14
  from dbt_platform_helper.utils.application import load_application
15
15
  from dbt_platform_helper.utils.aws import Vpc
16
16
  from dbt_platform_helper.utils.aws import get_connection_string
17
17
  from dbt_platform_helper.utils.aws import get_vpc_info_by_name
18
+ from dbt_platform_helper.utils.aws import wait_for_log_group_to_exist
19
+ from dbt_platform_helper.utils.files import apply_environment_defaults
18
20
  from dbt_platform_helper.utils.messages import abort_with_error
19
21
  from dbt_platform_helper.utils.validation import load_and_validate_platform_config
20
22
 
@@ -25,80 +27,80 @@ class DatabaseCopy:
25
27
  app: str,
26
28
  database: str,
27
29
  auto_approve: bool = False,
28
- load_application_fn: Callable[[str], Application] = load_application,
29
- vpc_config_fn: Callable[[Session, str, str, str], Vpc] = get_vpc_info_by_name,
30
- db_connection_string_fn: Callable[
30
+ load_application: Callable[[str], Application] = load_application,
31
+ vpc_config: Callable[[Session, str, str, str], Vpc] = get_vpc_info_by_name,
32
+ db_connection_string: Callable[
31
33
  [Session, str, str, str, Callable], str
32
34
  ] = get_connection_string,
33
35
  maintenance_page_provider: Callable[
34
36
  [str, str, list[str], str, str], None
35
37
  ] = MaintenancePageProvider(),
36
- input_fn: Callable[[str], str] = click.prompt,
37
- echo_fn: Callable[[str], str] = click.secho,
38
- abort_fn: Callable[[str], None] = abort_with_error,
38
+ input: Callable[[str], str] = click.prompt,
39
+ echo: Callable[[str], str] = click.secho,
40
+ abort: Callable[[str], None] = abort_with_error,
39
41
  ):
40
42
  self.app = app
41
43
  self.database = database
42
44
  self.auto_approve = auto_approve
43
- self.vpc_config_fn = vpc_config_fn
44
- self.db_connection_string_fn = db_connection_string_fn
45
+ self.vpc_config = vpc_config
46
+ self.db_connection_string = db_connection_string
45
47
  self.maintenance_page_provider = maintenance_page_provider
46
- self.input_fn = input_fn
47
- self.echo_fn = echo_fn
48
- self.abort_fn = abort_fn
48
+ self.input = input
49
+ self.echo = echo
50
+ self.abort = abort
49
51
 
50
52
  if not self.app:
51
53
  if not Path(PLATFORM_CONFIG_FILE).exists():
52
- self.abort_fn("You must either be in a deploy repo, or provide the --app option.")
54
+ self.abort("You must either be in a deploy repo, or provide the --app option.")
53
55
 
54
56
  config = load_and_validate_platform_config()
55
57
  self.app = config["application"]
56
58
 
57
59
  try:
58
- self.application = load_application_fn(self.app)
59
- except ApplicationNotFoundError:
60
- abort_fn(f"No such application '{app}'.")
60
+ self.application = load_application(self.app)
61
+ except ApplicationNotFoundException:
62
+ abort(f"No such application '{app}'.")
61
63
 
62
- def _execute_operation(self, is_dump: bool, env: str, vpc_name: str):
64
+ def _execute_operation(self, is_dump: bool, env: str, vpc_name: str, filename: str):
63
65
  vpc_name = self.enrich_vpc_name(env, vpc_name)
64
66
 
65
67
  environments = self.application.environments
66
68
  environment = environments.get(env)
67
69
  if not environment:
68
- self.abort_fn(
70
+ self.abort(
69
71
  f"No such environment '{env}'. Available environments are: {', '.join(environments.keys())}"
70
72
  )
71
73
 
72
74
  env_session = environment.session
73
75
 
74
76
  try:
75
- vpc_config = self.vpc_config_fn(env_session, self.app, env, vpc_name)
77
+ vpc_config = self.vpc_config(env_session, self.app, env, vpc_name)
76
78
  except AWSException as ex:
77
- self.abort_fn(str(ex))
79
+ self.abort(str(ex))
78
80
 
79
81
  database_identifier = f"{self.app}-{env}-{self.database}"
80
82
 
81
83
  try:
82
- db_connection_string = self.db_connection_string_fn(
84
+ db_connection_string = self.db_connection_string(
83
85
  env_session, self.app, env, database_identifier
84
86
  )
85
87
  except Exception as exc:
86
- self.abort_fn(f"{exc} (Database: {database_identifier})")
88
+ self.abort(f"{exc} (Database: {database_identifier})")
87
89
 
88
90
  try:
89
91
  task_arn = self.run_database_copy_task(
90
- env_session, env, vpc_config, is_dump, db_connection_string
92
+ env_session, env, vpc_config, is_dump, db_connection_string, filename
91
93
  )
92
94
  except Exception as exc:
93
- self.abort_fn(f"{exc} (Account id: {self.account_id(env)})")
95
+ self.abort(f"{exc} (Account id: {self.account_id(env)})")
94
96
 
95
97
  if is_dump:
96
98
  message = f"Dumping {self.database} from the {env} environment into S3"
97
99
  else:
98
100
  message = f"Loading data into {self.database} in the {env} environment from S3"
99
101
 
100
- self.echo_fn(message, fg="white", bold=True)
101
- self.echo_fn(
102
+ self.echo(message, fg="white", bold=True)
103
+ self.echo(
102
104
  f"Task {task_arn} started. Waiting for it to complete (this may take some time)...",
103
105
  fg="white",
104
106
  )
@@ -107,11 +109,10 @@ class DatabaseCopy:
107
109
  def enrich_vpc_name(self, env, vpc_name):
108
110
  if not vpc_name:
109
111
  if not Path(PLATFORM_CONFIG_FILE).exists():
110
- self.abort_fn(
111
- "You must either be in a deploy repo, or provide the vpc name option."
112
- )
112
+ self.abort("You must either be in a deploy repo, or provide the vpc name option.")
113
113
  config = load_and_validate_platform_config()
114
- vpc_name = config.get("environments", {}).get(env, {}).get("vpc")
114
+ env_config = apply_environment_defaults(config)["environments"]
115
+ vpc_name = env_config.get(env, {}).get("vpc")
115
116
  return vpc_name
116
117
 
117
118
  def run_database_copy_task(
@@ -121,12 +122,15 @@ class DatabaseCopy:
121
122
  vpc_config: Vpc,
122
123
  is_dump: bool,
123
124
  db_connection_string: str,
125
+ filename: str,
124
126
  ) -> str:
125
127
  client = session.client("ecs")
126
128
  action = "dump" if is_dump else "load"
129
+ dump_file_name = filename if filename else "data_dump"
127
130
  env_vars = [
128
131
  {"name": "DATA_COPY_OPERATION", "value": action.upper()},
129
132
  {"name": "DB_CONNECTION_STRING", "value": db_connection_string},
133
+ {"name": "DUMP_FILE_NAME", "value": dump_file_name},
130
134
  ]
131
135
  if not is_dump:
132
136
  env_vars.append({"name": "ECS_CLUSTER", "value": f"{self.app}-{env}"})
@@ -156,12 +160,12 @@ class DatabaseCopy:
156
160
 
157
161
  return response.get("tasks", [{}])[0].get("taskArn")
158
162
 
159
- def dump(self, env: str, vpc_name: str):
160
- self._execute_operation(True, env, vpc_name)
163
+ def dump(self, env: str, vpc_name: str, filename: str = None):
164
+ self._execute_operation(True, env, vpc_name, filename)
161
165
 
162
- def load(self, env: str, vpc_name: str):
166
+ def load(self, env: str, vpc_name: str, filename: str = None):
163
167
  if self.is_confirmed_ready_to_load(env):
164
- self._execute_operation(False, env, vpc_name)
168
+ self._execute_operation(False, env, vpc_name, filename)
165
169
 
166
170
  def copy(
167
171
  self,
@@ -176,8 +180,8 @@ class DatabaseCopy:
176
180
  to_vpc = self.enrich_vpc_name(to_env, to_vpc)
177
181
  if not no_maintenance_page:
178
182
  self.maintenance_page_provider.activate(self.app, to_env, services, template, to_vpc)
179
- self.dump(from_env, from_vpc)
180
- self.load(to_env, to_vpc)
183
+ self.dump(from_env, from_vpc, f"data_dump_{to_env}")
184
+ self.load(to_env, to_vpc, f"data_dump_{to_env}")
181
185
  if not no_maintenance_page:
182
186
  self.maintenance_page_provider.deactivate(self.app, to_env)
183
187
 
@@ -185,7 +189,7 @@ class DatabaseCopy:
185
189
  if self.auto_approve:
186
190
  return True
187
191
 
188
- user_input = self.input_fn(
192
+ user_input = self.input(
189
193
  f"\nWARNING: the load operation is destructive and will delete the {self.database} database in the {env} environment. Continue? (y/n)"
190
194
  )
191
195
  return user_input.lower().strip() in ["y", "yes"]
@@ -194,9 +198,11 @@ class DatabaseCopy:
194
198
  action = "dump" if is_dump else "load"
195
199
  log_group_name = f"/ecs/{self.app}-{env}-{self.database}-{action}"
196
200
  log_group_arn = f"arn:aws:logs:eu-west-2:{self.account_id(env)}:log-group:{log_group_name}"
197
- self.echo_fn(f"Tailing {log_group_name} logs", fg="yellow")
201
+ self.echo(f"Tailing {log_group_name} logs", fg="yellow")
198
202
  session = self.application.environments[env].session
199
- response = session.client("logs").start_live_tail(logGroupIdentifiers=[log_group_arn])
203
+ log_client = session.client("logs")
204
+ wait_for_log_group_to_exist(log_client, log_group_name)
205
+ response = log_client.start_live_tail(logGroupIdentifiers=[log_group_arn])
200
206
 
201
207
  stopped = False
202
208
  for data in response["responseStream"]:
@@ -210,9 +216,9 @@ class DatabaseCopy:
210
216
  match = re.match(r"(Stopping|Aborting) data (load|dump).*", message)
211
217
  if match:
212
218
  if match.group(1) == "Aborting":
213
- self.abort_fn("Task aborted abnormally. See logs above for details.")
219
+ self.abort("Task aborted abnormally. See logs above for details.")
214
220
  stopped = True
215
- self.echo_fn(message)
221
+ self.echo(message)
216
222
 
217
223
  def account_id(self, env):
218
224
  envs = self.application.environments
@@ -9,9 +9,9 @@ from typing import Union
9
9
  import boto3
10
10
  import click
11
11
 
12
- from dbt_platform_helper.providers.load_balancers import ListenerNotFoundError
13
- from dbt_platform_helper.providers.load_balancers import ListenerRuleNotFoundError
14
- from dbt_platform_helper.providers.load_balancers import LoadBalancerNotFoundError
12
+ from dbt_platform_helper.providers.load_balancers import ListenerNotFoundException
13
+ from dbt_platform_helper.providers.load_balancers import ListenerRuleNotFoundException
14
+ from dbt_platform_helper.providers.load_balancers import LoadBalancerNotFoundException
15
15
  from dbt_platform_helper.providers.load_balancers import find_https_listener
16
16
  from dbt_platform_helper.utils.application import Environment
17
17
  from dbt_platform_helper.utils.application import Service
@@ -75,13 +75,13 @@ class MaintenancePageProvider:
75
75
  else:
76
76
  raise click.Abort
77
77
 
78
- except LoadBalancerNotFoundError:
78
+ except LoadBalancerNotFoundException:
79
79
  click.secho(
80
80
  f"No load balancer found for environment {env} in the application {app}.", fg="red"
81
81
  )
82
82
  raise click.Abort
83
83
 
84
- except ListenerNotFoundError:
84
+ except ListenerNotFoundException:
85
85
  click.secho(
86
86
  f"No HTTPS listener found for environment {env} in the application {app}.", fg="red"
87
87
  )
@@ -110,13 +110,13 @@ class MaintenancePageProvider:
110
110
  f"Maintenance page removed from environment {env} in application {app}", fg="green"
111
111
  )
112
112
 
113
- except LoadBalancerNotFoundError:
113
+ except LoadBalancerNotFoundException:
114
114
  click.secho(
115
115
  f"No load balancer found for environment {env} in the application {app}.", fg="red"
116
116
  )
117
117
  raise click.Abort
118
118
 
119
- except ListenerNotFoundError:
119
+ except ListenerNotFoundException:
120
120
  click.secho(
121
121
  f"No HTTPS listener found for environment {env} in the application {app}.", fg="red"
122
122
  )
@@ -180,7 +180,7 @@ def remove_maintenance_page(session: boto3.Session, listener_arn: str):
180
180
  deleted = delete_listener_rule(tag_descriptions, name, lb_client)
181
181
 
182
182
  if name == "MaintenancePage" and not deleted:
183
- raise ListenerRuleNotFoundError()
183
+ raise ListenerRuleNotFoundException()
184
184
 
185
185
 
186
186
  def get_rules_tag_descriptions(rules: list, lb_client):
@@ -0,0 +1,5 @@
1
+ # This exception exists so that we can easily catch exceptions
2
+ # at the command level where we know we can just output the
3
+ # error and abort.
4
+ class PlatformException(Exception):
5
+ pass
@@ -0,0 +1,32 @@
1
+ from dbt_platform_helper.platform_exception import PlatformException
2
+
3
+
4
+ class AWSException(PlatformException):
5
+ pass
6
+
7
+
8
+ class CreateTaskTimeoutException(AWSException):
9
+ def __init__(self, addon_name: str, application_name: str, environment: str):
10
+ super().__init__(
11
+ f"""Client ({addon_name}) ECS task has failed to start for "{application_name}" in "{environment}" environment."""
12
+ )
13
+
14
+
15
+ class ImageNotFoundException(AWSException):
16
+ def __init__(self, commit: str):
17
+ super().__init__(
18
+ f"""The commit hash "{commit}" has not been built into an image, try the `platform-helper codebase build` command first."""
19
+ )
20
+
21
+
22
+ class LogGroupNotFoundException(AWSException):
23
+ def __init__(self, log_group_name: str):
24
+ super().__init__(f"""No log group called "{log_group_name}".""")
25
+
26
+
27
+ # Todo: This should probably be in the AWS Copilot provider, but was causing circular import when we tried it pre refactoring the utils/aws.py
28
+ class CopilotCodebaseNotFoundException(PlatformException):
29
+ def __init__(self, codebase: str):
30
+ super().__init__(
31
+ f"""The codebase "{codebase}" either does not exist or has not been deployed."""
32
+ )
@@ -1,105 +1,134 @@
1
1
  import json
2
2
 
3
+ import botocore
3
4
  from cfn_tools import dump_yaml
4
5
  from cfn_tools import load_yaml
5
6
 
6
-
7
- def add_stack_delete_policy_to_task_role(cloudformation_client, iam_client, task_name: str):
8
-
9
- stack_name = f"task-{task_name}"
10
- stack_resources = cloudformation_client.list_stack_resources(StackName=stack_name)[
11
- "StackResourceSummaries"
12
- ]
13
-
14
- for resource in stack_resources:
15
- if resource["LogicalResourceId"] == "DefaultTaskRole":
16
- task_role_name = resource["PhysicalResourceId"]
17
- iam_client.put_role_policy(
18
- RoleName=task_role_name,
19
- PolicyName="DeleteCloudFormationStack",
20
- PolicyDocument=json.dumps(
21
- {
22
- "Version": "2012-10-17",
23
- "Statement": [
24
- {
25
- "Action": ["cloudformation:DeleteStack"],
26
- "Effect": "Allow",
27
- "Resource": f"arn:aws:cloudformation:*:*:stack/{stack_name}/*",
28
- },
29
- ],
30
- },
31
- ),
32
- )
33
-
34
-
35
- def update_conduit_stack_resources(
36
- cloudformation_client,
37
- iam_client,
38
- ssm_client,
39
- application_name: str,
40
- env: str,
41
- addon_type: str,
42
- addon_name: str,
43
- task_name: str,
44
- parameter_name: str,
45
- access: str,
46
- ):
47
-
48
- conduit_stack_name = f"task-{task_name}"
49
- template = cloudformation_client.get_template(StackName=conduit_stack_name)
50
- template_yml = load_yaml(template["TemplateBody"])
51
- template_yml["Resources"]["LogGroup"]["DeletionPolicy"] = "Retain"
52
- template_yml["Resources"]["TaskNameParameter"] = load_yaml(
53
- f"""
54
- Type: AWS::SSM::Parameter
55
- Properties:
56
- Name: {parameter_name}
57
- Type: String
58
- Value: {task_name}
59
- """
60
- )
61
-
62
- log_filter_role_arn = iam_client.get_role(RoleName="CWLtoSubscriptionFilterRole")["Role"]["Arn"]
63
-
64
- destination_log_group_arns = json.loads(
65
- ssm_client.get_parameter(Name="/copilot/tools/central_log_groups")["Parameter"]["Value"]
66
- )
67
-
68
- destination_arn = destination_log_group_arns["dev"]
69
- if env.lower() in ("prod", "production"):
70
- destination_arn = destination_log_group_arns["prod"]
71
-
72
- template_yml["Resources"]["SubscriptionFilter"] = load_yaml(
73
- f"""
74
- Type: AWS::Logs::SubscriptionFilter
75
- DeletionPolicy: Retain
76
- Properties:
77
- RoleArn: {log_filter_role_arn}
78
- LogGroupName: /copilot/{task_name}
79
- FilterName: /copilot/conduit/{application_name}/{env}/{addon_type}/{addon_name}/{task_name.rsplit("-", 1)[1]}/{access}
80
- FilterPattern: ''
81
- DestinationArn: {destination_arn}
82
- """
83
- )
84
-
85
- params = []
86
- if "Parameters" in template_yml:
87
- for param in template_yml["Parameters"]:
88
- # TODO testing missed in codecov, update test to assert on method call below with params including ExistingParameter from cloudformation template.
89
- params.append({"ParameterKey": param, "UsePreviousValue": True})
90
-
91
- cloudformation_client.update_stack(
92
- StackName=conduit_stack_name,
93
- TemplateBody=dump_yaml(template_yml),
94
- Parameters=params,
95
- Capabilities=["CAPABILITY_IAM"],
96
- )
97
-
98
- return conduit_stack_name
99
-
100
-
101
- # TODO Catch errors and raise a more human friendly Exception is the CloudFormation stack goes into a "unhappy" state, e.g. ROLLBACK_IN_PROGRESS. Currently we get things like botocore.exceptions.WaiterError: Waiter StackUpdateComplete failed: Waiter encountered a terminal failure state: For expression "Stacks[].StackStatus" we matched expected path: "UPDATE_ROLLBACK_COMPLETE" at least once
102
- def wait_for_cloudformation_to_reach_status(cloudformation_client, stack_status, stack_name):
103
-
104
- waiter = cloudformation_client.get_waiter(stack_status)
105
- waiter.wait(StackName=stack_name, WaiterConfig={"Delay": 5, "MaxAttempts": 20})
7
+ from dbt_platform_helper.platform_exception import PlatformException
8
+
9
+
10
+ class CloudFormation:
11
+ def __init__(self, cloudformation_client, iam_client, ssm_client):
12
+ self.cloudformation_client = cloudformation_client
13
+ self.iam_client = iam_client
14
+ self.ssm_client = ssm_client
15
+
16
+ def add_stack_delete_policy_to_task_role(self, task_name: str):
17
+ stack_name = f"task-{task_name}"
18
+ stack_resources = self.cloudformation_client.list_stack_resources(StackName=stack_name)[
19
+ "StackResourceSummaries"
20
+ ]
21
+
22
+ for resource in stack_resources:
23
+ if resource["LogicalResourceId"] == "DefaultTaskRole":
24
+ task_role_name = resource["PhysicalResourceId"]
25
+ self.iam_client.put_role_policy(
26
+ RoleName=task_role_name,
27
+ PolicyName="DeleteCloudFormationStack",
28
+ PolicyDocument=json.dumps(
29
+ {
30
+ "Version": "2012-10-17",
31
+ "Statement": [
32
+ {
33
+ "Action": ["cloudformation:DeleteStack"],
34
+ "Effect": "Allow",
35
+ "Resource": f"arn:aws:cloudformation:*:*:stack/{stack_name}/*",
36
+ },
37
+ ],
38
+ },
39
+ ),
40
+ )
41
+
42
+ def update_conduit_stack_resources(
43
+ self,
44
+ application_name: str,
45
+ env: str,
46
+ addon_type: str,
47
+ addon_name: str,
48
+ task_name: str,
49
+ parameter_name: str,
50
+ access: str,
51
+ ):
52
+ conduit_stack_name = f"task-{task_name}"
53
+ template = self.cloudformation_client.get_template(StackName=conduit_stack_name)
54
+ template_yml = load_yaml(template["TemplateBody"])
55
+
56
+ template_yml["Resources"]["LogGroup"]["DeletionPolicy"] = "Retain"
57
+
58
+ template_yml["Resources"]["TaskNameParameter"] = load_yaml(
59
+ f"""
60
+ Type: AWS::SSM::Parameter
61
+ Properties:
62
+ Name: {parameter_name}
63
+ Type: String
64
+ Value: {task_name}
65
+ """
66
+ )
67
+
68
+ log_filter_role_arn = self.iam_client.get_role(RoleName="CWLtoSubscriptionFilterRole")[
69
+ "Role"
70
+ ]["Arn"]
71
+
72
+ destination_log_group_arns = json.loads(
73
+ self.ssm_client.get_parameter(Name="/copilot/tools/central_log_groups")["Parameter"][
74
+ "Value"
75
+ ]
76
+ )
77
+
78
+ destination_arn = destination_log_group_arns["dev"]
79
+ if env.lower() in ("prod", "production"):
80
+ destination_arn = destination_log_group_arns["prod"]
81
+
82
+ template_yml["Resources"]["SubscriptionFilter"] = load_yaml(
83
+ f"""
84
+ Type: AWS::Logs::SubscriptionFilter
85
+ DeletionPolicy: Retain
86
+ Properties:
87
+ RoleArn: {log_filter_role_arn}
88
+ LogGroupName: /copilot/{task_name}
89
+ FilterName: /copilot/conduit/{application_name}/{env}/{addon_type}/{addon_name}/{task_name.rsplit("-", 1)[1]}/{access}
90
+ FilterPattern: ''
91
+ DestinationArn: {destination_arn}
92
+ """
93
+ )
94
+
95
+ params = []
96
+ # TODO Currently not covered by tests - see https://uktrade.atlassian.net/browse/DBTP-1582
97
+ if "Parameters" in template_yml:
98
+ for param in template_yml["Parameters"]:
99
+ params.append({"ParameterKey": param, "UsePreviousValue": True})
100
+
101
+ self.cloudformation_client.update_stack(
102
+ StackName=conduit_stack_name,
103
+ TemplateBody=dump_yaml(template_yml),
104
+ Parameters=params,
105
+ Capabilities=["CAPABILITY_IAM"],
106
+ )
107
+
108
+ return conduit_stack_name
109
+
110
+ def wait_for_cloudformation_to_reach_status(self, stack_status, stack_name):
111
+ waiter = self.cloudformation_client.get_waiter(stack_status)
112
+
113
+ try:
114
+ waiter.wait(StackName=stack_name, WaiterConfig={"Delay": 5, "MaxAttempts": 20})
115
+ except botocore.exceptions.WaiterError as err:
116
+ current_status = err.last_response.get("Stacks", [{}])[0].get("StackStatus", "")
117
+
118
+ if current_status in [
119
+ "ROLLBACK_IN_PROGRESS",
120
+ "UPDATE_ROLLBACK_IN_PROGRESS",
121
+ "ROLLBACK_FAILED",
122
+ ]:
123
+ raise CloudFormationException(stack_name, current_status)
124
+ else:
125
+ raise CloudFormationException(
126
+ stack_name, f"Error while waiting for stack status: {str(err)}"
127
+ )
128
+
129
+
130
+ class CloudFormationException(PlatformException):
131
+ def __init__(self, stack_name: str, current_status: str):
132
+ super().__init__(
133
+ f"The CloudFormation stack '{stack_name}' is not in a good state: {current_status}"
134
+ )