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
@@ -0,0 +1,222 @@
1
+ import json
2
+ import stat
3
+ import subprocess
4
+ from collections.abc import Callable
5
+ from pathlib import Path
6
+
7
+ import click
8
+ import requests
9
+ import yaml
10
+ from boto3 import Session
11
+
12
+ from dbt_platform_helper.exceptions import ApplicationDeploymentNotTriggered
13
+ from dbt_platform_helper.exceptions import ApplicationEnvironmentNotFoundError
14
+ from dbt_platform_helper.exceptions import NoCopilotCodebasesFoundError
15
+ from dbt_platform_helper.exceptions import NotInCodeBaseRepositoryError
16
+ from dbt_platform_helper.utils.application import Application
17
+ from dbt_platform_helper.utils.application import load_application
18
+ from dbt_platform_helper.utils.aws import check_codebase_exists
19
+ from dbt_platform_helper.utils.aws import check_image_exists
20
+ from dbt_platform_helper.utils.aws import get_aws_session_or_abort
21
+ from dbt_platform_helper.utils.aws import get_build_url_from_arn
22
+ from dbt_platform_helper.utils.aws import list_latest_images
23
+ from dbt_platform_helper.utils.aws import start_build_extraction
24
+ from dbt_platform_helper.utils.files import mkfile
25
+ from dbt_platform_helper.utils.git import check_if_commit_exists
26
+ from dbt_platform_helper.utils.template import setup_templates
27
+
28
+
29
+ class Codebase:
30
+ def __init__(
31
+ self,
32
+ input_fn: Callable[[str], str] = click.prompt,
33
+ echo_fn: Callable[[str], str] = click.secho,
34
+ confirm_fn: Callable[[str], bool] = click.confirm,
35
+ load_application_fn: Callable[[str], Application] = load_application,
36
+ get_aws_session_or_abort_fn: Callable[[str], Session] = get_aws_session_or_abort,
37
+ check_codebase_exists_fn: Callable[[str], str] = check_codebase_exists,
38
+ check_image_exists_fn: Callable[[str], str] = check_image_exists,
39
+ get_build_url_from_arn_fn: Callable[[str], str] = get_build_url_from_arn,
40
+ list_latest_images_fn: Callable[[str], str] = list_latest_images,
41
+ start_build_extraction_fn: Callable[[str], str] = start_build_extraction,
42
+ check_if_commit_exists_fn: Callable[[str], str] = check_if_commit_exists,
43
+ subprocess: Callable[[str], str] = subprocess.run,
44
+ ):
45
+ self.input_fn = input_fn
46
+ self.echo_fn = echo_fn
47
+ self.confirm_fn = confirm_fn
48
+ self.load_application_fn = load_application_fn
49
+ self.get_aws_session_or_abort_fn = get_aws_session_or_abort_fn
50
+ self.check_codebase_exists_fn = check_codebase_exists_fn
51
+ self.check_image_exists_fn = check_image_exists_fn
52
+ self.get_build_url_from_arn_fn = get_build_url_from_arn_fn
53
+ self.list_latest_images_fn = list_latest_images_fn
54
+ self.start_build_extraction_fn = start_build_extraction_fn
55
+ self.check_if_commit_exists_fn = check_if_commit_exists_fn
56
+ self.subprocess = subprocess
57
+
58
+ def prepare(self):
59
+ """Sets up an application codebase for use within a DBT platform
60
+ project."""
61
+ templates = setup_templates()
62
+
63
+ repository = (
64
+ self.subprocess(["git", "remote", "get-url", "origin"], capture_output=True, text=True)
65
+ .stdout.split("/")[-1]
66
+ .strip()
67
+ .removesuffix(".git")
68
+ )
69
+ if repository.endswith("-deploy") or Path("./copilot").exists():
70
+ raise NotInCodeBaseRepositoryError
71
+
72
+ builder_configuration_url = "https://raw.githubusercontent.com/uktrade/ci-image-builder/main/image_builder/configuration/builder_configuration.yml"
73
+ builder_configuration_response = requests.get(builder_configuration_url)
74
+ builder_configuration_content = yaml.safe_load(
75
+ builder_configuration_response.content.decode("utf-8")
76
+ )
77
+ builder_versions = next(
78
+ (
79
+ item
80
+ for item in builder_configuration_content["builders"]
81
+ if item["name"] == "paketobuildpacks/builder-jammy-base"
82
+ ),
83
+ None,
84
+ )
85
+ builder_version = max(x["version"] for x in builder_versions["versions"])
86
+ builder_version = min(builder_version, "0.4.240")
87
+
88
+ Path("./.copilot/phases").mkdir(parents=True, exist_ok=True)
89
+ image_build_run_contents = templates.get_template(f".copilot/image_build_run.sh").render()
90
+
91
+ config_contents = templates.get_template(f".copilot/config.yml").render(
92
+ repository=repository, builder_version=builder_version
93
+ )
94
+ self.echo_fn(
95
+ mkfile(
96
+ Path("."), ".copilot/image_build_run.sh", image_build_run_contents, overwrite=True
97
+ )
98
+ )
99
+
100
+ image_build_run_file = Path(".copilot/image_build_run.sh")
101
+ image_build_run_file.chmod(image_build_run_file.stat().st_mode | stat.S_IEXEC)
102
+
103
+ self.echo_fn(mkfile(Path("."), ".copilot/config.yml", config_contents, overwrite=True))
104
+
105
+ for phase in ["build", "install", "post_build", "pre_build"]:
106
+ phase_contents = templates.get_template(f".copilot/phases/{phase}.sh").render()
107
+
108
+ self.echo_fn(
109
+ mkfile(Path("./.copilot"), f"phases/{phase}.sh", phase_contents, overwrite=True)
110
+ )
111
+
112
+ def build(self, app: str, codebase: str, commit: str):
113
+ """Trigger a CodePipeline pipeline based build."""
114
+ session = self.get_aws_session_or_abort_fn()
115
+ self.load_application_fn(app, default_session=session)
116
+
117
+ self.check_if_commit_exists_fn(commit)
118
+
119
+ codebuild_client = session.client("codebuild")
120
+ build_url = self.__start_build_with_confirmation(
121
+ self.confirm_fn,
122
+ codebuild_client,
123
+ self.get_build_url_from_arn_fn,
124
+ f'You are about to build "{app}" for "{codebase}" with commit "{commit}". Do you want to continue?',
125
+ {
126
+ "projectName": f"codebuild-{app}-{codebase}",
127
+ "artifactsOverride": {"type": "NO_ARTIFACTS"},
128
+ "sourceVersion": commit,
129
+ },
130
+ )
131
+
132
+ if build_url:
133
+ return self.echo_fn(
134
+ f"Your build has been triggered. Check your build progress in the AWS Console: {build_url}"
135
+ )
136
+
137
+ raise ApplicationDeploymentNotTriggered()
138
+
139
+ def deploy(self, app, env, codebase, commit):
140
+ """Trigger a CodePipeline pipeline based deployment."""
141
+ session = self.get_aws_session_or_abort_fn()
142
+
143
+ application = self.load_application_fn(app, default_session=session)
144
+ if not application.environments.get(env):
145
+ raise ApplicationEnvironmentNotFoundError()
146
+
147
+ json.loads(self.check_codebase_exists_fn(session, application, codebase))
148
+
149
+ self.check_image_exists_fn(session, application, codebase, commit)
150
+
151
+ codebuild_client = session.client("codebuild")
152
+ build_url = self.__start_build_with_confirmation(
153
+ self.confirm_fn,
154
+ codebuild_client,
155
+ self.get_build_url_from_arn_fn,
156
+ f'You are about to deploy "{app}" for "{codebase}" with commit "{commit}" to the "{env}" environment. Do you want to continue?',
157
+ {
158
+ "projectName": f"pipeline-{application.name}-{codebase}-BuildProject",
159
+ "artifactsOverride": {"type": "NO_ARTIFACTS"},
160
+ "sourceTypeOverride": "NO_SOURCE",
161
+ "environmentVariablesOverride": [
162
+ {"name": "COPILOT_ENVIRONMENT", "value": env},
163
+ {"name": "IMAGE_TAG", "value": f"commit-{commit}"},
164
+ ],
165
+ },
166
+ )
167
+
168
+ if build_url:
169
+ return self.echo_fn(
170
+ "Your deployment has been triggered. Check your build progress in the AWS Console: "
171
+ f"{build_url}",
172
+ )
173
+
174
+ raise ApplicationDeploymentNotTriggered()
175
+
176
+ def list(self, app: str, with_images: bool):
177
+ """List available codebases for the application."""
178
+ session = self.get_aws_session_or_abort_fn()
179
+ application = self.load_application_fn(app, session)
180
+ ssm_client = session.client("ssm")
181
+ ecr_client = session.client("ecr")
182
+ codebases = self.__get_codebases(application, ssm_client)
183
+
184
+ self.echo_fn("The following codebases are available:")
185
+
186
+ for codebase in codebases:
187
+ self.echo_fn(f"- {codebase['name']} (https://github.com/{codebase['repository']})")
188
+ if with_images:
189
+ self.list_latest_images_fn(
190
+ ecr_client,
191
+ f"{application.name}/{codebase['name']}",
192
+ codebase["repository"],
193
+ self.echo_fn,
194
+ )
195
+
196
+ self.echo_fn("")
197
+
198
+ def __get_codebases(self, application, ssm_client):
199
+ parameters = ssm_client.get_parameters_by_path(
200
+ Path=f"/copilot/applications/{application.name}/codebases",
201
+ Recursive=True,
202
+ )["Parameters"]
203
+
204
+ codebases = [json.loads(p["Value"]) for p in parameters]
205
+
206
+ if not codebases:
207
+ # TODO Is this really an error? Or just no codebases so we could return an empty list?
208
+ raise NoCopilotCodebasesFoundError
209
+ return codebases
210
+
211
+ def __start_build_with_confirmation(
212
+ self,
213
+ confirm_fn,
214
+ codebuild_client,
215
+ get_build_url_from_arn_fn,
216
+ confirmation_message,
217
+ build_options,
218
+ ):
219
+ if confirm_fn(confirmation_message):
220
+ build_arn = self.start_build_extraction_fn(codebuild_client, build_options)
221
+ return get_build_url_from_arn_fn(build_arn)
222
+ return None
@@ -0,0 +1,172 @@
1
+ import subprocess
2
+ from collections.abc import Callable
3
+
4
+ import click
5
+
6
+ from dbt_platform_helper.exceptions import ECSAgentNotRunning
7
+ from dbt_platform_helper.providers.cloudformation import (
8
+ add_stack_delete_policy_to_task_role,
9
+ )
10
+ from dbt_platform_helper.providers.cloudformation import update_conduit_stack_resources
11
+ from dbt_platform_helper.providers.cloudformation import (
12
+ wait_for_cloudformation_to_reach_status,
13
+ )
14
+ from dbt_platform_helper.providers.copilot import connect_to_addon_client_task
15
+ from dbt_platform_helper.providers.copilot import create_addon_client_task
16
+ from dbt_platform_helper.providers.copilot import create_postgres_admin_task
17
+ from dbt_platform_helper.providers.ecs import ecs_exec_is_available
18
+ from dbt_platform_helper.providers.ecs import get_cluster_arn
19
+ from dbt_platform_helper.providers.ecs import get_ecs_task_arns
20
+ from dbt_platform_helper.providers.ecs import get_or_create_task_name
21
+ from dbt_platform_helper.providers.secrets import get_addon_type
22
+ from dbt_platform_helper.providers.secrets import get_parameter_name
23
+ from dbt_platform_helper.utils.application import Application
24
+ from dbt_platform_helper.utils.messages import abort_with_error
25
+
26
+
27
+ class Conduit:
28
+ def __init__(
29
+ self,
30
+ application: Application,
31
+ echo_fn: Callable[[str], str] = click.secho,
32
+ subprocess_fn: subprocess = subprocess,
33
+ get_ecs_task_arns_fn=get_ecs_task_arns,
34
+ connect_to_addon_client_task_fn=connect_to_addon_client_task,
35
+ create_addon_client_task_fn=create_addon_client_task,
36
+ create_postgres_admin_task_fn=create_postgres_admin_task,
37
+ get_addon_type_fn=get_addon_type,
38
+ ecs_exec_is_available_fn=ecs_exec_is_available,
39
+ get_cluster_arn_fn=get_cluster_arn,
40
+ get_parameter_name_fn=get_parameter_name,
41
+ get_or_create_task_name_fn=get_or_create_task_name,
42
+ add_stack_delete_policy_to_task_role_fn=add_stack_delete_policy_to_task_role,
43
+ update_conduit_stack_resources_fn=update_conduit_stack_resources,
44
+ wait_for_cloudformation_to_reach_status_fn=wait_for_cloudformation_to_reach_status,
45
+ abort_fn=abort_with_error,
46
+ ):
47
+
48
+ self.application = application
49
+ self.subprocess_fn = subprocess_fn
50
+ self.echo_fn = echo_fn
51
+ self.get_ecs_task_arns_fn = get_ecs_task_arns_fn
52
+ self.connect_to_addon_client_task_fn = connect_to_addon_client_task_fn
53
+ self.create_addon_client_task_fn = create_addon_client_task_fn
54
+ self.create_postgres_admin_task = create_postgres_admin_task_fn
55
+ self.get_addon_type_fn = get_addon_type_fn
56
+ self.ecs_exec_is_available_fn = ecs_exec_is_available_fn
57
+ self.get_cluster_arn_fn = get_cluster_arn_fn
58
+ self.get_parameter_name_fn = get_parameter_name_fn
59
+ self.get_or_create_task_name_fn = get_or_create_task_name_fn
60
+ self.add_stack_delete_policy_to_task_role_fn = add_stack_delete_policy_to_task_role_fn
61
+ self.update_conduit_stack_resources_fn = update_conduit_stack_resources_fn
62
+ self.wait_for_cloudformation_to_reach_status_fn = wait_for_cloudformation_to_reach_status_fn
63
+ self.abort_fn = abort_fn
64
+
65
+ def start(self, env: str, addon_name: str, access: str = "read"):
66
+ clients = self._initialise_clients(env)
67
+ addon_type, cluster_arn, parameter_name, task_name = self._get_addon_details(
68
+ env, addon_name, access
69
+ )
70
+
71
+ self.echo_fn(f"Checking if a conduit task is already running for {addon_type}")
72
+ task_arn = self.get_ecs_task_arns_fn(clients["ecs"], cluster_arn, task_name)
73
+ if not task_arn:
74
+ self.echo_fn("Creating conduit task")
75
+ self.create_addon_client_task_fn(
76
+ clients["iam"],
77
+ clients["ssm"],
78
+ clients["secrets_manager"],
79
+ self.subprocess_fn,
80
+ self.application,
81
+ env,
82
+ addon_type,
83
+ addon_name,
84
+ task_name,
85
+ access,
86
+ )
87
+
88
+ self.echo_fn("Updating conduit task")
89
+ self._update_stack_resources(
90
+ clients["cloudformation"],
91
+ clients["iam"],
92
+ clients["ssm"],
93
+ self.application.name,
94
+ env,
95
+ addon_type,
96
+ addon_name,
97
+ task_name,
98
+ parameter_name,
99
+ access,
100
+ )
101
+
102
+ task_arn = self.get_ecs_task_arns_fn(clients["ecs"], cluster_arn, task_name)
103
+
104
+ else:
105
+ self.echo_fn("Conduit task already running")
106
+
107
+ self.echo_fn(f"Checking if exec is available for conduit task...")
108
+
109
+ try:
110
+ self.ecs_exec_is_available_fn(clients["ecs"], cluster_arn, task_arn)
111
+ except ECSAgentNotRunning:
112
+ self.abort_fn('ECS exec agent never reached "RUNNING" status')
113
+
114
+ self.echo_fn("Connecting to conduit task")
115
+ self.connect_to_addon_client_task_fn(
116
+ clients["ecs"], self.subprocess_fn, self.application.name, env, cluster_arn, task_name
117
+ )
118
+
119
+ def _initialise_clients(self, env):
120
+ return {
121
+ "ecs": self.application.environments[env].session.client("ecs"),
122
+ "iam": self.application.environments[env].session.client("iam"),
123
+ "ssm": self.application.environments[env].session.client("ssm"),
124
+ "cloudformation": self.application.environments[env].session.client("cloudformation"),
125
+ "secrets_manager": self.application.environments[env].session.client("secretsmanager"),
126
+ }
127
+
128
+ def _get_addon_details(self, env, addon_name, access):
129
+ ssm_client = self.application.environments[env].session.client("ssm")
130
+ ecs_client = self.application.environments[env].session.client("ecs")
131
+
132
+ addon_type = self.get_addon_type_fn(ssm_client, self.application.name, env, addon_name)
133
+ cluster_arn = self.get_cluster_arn_fn(ecs_client, self.application.name, env)
134
+ parameter_name = self.get_parameter_name_fn(
135
+ self.application.name, env, addon_type, addon_name, access
136
+ )
137
+ task_name = self.get_or_create_task_name_fn(
138
+ ssm_client, self.application.name, env, addon_name, parameter_name
139
+ )
140
+
141
+ return addon_type, cluster_arn, parameter_name, task_name
142
+
143
+ def _update_stack_resources(
144
+ self,
145
+ cloudformation_client,
146
+ iam_client,
147
+ ssm_client,
148
+ app_name,
149
+ env,
150
+ addon_type,
151
+ addon_name,
152
+ task_name,
153
+ parameter_name,
154
+ access,
155
+ ):
156
+ self.add_stack_delete_policy_to_task_role_fn(cloudformation_client, iam_client, task_name)
157
+ stack_name = self.update_conduit_stack_resources_fn(
158
+ cloudformation_client,
159
+ iam_client,
160
+ ssm_client,
161
+ app_name,
162
+ env,
163
+ addon_type,
164
+ addon_name,
165
+ task_name,
166
+ parameter_name,
167
+ access,
168
+ )
169
+ self.echo_fn("Waiting for conduit task update to complete...")
170
+ self.wait_for_cloudformation_to_reach_status_fn(
171
+ cloudformation_client, "stack_update_complete", stack_name
172
+ )
@@ -8,9 +8,9 @@ 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
11
12
  from dbt_platform_helper.exceptions import AWSException
