dbt-platform-helper 12.1.0__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.
- dbt_platform_helper/COMMANDS.md +5 -4
- dbt_platform_helper/commands/application.py +1 -0
- dbt_platform_helper/commands/codebase.py +3 -2
- dbt_platform_helper/commands/conduit.py +34 -409
- dbt_platform_helper/commands/secrets.py +1 -1
- dbt_platform_helper/constants.py +12 -2
- dbt_platform_helper/domain/codebase.py +1 -1
- dbt_platform_helper/domain/conduit.py +172 -0
- dbt_platform_helper/exceptions.py +33 -0
- dbt_platform_helper/providers/__init__.py +0 -0
- dbt_platform_helper/providers/cloudformation.py +105 -0
- dbt_platform_helper/providers/copilot.py +144 -0
- dbt_platform_helper/providers/ecs.py +78 -0
- dbt_platform_helper/providers/secrets.py +85 -0
- dbt_platform_helper/templates/addons/svc/prometheus-policy.yml +2 -0
- dbt_platform_helper/templates/pipelines/environments/manifest.yml +0 -1
- dbt_platform_helper/utils/validation.py +22 -1
- {dbt_platform_helper-12.1.0.dist-info → dbt_platform_helper-12.2.0.dist-info}/METADATA +2 -1
- {dbt_platform_helper-12.1.0.dist-info → dbt_platform_helper-12.2.0.dist-info}/RECORD +22 -16
- {dbt_platform_helper-12.1.0.dist-info → dbt_platform_helper-12.2.0.dist-info}/WHEEL +1 -1
- {dbt_platform_helper-12.1.0.dist-info → dbt_platform_helper-12.2.0.dist-info}/LICENSE +0 -0
- {dbt_platform_helper-12.1.0.dist-info → dbt_platform_helper-12.2.0.dist-info}/entry_points.txt +0 -0
|
@@ -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
|
+
)
|
|
@@ -20,6 +20,31 @@ class IncompatibleMinorVersion(ValidationException):
|
|
|
20
20
|
self.check_version = check_version
|
|
21
21
|
|
|
22
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
|
+
|
|
23
48
|
class CopilotCodebaseNotFoundError(Exception):
|
|
24
49
|
pass
|
|
25
50
|
|
|
@@ -46,3 +71,11 @@ class ApplicationNotFoundError(Exception):
|
|
|
46
71
|
|
|
47
72
|
class ApplicationEnvironmentNotFoundError(Exception):
|
|
48
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})
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import time
|
|
3
|
+
|
|
4
|
+
from botocore.exceptions import ClientError
|
|
5
|
+
|
|
6
|
+
from dbt_platform_helper.constants import CONDUIT_DOCKER_IMAGE_LOCATION
|
|
7
|
+
from dbt_platform_helper.exceptions import CreateTaskTimeoutError
|
|
8
|
+
from dbt_platform_helper.providers.ecs import get_ecs_task_arns
|
|
9
|
+
from dbt_platform_helper.providers.secrets import get_connection_secret_arn
|
|
10
|
+
from dbt_platform_helper.providers.secrets import (
|
|
11
|
+
get_postgres_connection_data_updated_with_master_secret,
|
|
12
|
+
)
|
|
13
|
+
from dbt_platform_helper.utils.application import Application
|
|
14
|
+
from dbt_platform_helper.utils.messages import abort_with_error
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def create_addon_client_task(
|
|
18
|
+
iam_client,
|
|
19
|
+
ssm_client,
|
|
20
|
+
secrets_manager_client,
|
|
21
|
+
subprocess,
|
|
22
|
+
application: Application,
|
|
23
|
+
env: str,
|
|
24
|
+
addon_type: str,
|
|
25
|
+
addon_name: str,
|
|
26
|
+
task_name: str,
|
|
27
|
+
access: str,
|
|
28
|
+
):
|
|
29
|
+
secret_name = f"/copilot/{application.name}/{env}/secrets/{_normalise_secret_name(addon_name)}"
|
|
30
|
+
|
|
31
|
+
if addon_type == "postgres":
|
|
32
|
+
if access == "read":
|
|
33
|
+
secret_name += "_READ_ONLY_USER"
|
|
34
|
+
elif access == "write":
|
|
35
|
+
secret_name += "_APPLICATION_USER"
|
|
36
|
+
elif access == "admin":
|
|
37
|
+
create_postgres_admin_task(
|
|
38
|
+
ssm_client,
|
|
39
|
+
secrets_manager_client,
|
|
40
|
+
subprocess,
|
|
41
|
+
application,
|
|
42
|
+
addon_name,
|
|
43
|
+
addon_type,
|
|
44
|
+
env,
|
|
45
|
+
secret_name,
|
|
46
|
+
task_name,
|
|
47
|
+
)
|
|
48
|
+
return
|
|
49
|
+
elif addon_type == "redis" or addon_type == "opensearch":
|
|
50
|
+
secret_name += "_ENDPOINT"
|
|
51
|
+
|
|
52
|
+
role_name = f"{addon_name}-{application.name}-{env}-conduitEcsTask"
|
|
53
|
+
|
|
54
|
+
try:
|
|
55
|
+
iam_client.get_role(RoleName=role_name)
|
|
56
|
+
execution_role = f"--execution-role {role_name} "
|
|
57
|
+
except ClientError as ex:
|
|
58
|
+
execution_role = ""
|
|
59
|
+
# We cannot check for botocore.errorfactory.NoSuchEntityException as botocore generates that class on the fly as part of errorfactory.
|
|
60
|
+
# factory. Checking the error code is the recommended way of handling these exceptions.
|
|
61
|
+
if ex.response.get("Error", {}).get("Code", None) != "NoSuchEntity":
|
|
62
|
+
# TODO Raise an exception to be caught at the command layer
|
|
63
|
+
abort_with_error(
|
|
64
|
+
f"cannot obtain Role {role_name}: {ex.response.get('Error', {}).get('Message', '')}"
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
subprocess.call(
|
|
68
|
+
f"copilot task run --app {application.name} --env {env} "
|
|
69
|
+
f"--task-group-name {task_name} "
|
|
70
|
+
f"{execution_role}"
|
|
71
|
+
f"--image {CONDUIT_DOCKER_IMAGE_LOCATION}:{addon_type} "
|
|
72
|
+
f"--secrets CONNECTION_SECRET={get_connection_secret_arn(ssm_client,secrets_manager_client, secret_name)} "
|
|
73
|
+
"--platform-os linux "
|
|
74
|
+
"--platform-arch arm64",
|
|
75
|
+
shell=True,
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def create_postgres_admin_task(
|
|
80
|
+
ssm_client,
|
|
81
|
+
secrets_manager_client,
|
|
82
|
+
subprocess,
|
|
83
|
+
app: Application,
|
|
84
|
+
addon_name: str,
|
|
85
|
+
addon_type: str,
|
|
86
|
+
env: str,
|
|
87
|
+
secret_name: str,
|
|
88
|
+
task_name: str,
|
|
89
|
+
):
|
|
90
|
+
read_only_secret_name = secret_name + "_READ_ONLY_USER"
|
|
91
|
+
master_secret_name = (
|
|
92
|
+
f"/copilot/{app.name}/{env}/secrets/{_normalise_secret_name(addon_name)}_RDS_MASTER_ARN"
|
|
93
|
+
)
|
|
94
|
+
master_secret_arn = ssm_client.get_parameter(Name=master_secret_name, WithDecryption=True)[
|
|
95
|
+
"Parameter"
|
|
96
|
+
]["Value"]
|
|
97
|
+
connection_string = json.dumps(
|
|
98
|
+
get_postgres_connection_data_updated_with_master_secret(
|
|
99
|
+
ssm_client, secrets_manager_client, read_only_secret_name, master_secret_arn
|
|
100
|
+
)
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
subprocess.call(
|
|
104
|
+
f"copilot task run --app {app.name} --env {env} "
|
|
105
|
+
f"--task-group-name {task_name} "
|
|
106
|
+
f"--image {CONDUIT_DOCKER_IMAGE_LOCATION}:{addon_type} "
|
|
107
|
+
f"--env-vars CONNECTION_SECRET='{connection_string}' "
|
|
108
|
+
"--platform-os linux "
|
|
109
|
+
"--platform-arch arm64",
|
|
110
|
+
shell=True,
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def connect_to_addon_client_task(
|
|
115
|
+
ecs_client,
|
|
116
|
+
subprocess,
|
|
117
|
+
application_name,
|
|
118
|
+
env,
|
|
119
|
+
cluster_arn,
|
|
120
|
+
task_name,
|
|
121
|
+
addon_client_is_running_fn=get_ecs_task_arns,
|
|
122
|
+
):
|
|
123
|
+
running = False
|
|
124
|
+
tries = 0
|
|
125
|
+
while tries < 15 and not running:
|
|
126
|
+
tries += 1
|
|
127
|
+
if addon_client_is_running_fn(ecs_client, cluster_arn, task_name):
|
|
128
|
+
subprocess.call(
|
|
129
|
+
"copilot task exec "
|
|
130
|
+
f"--app {application_name} --env {env} "
|
|
131
|
+
f"--name {task_name} "
|
|
132
|
+
f"--command bash",
|
|
133
|
+
shell=True,
|
|
134
|
+
)
|
|
135
|
+
running = True
|
|
136
|
+
|
|
137
|
+
time.sleep(1)
|
|
138
|
+
|
|
139
|
+
if not running:
|
|
140
|
+
raise CreateTaskTimeoutError
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _normalise_secret_name(addon_name: str) -> str:
|
|
144
|
+
return addon_name.replace("-", "_").upper()
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import random
|
|
2
|
+
import string
|
|
3
|
+
import time
|
|
4
|
+
from typing import List
|
|
5
|
+
|
|
6
|
+
from dbt_platform_helper.exceptions import ECSAgentNotRunning
|
|
7
|
+
from dbt_platform_helper.exceptions import NoClusterError
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def get_cluster_arn(ecs_client, application_name: str, env: str) -> str:
|
|
11
|
+
for cluster_arn in ecs_client.list_clusters()["clusterArns"]:
|
|
12
|
+
tags_response = ecs_client.list_tags_for_resource(resourceArn=cluster_arn)
|
|
13
|
+
tags = tags_response["tags"]
|
|
14
|
+
|
|
15
|
+
app_key_found = False
|
|
16
|
+
env_key_found = False
|
|
17
|
+
cluster_key_found = False
|
|
18
|
+
|
|
19
|
+
for tag in tags:
|
|
20
|
+
if tag["key"] == "copilot-application" and tag["value"] == application_name:
|
|
21
|
+
app_key_found = True
|
|
22
|
+
if tag["key"] == "copilot-environment" and tag["value"] == env:
|
|
23
|
+
env_key_found = True
|
|
24
|
+
if tag["key"] == "aws:cloudformation:logical-id" and tag["value"] == "Cluster":
|
|
25
|
+
cluster_key_found = True
|
|
26
|
+
|
|
27
|
+
if app_key_found and env_key_found and cluster_key_found:
|
|
28
|
+
return cluster_arn
|
|
29
|
+
|
|
30
|
+
raise NoClusterError
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def get_or_create_task_name(
|
|
34
|
+
ssm_client, application_name: str, env: str, addon_name: str, parameter_name: str
|
|
35
|
+
) -> str:
|
|
36
|
+
try:
|
|
37
|
+
return ssm_client.get_parameter(Name=parameter_name)["Parameter"]["Value"]
|
|
38
|
+
except ssm_client.exceptions.ParameterNotFound:
|
|
39
|
+
random_id = "".join(random.choices(string.ascii_lowercase + string.digits, k=12))
|
|
40
|
+
return f"conduit-{application_name}-{env}-{addon_name}-{random_id}"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def get_ecs_task_arns(ecs_client, cluster_arn: str, task_name: str):
|
|
44
|
+
|
|
45
|
+
tasks = ecs_client.list_tasks(
|
|
46
|
+
cluster=cluster_arn,
|
|
47
|
+
desiredStatus="RUNNING",
|
|
48
|
+
family=f"copilot-{task_name}",
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
if not tasks["taskArns"]:
|
|
52
|
+
return []
|
|
53
|
+
|
|
54
|
+
return tasks["taskArns"]
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def ecs_exec_is_available(ecs_client, cluster_arn: str, task_arns: List[str]):
|
|
58
|
+
|
|
59
|
+
current_attemps = 0
|
|
60
|
+
execute_command_agent_status = ""
|
|
61
|
+
|
|
62
|
+
while execute_command_agent_status != "RUNNING" and current_attemps < 25:
|
|
63
|
+
|
|
64
|
+
current_attemps += 1
|
|
65
|
+
|
|
66
|
+
task_details = ecs_client.describe_tasks(cluster=cluster_arn, tasks=task_arns)
|
|
67
|
+
|
|
68
|
+
managed_agents = task_details["tasks"][0]["containers"][0]["managedAgents"]
|
|
69
|
+
execute_command_agent_status = [
|
|
70
|
+
agent["lastStatus"]
|
|
71
|
+
for agent in managed_agents
|
|
72
|
+
if agent["name"] == "ExecuteCommandAgent"
|
|
73
|
+
][0]
|
|
74
|
+
|
|
75
|
+
time.sleep(1)
|
|
76
|
+
|
|
77
|
+
if execute_command_agent_status != "RUNNING":
|
|
78
|
+
raise ECSAgentNotRunning
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import urllib
|
|
3
|
+
|
|
4
|
+
from dbt_platform_helper.constants import CONDUIT_ADDON_TYPES
|
|
5
|
+
from dbt_platform_helper.exceptions import AddonNotFoundError
|
|
6
|
+
from dbt_platform_helper.exceptions import AddonTypeMissingFromConfigError
|
|
7
|
+
from dbt_platform_helper.exceptions import InvalidAddonTypeError
|
|
8
|
+
from dbt_platform_helper.exceptions import ParameterNotFoundError
|
|
9
|
+
from dbt_platform_helper.exceptions import SecretNotFoundError
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def get_postgres_connection_data_updated_with_master_secret(
|
|
13
|
+
ssm_client, secrets_manager_client, parameter_name, secret_arn
|
|
14
|
+
):
|
|
15
|
+
response = ssm_client.get_parameter(Name=parameter_name, WithDecryption=True)
|
|
16
|
+
parameter_value = response["Parameter"]["Value"]
|
|
17
|
+
|
|
18
|
+
parameter_data = json.loads(parameter_value)
|
|
19
|
+
|
|
20
|
+
secret_response = secrets_manager_client.get_secret_value(SecretId=secret_arn)
|
|
21
|
+
secret_value = json.loads(secret_response["SecretString"])
|
|
22
|
+
|
|
23
|
+
parameter_data["username"] = urllib.parse.quote(secret_value["username"])
|
|
24
|
+
parameter_data["password"] = urllib.parse.quote(secret_value["password"])
|
|
25
|
+
|
|
26
|
+
return parameter_data
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def get_connection_secret_arn(ssm_client, secrets_manager_client, secret_name: str) -> str:
|
|
30
|
+
|
|
31
|
+
try:
|
|
32
|
+
return ssm_client.get_parameter(Name=secret_name, WithDecryption=False)["Parameter"]["ARN"]
|
|
33
|
+
except ssm_client.exceptions.ParameterNotFound:
|
|
34
|
+
pass
|
|
35
|
+
|
|
36
|
+
try:
|
|
37
|
+
return secrets_manager_client.describe_secret(SecretId=secret_name)["ARN"]
|
|
38
|
+
except secrets_manager_client.exceptions.ResourceNotFoundException:
|
|
39
|
+
pass
|
|
40
|
+
|
|
41
|
+
raise SecretNotFoundError(secret_name)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def get_addon_type(ssm_client, application_name: str, env: str, addon_name: str) -> str:
|
|
45
|
+
addon_type = None
|
|
46
|
+
try:
|
|
47
|
+
addon_config = json.loads(
|
|
48
|
+
ssm_client.get_parameter(
|
|
49
|
+
Name=f"/copilot/applications/{application_name}/environments/{env}/addons"
|
|
50
|
+
)["Parameter"]["Value"]
|
|
51
|
+
)
|
|
52
|
+
except ssm_client.exceptions.ParameterNotFound:
|
|
53
|
+
raise ParameterNotFoundError
|
|
54
|
+
|
|
55
|
+
if addon_name not in addon_config.keys():
|
|
56
|
+
raise AddonNotFoundError
|
|
57
|
+
|
|
58
|
+
for name, config in addon_config.items():
|
|
59
|
+
if name == addon_name:
|
|
60
|
+
if not config.get("type"):
|
|
61
|
+
raise AddonTypeMissingFromConfigError()
|
|
62
|
+
addon_type = config["type"]
|
|
63
|
+
|
|
64
|
+
if not addon_type or addon_type not in CONDUIT_ADDON_TYPES:
|
|
65
|
+
raise InvalidAddonTypeError(addon_type)
|
|
66
|
+
|
|
67
|
+
if "postgres" in addon_type:
|
|
68
|
+
addon_type = "postgres"
|
|
69
|
+
|
|
70
|
+
return addon_type
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def get_parameter_name(
|
|
74
|
+
application_name: str, env: str, addon_type: str, addon_name: str, access: str
|
|
75
|
+
) -> str:
|
|
76
|
+
if addon_type == "postgres":
|
|
77
|
+
return f"/copilot/{application_name}/{env}/conduits/{_normalise_secret_name(addon_name)}_{access.upper()}"
|
|
78
|
+
elif addon_type == "redis" or addon_type == "opensearch":
|
|
79
|
+
return f"/copilot/{application_name}/{env}/conduits/{_normalise_secret_name(addon_name)}_ENDPOINT"
|
|
80
|
+
else:
|
|
81
|
+
return f"/copilot/{application_name}/{env}/conduits/{_normalise_secret_name(addon_name)}"
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _normalise_secret_name(addon_name: str) -> str:
|
|
85
|
+
return addon_name.replace("-", "_").upper()
|
|
@@ -16,7 +16,6 @@ source:
|
|
|
16
16
|
provider: GitHub
|
|
17
17
|
# Additional properties that further specify the location of the artifacts.
|
|
18
18
|
properties:
|
|
19
|
-
# Todo: Allow for overriding this, but without risking deploying a branch to higher environments
|
|
20
19
|
branch: main
|
|
21
20
|
repository: https://github.com/{{ git_repo }}
|
|
22
21
|
connection_name: {{ app_name }}
|
|
@@ -266,6 +266,25 @@ def iam_role_arn_regex(key):
|
|
|
266
266
|
)
|
|
267
267
|
|
|
268
268
|
|
|
269
|
+
def dbt_email_address_regex(key):
|
|
270
|
+
return Regex(
|
|
271
|
+
r"^[\w.-]+@(businessandtrade.gov.uk|digital.trade.gov.uk)$",
|
|
272
|
+
error=f"{key} must contain a valid DBT email address",
|
|
273
|
+
)
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
EXTERNAL_ROLE_ACCESS = {
|
|
277
|
+
"role_arn": iam_role_arn_regex("role_arn"),
|
|
278
|
+
"read": bool,
|
|
279
|
+
"write": bool,
|
|
280
|
+
"cyber_sign_off_by": dbt_email_address_regex("cyber_sign_off_by"),
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
EXTERNAL_ROLE_ACCESS_NAME = Regex(
|
|
284
|
+
r"^([a-z][a-zA-Z0-9_-]*)$",
|
|
285
|
+
error="External role access block name {} is invalid: names must only contain lowercase alphanumeric characters separated by hypen or underscore",
|
|
286
|
+
)
|
|
287
|
+
|
|
269
288
|
DATA_IMPORT = {
|
|
270
289
|
Optional("source_kms_key_arn"): kms_key_arn_regex("source_kms_key_arn"),
|
|
271
290
|
"source_bucket_arn": s3_bucket_arn_regex("source_bucket_arn"),
|
|
@@ -288,7 +307,8 @@ S3_BASE = {
|
|
|
288
307
|
Optional("versioning"): bool,
|
|
289
308
|
Optional("lifecycle_rules"): [LIFECYCLE_RULE],
|
|
290
309
|
Optional("data_migration"): DATA_MIGRATION,
|
|
291
|
-
|
|
310
|
+
Optional("external_role_access"): {EXTERNAL_ROLE_ACCESS_NAME: EXTERNAL_ROLE_ACCESS},
|
|
311
|
+
},
|
|
292
312
|
},
|
|
293
313
|
}
|
|
294
314
|
|
|
@@ -402,6 +422,7 @@ ALB_DEFINITION = {
|
|
|
402
422
|
Optional("forwarded_values_query_string"): bool,
|
|
403
423
|
Optional("origin_protocol_policy"): str,
|
|
404
424
|
Optional("origin_ssl_protocols"): list,
|
|
425
|
+
Optional("slack_alert_channel_alb_secret_rotation"): str,
|
|
405
426
|
Optional("viewer_certificate_minimum_protocol_version"): str,
|
|
406
427
|
Optional("viewer_certificate_ssl_support_method"): str,
|
|
407
428
|
Optional("viewer_protocol_policy"): str,
|