12
13
  from dbt_platform_helper.utils.application import Application
13
- from dbt_platform_helper.utils.application import ApplicationNotFoundError
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
@@ -18,3 +18,64 @@ class IncompatibleMinorVersion(ValidationException):
18
18
  super().__init__()
19
19
  self.app_version = app_version
20
20
  self.check_version = check_version
21
+
22
+
23
+ class NoClusterError(AWSException):
24
+ pass
25
+
26
+
27
+ class CreateTaskTimeoutError(AWSException):
28
+ pass
29
+
30
+
31
+ class ParameterNotFoundError(AWSException):
32
+ pass
33
+
34
+
35
+ class AddonNotFoundError(AWSException):
36
+ pass
37
+
38
+
39
+ class InvalidAddonTypeError(AWSException):
40
+ def __init__(self, addon_type):
41
+ self.addon_type = addon_type
42
+
43
+
44
+ class AddonTypeMissingFromConfigError(AWSException):
45
+ pass
46
+
47
+
48
+ class CopilotCodebaseNotFoundError(Exception):
49
+ pass
50
+
51
+
52
+ class NotInCodeBaseRepositoryError(Exception):
53
+ pass
54
+
55
+
56
+ class NoCopilotCodebasesFoundError(Exception):
57
+ pass
58
+
59
+
60
+ class ImageNotFoundError(Exception):
61
+ pass
62
+
63
+
64
+ class ApplicationDeploymentNotTriggered(Exception):
65
+ pass
66
+
67
+
68
+ class ApplicationNotFoundError(Exception):
69
+ pass
70
+
71
+
72
+ class ApplicationEnvironmentNotFoundError(Exception):
73
+ pass
74
+
75
+
76
+ class SecretNotFoundError(AWSException):
77
+ pass
78
+
79
+
80
+ class ECSAgentNotRunning(AWSException):
81
+ pass
File without changes
@@ -0,0 +1,105 @@
1
+ import json
2
+
3
+ from cfn_tools import dump_yaml
4
+ from cfn_tools import load_yaml
5
+
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})