dbt-platform-helper 11.0.1__py3-none-any.whl → 11.1.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 +60 -19
- dbt_platform_helper/commands/database.py +97 -18
- dbt_platform_helper/commands/environment.py +6 -518
- dbt_platform_helper/domain/__init__.py +0 -0
- dbt_platform_helper/domain/database_copy.py +220 -0
- dbt_platform_helper/domain/maintenance_page.py +459 -0
- dbt_platform_helper/providers/load_balancers.py +51 -0
- dbt_platform_helper/utils/aws.py +47 -37
- dbt_platform_helper/utils/validation.py +29 -0
- {dbt_platform_helper-11.0.1.dist-info → dbt_platform_helper-11.1.0.dist-info}/METADATA +2 -1
- {dbt_platform_helper-11.0.1.dist-info → dbt_platform_helper-11.1.0.dist-info}/RECORD +14 -11
- dbt_platform_helper/commands/database_helpers.py +0 -145
- {dbt_platform_helper-11.0.1.dist-info → dbt_platform_helper-11.1.0.dist-info}/LICENSE +0 -0
- {dbt_platform_helper-11.0.1.dist-info → dbt_platform_helper-11.1.0.dist-info}/WHEEL +0 -0
- {dbt_platform_helper-11.0.1.dist-info → dbt_platform_helper-11.1.0.dist-info}/entry_points.txt +0 -0
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
import re
|
|
2
|
+
from collections.abc import Callable
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
import boto3
|
|
6
|
+
import click
|
|
7
|
+
from boto3 import Session
|
|
8
|
+
|
|
9
|
+
from dbt_platform_helper.constants import PLATFORM_CONFIG_FILE
|
|
10
|
+
from dbt_platform_helper.domain.maintenance_page import MaintenancePageProvider
|
|
11
|
+
from dbt_platform_helper.exceptions import AWSException
|
|
12
|
+
from dbt_platform_helper.utils.application import Application
|
|
13
|
+
from dbt_platform_helper.utils.application import ApplicationNotFoundError
|
|
14
|
+
from dbt_platform_helper.utils.application import load_application
|
|
15
|
+
from dbt_platform_helper.utils.aws import Vpc
|
|
16
|
+
from dbt_platform_helper.utils.aws import get_connection_string
|
|
17
|
+
from dbt_platform_helper.utils.aws import get_vpc_info_by_name
|
|
18
|
+
from dbt_platform_helper.utils.messages import abort_with_error
|
|
19
|
+
from dbt_platform_helper.utils.validation import load_and_validate_platform_config
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class DatabaseCopy:
|
|
23
|
+
def __init__(
|
|
24
|
+
self,
|
|
25
|
+
app: str,
|
|
26
|
+
database: str,
|
|
27
|
+
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[
|
|
31
|
+
[Session, str, str, str, Callable], str
|
|
32
|
+
] = get_connection_string,
|
|
33
|
+
maintenance_page_provider: Callable[
|
|
34
|
+
[str, str, list[str], str, str], None
|
|
35
|
+
] = 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,
|
|
39
|
+
):
|
|
40
|
+
self.app = app
|
|
41
|
+
self.database = database
|
|
42
|
+
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.maintenance_page_provider = maintenance_page_provider
|
|
46
|
+
self.input_fn = input_fn
|
|
47
|
+
self.echo_fn = echo_fn
|
|
48
|
+
self.abort_fn = abort_fn
|
|
49
|
+
|
|
50
|
+
if not self.app:
|
|
51
|
+
if not Path(PLATFORM_CONFIG_FILE).exists():
|
|
52
|
+
self.abort_fn("You must either be in a deploy repo, or provide the --app option.")
|
|
53
|
+
|
|
54
|
+
config = load_and_validate_platform_config(disable_aws_validation=True)
|
|
55
|
+
self.app = config["application"]
|
|
56
|
+
|
|
57
|
+
try:
|
|
58
|
+
self.application = load_application_fn(self.app)
|
|
59
|
+
except ApplicationNotFoundError:
|
|
60
|
+
abort_fn(f"No such application '{app}'.")
|
|
61
|
+
|
|
62
|
+
def _execute_operation(self, is_dump: bool, env: str, vpc_name: str):
|
|
63
|
+
vpc_name = self.enrich_vpc_name(env, vpc_name)
|
|
64
|
+
|
|
65
|
+
environments = self.application.environments
|
|
66
|
+
environment = environments.get(env)
|
|
67
|
+
if not environment:
|
|
68
|
+
self.abort_fn(
|
|
69
|
+
f"No such environment '{env}'. Available environments are: {', '.join(environments.keys())}"
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
env_session = environment.session
|
|
73
|
+
|
|
74
|
+
try:
|
|
75
|
+
vpc_config = self.vpc_config_fn(env_session, self.app, env, vpc_name)
|
|
76
|
+
except AWSException as ex:
|
|
77
|
+
self.abort_fn(str(ex))
|
|
78
|
+
|
|
79
|
+
database_identifier = f"{self.app}-{env}-{self.database}"
|
|
80
|
+
|
|
81
|
+
try:
|
|
82
|
+
db_connection_string = self.db_connection_string_fn(
|
|
83
|
+
env_session, self.app, env, database_identifier
|
|
84
|
+
)
|
|
85
|
+
except Exception as exc:
|
|
86
|
+
self.abort_fn(f"{exc} (Database: {database_identifier})")
|
|
87
|
+
|
|
88
|
+
try:
|
|
89
|
+
task_arn = self.run_database_copy_task(
|
|
90
|
+
env_session, env, vpc_config, is_dump, db_connection_string
|
|
91
|
+
)
|
|
92
|
+
except Exception as exc:
|
|
93
|
+
self.abort_fn(f"{exc} (Account id: {self.account_id(env)})")
|
|
94
|
+
|
|
95
|
+
if is_dump:
|
|
96
|
+
message = f"Dumping {self.database} from the {env} environment into S3"
|
|
97
|
+
else:
|
|
98
|
+
message = f"Loading data into {self.database} in the {env} environment from S3"
|
|
99
|
+
|
|
100
|
+
self.echo_fn(message, fg="white", bold=True)
|
|
101
|
+
self.echo_fn(
|
|
102
|
+
f"Task {task_arn} started. Waiting for it to complete (this may take some time)...",
|
|
103
|
+
fg="white",
|
|
104
|
+
)
|
|
105
|
+
self.tail_logs(is_dump, env)
|
|
106
|
+
|
|
107
|
+
def enrich_vpc_name(self, env, vpc_name):
|
|
108
|
+
if not vpc_name:
|
|
109
|
+
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
|
+
)
|
|
113
|
+
config = load_and_validate_platform_config(disable_aws_validation=True)
|
|
114
|
+
vpc_name = config.get("environments", {}).get(env, {}).get("vpc")
|
|
115
|
+
return vpc_name
|
|
116
|
+
|
|
117
|
+
def run_database_copy_task(
|
|
118
|
+
self,
|
|
119
|
+
session: boto3.session.Session,
|
|
120
|
+
env: str,
|
|
121
|
+
vpc_config: Vpc,
|
|
122
|
+
is_dump: bool,
|
|
123
|
+
db_connection_string: str,
|
|
124
|
+
) -> str:
|
|
125
|
+
client = session.client("ecs")
|
|
126
|
+
action = "dump" if is_dump else "load"
|
|
127
|
+
env_vars = [
|
|
128
|
+
{"name": "DATA_COPY_OPERATION", "value": action.upper()},
|
|
129
|
+
{"name": "DB_CONNECTION_STRING", "value": db_connection_string},
|
|
130
|
+
]
|
|
131
|
+
if not is_dump:
|
|
132
|
+
env_vars.append({"name": "ECS_CLUSTER", "value": f"{self.app}-{env}"})
|
|
133
|
+
|
|
134
|
+
response = client.run_task(
|
|
135
|
+
taskDefinition=f"arn:aws:ecs:eu-west-2:{self.account_id(env)}:task-definition/{self.app}-{env}-{self.database}-{action}",
|
|
136
|
+
cluster=f"{self.app}-{env}",
|
|
137
|
+
capacityProviderStrategy=[
|
|
138
|
+
{"capacityProvider": "FARGATE", "weight": 1, "base": 0},
|
|
139
|
+
],
|
|
140
|
+
networkConfiguration={
|
|
141
|
+
"awsvpcConfiguration": {
|
|
142
|
+
"subnets": vpc_config.subnets,
|
|
143
|
+
"securityGroups": vpc_config.security_groups,
|
|
144
|
+
"assignPublicIp": "DISABLED",
|
|
145
|
+
}
|
|
146
|
+
},
|
|
147
|
+
overrides={
|
|
148
|
+
"containerOverrides": [
|
|
149
|
+
{
|
|
150
|
+
"name": f"{self.app}-{env}-{self.database}-{action}",
|
|
151
|
+
"environment": env_vars,
|
|
152
|
+
}
|
|
153
|
+
]
|
|
154
|
+
},
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
return response.get("tasks", [{}])[0].get("taskArn")
|
|
158
|
+
|
|
159
|
+
def dump(self, env: str, vpc_name: str):
|
|
160
|
+
self._execute_operation(True, env, vpc_name)
|
|
161
|
+
|
|
162
|
+
def load(self, env: str, vpc_name: str):
|
|
163
|
+
if self.is_confirmed_ready_to_load(env):
|
|
164
|
+
self._execute_operation(False, env, vpc_name)
|
|
165
|
+
|
|
166
|
+
def copy(
|
|
167
|
+
self,
|
|
168
|
+
from_env: str,
|
|
169
|
+
to_env: str,
|
|
170
|
+
from_vpc: str,
|
|
171
|
+
to_vpc: str,
|
|
172
|
+
services: tuple[str],
|
|
173
|
+
template: str,
|
|
174
|
+
no_maintenance_page: bool = False,
|
|
175
|
+
):
|
|
176
|
+
to_vpc = self.enrich_vpc_name(to_env, to_vpc)
|
|
177
|
+
if not no_maintenance_page:
|
|
178
|
+
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)
|
|
181
|
+
if not no_maintenance_page:
|
|
182
|
+
self.maintenance_page_provider.deactivate(self.app, to_env)
|
|
183
|
+
|
|
184
|
+
def is_confirmed_ready_to_load(self, env: str) -> bool:
|
|
185
|
+
if self.auto_approve:
|
|
186
|
+
return True
|
|
187
|
+
|
|
188
|
+
user_input = self.input_fn(
|
|
189
|
+
f"\nWARNING: the load operation is destructive and will delete the {self.database} database in the {env} environment. Continue? (y/n)"
|
|
190
|
+
)
|
|
191
|
+
return user_input.lower().strip() in ["y", "yes"]
|
|
192
|
+
|
|
193
|
+
def tail_logs(self, is_dump: bool, env: str):
|
|
194
|
+
action = "dump" if is_dump else "load"
|
|
195
|
+
log_group_name = f"/ecs/{self.app}-{env}-{self.database}-{action}"
|
|
196
|
+
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")
|
|
198
|
+
session = self.application.environments[env].session
|
|
199
|
+
response = session.client("logs").start_live_tail(logGroupIdentifiers=[log_group_arn])
|
|
200
|
+
|
|
201
|
+
stopped = False
|
|
202
|
+
for data in response["responseStream"]:
|
|
203
|
+
if stopped:
|
|
204
|
+
break
|
|
205
|
+
results = data.get("sessionUpdate", {}).get("sessionResults", [])
|
|
206
|
+
for result in results:
|
|
207
|
+
message = result.get("message")
|
|
208
|
+
|
|
209
|
+
if message:
|
|
210
|
+
match = re.match(r"(Stopping|Aborting) data (load|dump).*", message)
|
|
211
|
+
if match:
|
|
212
|
+
if match.group(1) == "Aborting":
|
|
213
|
+
self.abort_fn("Task aborted abnormally. See logs above for details.")
|
|
214
|
+
stopped = True
|
|
215
|
+
self.echo_fn(message)
|
|
216
|
+
|
|
217
|
+
def account_id(self, env):
|
|
218
|
+
envs = self.application.environments
|
|
219
|
+
if env in envs:
|
|
220
|
+
return envs.get(env).account_id
|
|
@@ -0,0 +1,459 @@
|
|
|
1
|
+
import itertools
|
|
2
|
+
import random
|
|
3
|
+
import re
|
|
4
|
+
import string
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import List
|
|
7
|
+
from typing import Union
|
|
8
|
+
|
|
9
|
+
import boto3
|
|
10
|
+
import click
|
|
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
|
|
15
|
+
from dbt_platform_helper.providers.load_balancers import find_https_listener
|
|
16
|
+
from dbt_platform_helper.utils.application import Environment
|
|
17
|
+
from dbt_platform_helper.utils.application import Service
|
|
18
|
+
from dbt_platform_helper.utils.application import load_application
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class MaintenancePageProvider:
|
|
22
|
+
def activate(self, app, env, svc, template, vpc):
|
|
23
|
+
application = load_application(app)
|
|
24
|
+
application_environment = get_app_environment(app, env)
|
|
25
|
+
|
|
26
|
+
if "*" in svc:
|
|
27
|
+
services = [
|
|
28
|
+
s for s in application.services.values() if s.kind == "Load Balanced Web Service"
|
|
29
|
+
]
|
|
30
|
+
else:
|
|
31
|
+
all_services = [get_app_service(app, s) for s in list(svc)]
|
|
32
|
+
services = [s for s in all_services if s.kind == "Load Balanced Web Service"]
|
|
33
|
+
|
|
34
|
+
if not services:
|
|
35
|
+
click.secho(f"No services deployed yet to {app} environment {env}", fg="red")
|
|
36
|
+
raise click.Abort
|
|
37
|
+
|
|
38
|
+
try:
|
|
39
|
+
https_listener = find_https_listener(application_environment.session, app, env)
|
|
40
|
+
current_maintenance_page = get_maintenance_page(
|
|
41
|
+
application_environment.session, https_listener
|
|
42
|
+
)
|
|
43
|
+
remove_current_maintenance_page = False
|
|
44
|
+
if current_maintenance_page:
|
|
45
|
+
remove_current_maintenance_page = click.confirm(
|
|
46
|
+
f"There is currently a '{current_maintenance_page}' maintenance page for the {env} "
|
|
47
|
+
f"environment in {app}.\nWould you like to replace it with a '{template}' "
|
|
48
|
+
f"maintenance page?"
|
|
49
|
+
)
|
|
50
|
+
if not remove_current_maintenance_page:
|
|
51
|
+
raise click.Abort
|
|
52
|
+
|
|
53
|
+
if remove_current_maintenance_page or click.confirm(
|
|
54
|
+
f"You are about to enable the '{template}' maintenance page for the {env} "
|
|
55
|
+
f"environment in {app}.\nWould you like to continue?"
|
|
56
|
+
):
|
|
57
|
+
if current_maintenance_page and remove_current_maintenance_page:
|
|
58
|
+
remove_maintenance_page(application_environment.session, https_listener)
|
|
59
|
+
|
|
60
|
+
allowed_ips = get_env_ips(vpc, application_environment)
|
|
61
|
+
|
|
62
|
+
add_maintenance_page(
|
|
63
|
+
application_environment.session,
|
|
64
|
+
https_listener,
|
|
65
|
+
app,
|
|
66
|
+
env,
|
|
67
|
+
services,
|
|
68
|
+
allowed_ips,
|
|
69
|
+
template,
|
|
70
|
+
)
|
|
71
|
+
click.secho(
|
|
72
|
+
f"Maintenance page '{template}' added for environment {env} in application {app}",
|
|
73
|
+
fg="green",
|
|
74
|
+
)
|
|
75
|
+
else:
|
|
76
|
+
raise click.Abort
|
|
77
|
+
|
|
78
|
+
except LoadBalancerNotFoundError:
|
|
79
|
+
click.secho(
|
|
80
|
+
f"No load balancer found for environment {env} in the application {app}.", fg="red"
|
|
81
|
+
)
|
|
82
|
+
raise click.Abort
|
|
83
|
+
|
|
84
|
+
except ListenerNotFoundError:
|
|
85
|
+
click.secho(
|
|
86
|
+
f"No HTTPS listener found for environment {env} in the application {app}.", fg="red"
|
|
87
|
+
)
|
|
88
|
+
raise click.Abort
|
|
89
|
+
|
|
90
|
+
def deactivate(self, app, env):
|
|
91
|
+
application_environment = get_app_environment(app, env)
|
|
92
|
+
|
|
93
|
+
try:
|
|
94
|
+
https_listener = find_https_listener(application_environment.session, app, env)
|
|
95
|
+
current_maintenance_page = get_maintenance_page(
|
|
96
|
+
application_environment.session, https_listener
|
|
97
|
+
)
|
|
98
|
+
if not current_maintenance_page:
|
|
99
|
+
click.secho("There is no current maintenance page to remove", fg="red")
|
|
100
|
+
raise click.Abort
|
|
101
|
+
|
|
102
|
+
if not click.confirm(
|
|
103
|
+
f"There is currently a '{current_maintenance_page}' maintenance page, "
|
|
104
|
+
f"would you like to remove it?"
|
|
105
|
+
):
|
|
106
|
+
raise click.Abort
|
|
107
|
+
|
|
108
|
+
remove_maintenance_page(application_environment.session, https_listener)
|
|
109
|
+
click.secho(
|
|
110
|
+
f"Maintenance page removed from environment {env} in application {app}", fg="green"
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
except LoadBalancerNotFoundError:
|
|
114
|
+
click.secho(
|
|
115
|
+
f"No load balancer found for environment {env} in the application {app}.", fg="red"
|
|
116
|
+
)
|
|
117
|
+
raise click.Abort
|
|
118
|
+
|
|
119
|
+
except ListenerNotFoundError:
|
|
120
|
+
click.secho(
|
|
121
|
+
f"No HTTPS listener found for environment {env} in the application {app}.", fg="red"
|
|
122
|
+
)
|
|
123
|
+
raise click.Abort
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def get_app_service(app_name: str, svc_name: str) -> Service:
|
|
127
|
+
application = load_application(app_name)
|
|
128
|
+
application_service = application.services.get(svc_name)
|
|
129
|
+
|
|
130
|
+
if not application_service:
|
|
131
|
+
click.secho(
|
|
132
|
+
f"The service {svc_name} was not found in the application {app_name}. "
|
|
133
|
+
f"It either does not exist, or has not been deployed.",
|
|
134
|
+
fg="red",
|
|
135
|
+
)
|
|
136
|
+
raise click.Abort
|
|
137
|
+
|
|
138
|
+
return application_service
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def get_app_environment(app_name: str, env_name: str) -> Environment:
|
|
142
|
+
application = load_application(app_name)
|
|
143
|
+
application_environment = application.environments.get(env_name)
|
|
144
|
+
|
|
145
|
+
if not application_environment:
|
|
146
|
+
click.secho(
|
|
147
|
+
f"The environment {env_name} was not found in the application {app_name}. "
|
|
148
|
+
f"It either does not exist, or has not been deployed.",
|
|
149
|
+
fg="red",
|
|
150
|
+
)
|
|
151
|
+
raise click.Abort
|
|
152
|
+
|
|
153
|
+
return application_environment
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def get_maintenance_page(session: boto3.Session, listener_arn: str) -> Union[str, None]:
|
|
157
|
+
lb_client = session.client("elbv2")
|
|
158
|
+
|
|
159
|
+
rules = lb_client.describe_rules(ListenerArn=listener_arn)["Rules"]
|
|
160
|
+
tag_descriptions = get_rules_tag_descriptions(rules, lb_client)
|
|
161
|
+
|
|
162
|
+
maintenance_page_type = None
|
|
163
|
+
for description in tag_descriptions:
|
|
164
|
+
tags = {t["Key"]: t["Value"] for t in description["Tags"]}
|
|
165
|
+
if tags.get("name") == "MaintenancePage":
|
|
166
|
+
maintenance_page_type = tags.get("type")
|
|
167
|
+
|
|
168
|
+
return maintenance_page_type
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def remove_maintenance_page(session: boto3.Session, listener_arn: str):
|
|
172
|
+
lb_client = session.client("elbv2")
|
|
173
|
+
|
|
174
|
+
rules = lb_client.describe_rules(ListenerArn=listener_arn)["Rules"]
|
|
175
|
+
# TODO: The next line doesn't appear to do anything.
|
|
176
|
+
tag_descriptions = get_rules_tag_descriptions(rules, lb_client)
|
|
177
|
+
# TODO: In fact the following line seems to do the same but better.
|
|
178
|
+
tag_descriptions = lb_client.describe_tags(ResourceArns=[r["RuleArn"] for r in rules])[
|
|
179
|
+
"TagDescriptions"
|
|
180
|
+
]
|
|
181
|
+
|
|
182
|
+
for name in ["MaintenancePage", "AllowedIps", "BypassIpFilter", "AllowedSourceIps"]:
|
|
183
|
+
deleted = delete_listener_rule(tag_descriptions, name, lb_client)
|
|
184
|
+
|
|
185
|
+
if name == "MaintenancePage" and not deleted:
|
|
186
|
+
raise ListenerRuleNotFoundError()
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def get_rules_tag_descriptions(rules: list, lb_client):
|
|
190
|
+
tag_descriptions = []
|
|
191
|
+
chunk_size = 20
|
|
192
|
+
|
|
193
|
+
for i in range(0, len(rules), chunk_size):
|
|
194
|
+
chunk = rules[i : i + chunk_size]
|
|
195
|
+
resource_arns = [r["RuleArn"] for r in chunk]
|
|
196
|
+
response = lb_client.describe_tags(ResourceArns=resource_arns)
|
|
197
|
+
tag_descriptions.extend(response["TagDescriptions"])
|
|
198
|
+
|
|
199
|
+
return tag_descriptions
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def delete_listener_rule(tag_descriptions: list, tag_name: str, lb_client: boto3.client):
|
|
203
|
+
current_rule_arn = None
|
|
204
|
+
|
|
205
|
+
for description in tag_descriptions:
|
|
206
|
+
tags = {t["Key"]: t["Value"] for t in description["Tags"]}
|
|
207
|
+
if tags.get("name") == tag_name:
|
|
208
|
+
current_rule_arn = description["ResourceArn"]
|
|
209
|
+
if current_rule_arn:
|
|
210
|
+
lb_client.delete_rule(RuleArn=current_rule_arn)
|
|
211
|
+
|
|
212
|
+
return current_rule_arn
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def add_maintenance_page(
|
|
216
|
+
session: boto3.Session,
|
|
217
|
+
listener_arn: str,
|
|
218
|
+
app: str,
|
|
219
|
+
env: str,
|
|
220
|
+
services: List[Service],
|
|
221
|
+
allowed_ips: tuple,
|
|
222
|
+
template: str = "default",
|
|
223
|
+
):
|
|
224
|
+
lb_client = session.client("elbv2")
|
|
225
|
+
maintenance_page_content = get_maintenance_page_template(template)
|
|
226
|
+
bypass_value = "".join(random.choices(string.ascii_lowercase + string.digits, k=12))
|
|
227
|
+
|
|
228
|
+
rule_priority = itertools.count(start=1)
|
|
229
|
+
|
|
230
|
+
for svc in services:
|
|
231
|
+
target_group_arn = find_target_group(app, env, svc.name, session)
|
|
232
|
+
|
|
233
|
+
# not all of an application's services are guaranteed to have been deployed to an environment
|
|
234
|
+
if not target_group_arn:
|
|
235
|
+
continue
|
|
236
|
+
|
|
237
|
+
for ip in allowed_ips:
|
|
238
|
+
create_header_rule(
|
|
239
|
+
lb_client,
|
|
240
|
+
listener_arn,
|
|
241
|
+
target_group_arn,
|
|
242
|
+
"X-Forwarded-For",
|
|
243
|
+
[ip],
|
|
244
|
+
"AllowedIps",
|
|
245
|
+
next(rule_priority),
|
|
246
|
+
)
|
|
247
|
+
create_source_ip_rule(
|
|
248
|
+
lb_client,
|
|
249
|
+
listener_arn,
|
|
250
|
+
target_group_arn,
|
|
251
|
+
[ip],
|
|
252
|
+
"AllowedSourceIps",
|
|
253
|
+
next(rule_priority),
|
|
254
|
+
)
|
|
255
|
+
|
|
256
|
+
create_header_rule(
|
|
257
|
+
lb_client,
|
|
258
|
+
listener_arn,
|
|
259
|
+
target_group_arn,
|
|
260
|
+
"Bypass-Key",
|
|
261
|
+
[bypass_value],
|
|
262
|
+
"BypassIpFilter",
|
|
263
|
+
next(rule_priority),
|
|
264
|
+
)
|
|
265
|
+
|
|
266
|
+
click.secho(
|
|
267
|
+
f"\nUse a browser plugin to add `Bypass-Key` header with value {bypass_value} to your requests. For more detail, visit https://platform.readme.trade.gov.uk/activities/holding-and-maintenance-pages/",
|
|
268
|
+
fg="green",
|
|
269
|
+
)
|
|
270
|
+
|
|
271
|
+
lb_client.create_rule(
|
|
272
|
+
ListenerArn=listener_arn,
|
|
273
|
+
Priority=next(rule_priority),
|
|
274
|
+
Conditions=[
|
|
275
|
+
{
|
|
276
|
+
"Field": "path-pattern",
|
|
277
|
+
"PathPatternConfig": {"Values": ["/*"]},
|
|
278
|
+
}
|
|
279
|
+
],
|
|
280
|
+
Actions=[
|
|
281
|
+
{
|
|
282
|
+
"Type": "fixed-response",
|
|
283
|
+
"FixedResponseConfig": {
|
|
284
|
+
"StatusCode": "503",
|
|
285
|
+
"ContentType": "text/html",
|
|
286
|
+
"MessageBody": maintenance_page_content,
|
|
287
|
+
},
|
|
288
|
+
}
|
|
289
|
+
],
|
|
290
|
+
Tags=[
|
|
291
|
+
{"Key": "name", "Value": "MaintenancePage"},
|
|
292
|
+
{"Key": "type", "Value": template},
|
|
293
|
+
],
|
|
294
|
+
)
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def get_maintenance_page_template(template) -> str:
|
|
298
|
+
template_contents = (
|
|
299
|
+
Path(__file__)
|
|
300
|
+
.parent.parent.joinpath(
|
|
301
|
+
f"templates/svc/maintenance_pages/{template}.html",
|
|
302
|
+
)
|
|
303
|
+
.read_text()
|
|
304
|
+
.replace("\n", "")
|
|
305
|
+
)
|
|
306
|
+
|
|
307
|
+
# [^\S]\s+ - Remove any space that is not preceded by a non-space character.
|
|
308
|
+
return re.sub(r"[^\S]\s+", "", template_contents)
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
def find_target_group(app: str, env: str, svc: str, session: boto3.Session) -> str:
|
|
312
|
+
rg_tagging_client = session.client("resourcegroupstaggingapi")
|
|
313
|
+
response = rg_tagging_client.get_resources(
|
|
314
|
+
TagFilters=[
|
|
315
|
+
{
|
|
316
|
+
"Key": "copilot-application",
|
|
317
|
+
"Values": [
|
|
318
|
+
app,
|
|
319
|
+
],
|
|
320
|
+
"Key": "copilot-environment",
|
|
321
|
+
"Values": [
|
|
322
|
+
env,
|
|
323
|
+
],
|
|
324
|
+
"Key": "copilot-service",
|
|
325
|
+
"Values": [
|
|
326
|
+
svc,
|
|
327
|
+
],
|
|
328
|
+
},
|
|
329
|
+
],
|
|
330
|
+
ResourceTypeFilters=[
|
|
331
|
+
"elasticloadbalancing:targetgroup",
|
|
332
|
+
],
|
|
333
|
+
)
|
|
334
|
+
for resource in response["ResourceTagMappingList"]:
|
|
335
|
+
tags = {tag["Key"]: tag["Value"] for tag in resource["Tags"]}
|
|
336
|
+
|
|
337
|
+
if (
|
|
338
|
+
"copilot-service" in tags
|
|
339
|
+
and tags["copilot-service"] == svc
|
|
340
|
+
and "copilot-environment" in tags
|
|
341
|
+
and tags["copilot-environment"] == env
|
|
342
|
+
and "copilot-application" in tags
|
|
343
|
+
and tags["copilot-application"] == app
|
|
344
|
+
):
|
|
345
|
+
return resource["ResourceARN"]
|
|
346
|
+
|
|
347
|
+
click.secho(
|
|
348
|
+
f"No target group found for application: {app}, environment: {env}, service: {svc}",
|
|
349
|
+
fg="red",
|
|
350
|
+
)
|
|
351
|
+
|
|
352
|
+
return None
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
def create_header_rule(
|
|
356
|
+
lb_client: boto3.client,
|
|
357
|
+
listener_arn: str,
|
|
358
|
+
target_group_arn: str,
|
|
359
|
+
header_name: str,
|
|
360
|
+
values: list,
|
|
361
|
+
rule_name: str,
|
|
362
|
+
priority: int,
|
|
363
|
+
):
|
|
364
|
+
conditions = get_host_conditions(lb_client, listener_arn, target_group_arn)
|
|
365
|
+
|
|
366
|
+
# add new condition to existing conditions
|
|
367
|
+
combined_conditions = [
|
|
368
|
+
{
|
|
369
|
+
"Field": "http-header",
|
|
370
|
+
"HttpHeaderConfig": {"HttpHeaderName": header_name, "Values": values},
|
|
371
|
+
}
|
|
372
|
+
] + conditions
|
|
373
|
+
|
|
374
|
+
lb_client.create_rule(
|
|
375
|
+
ListenerArn=listener_arn,
|
|
376
|
+
Priority=priority,
|
|
377
|
+
Conditions=combined_conditions,
|
|
378
|
+
Actions=[{"Type": "forward", "TargetGroupArn": target_group_arn}],
|
|
379
|
+
Tags=[
|
|
380
|
+
{"Key": "name", "Value": rule_name},
|
|
381
|
+
],
|
|
382
|
+
)
|
|
383
|
+
|
|
384
|
+
click.secho(
|
|
385
|
+
f"Creating listener rule {rule_name} for HTTPS Listener with arn {listener_arn}.\n\nIf request header {header_name} contains one of the values {values}, the request will be forwarded to target group with arn {target_group_arn}.",
|
|
386
|
+
fg="green",
|
|
387
|
+
)
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
def create_source_ip_rule(
|
|
391
|
+
lb_client: boto3.client,
|
|
392
|
+
listener_arn: str,
|
|
393
|
+
target_group_arn: str,
|
|
394
|
+
values: list,
|
|
395
|
+
rule_name: str,
|
|
396
|
+
priority: int,
|
|
397
|
+
):
|
|
398
|
+
conditions = get_host_conditions(lb_client, listener_arn, target_group_arn)
|
|
399
|
+
|
|
400
|
+
# add new condition to existing conditions
|
|
401
|
+
combined_conditions = [
|
|
402
|
+
{
|
|
403
|
+
"Field": "source-ip",
|
|
404
|
+
"SourceIpConfig": {"Values": [value + "/32" for value in values]},
|
|
405
|
+
}
|
|
406
|
+
] + conditions
|
|
407
|
+
|
|
408
|
+
lb_client.create_rule(
|
|
409
|
+
ListenerArn=listener_arn,
|
|
410
|
+
Priority=priority,
|
|
411
|
+
Conditions=combined_conditions,
|
|
412
|
+
Actions=[{"Type": "forward", "TargetGroupArn": target_group_arn}],
|
|
413
|
+
Tags=[
|
|
414
|
+
{"Key": "name", "Value": rule_name},
|
|
415
|
+
],
|
|
416
|
+
)
|
|
417
|
+
|
|
418
|
+
click.secho(
|
|
419
|
+
f"Creating listener rule {rule_name} for HTTPS Listener with arn {listener_arn}.\n\nIf request source ip matches one of the values {values}, the request will be forwarded to target group with arn {target_group_arn}.",
|
|
420
|
+
fg="green",
|
|
421
|
+
)
|
|
422
|
+
|
|
423
|
+
|
|
424
|
+
def get_host_conditions(lb_client: boto3.client, listener_arn: str, target_group_arn: str):
|
|
425
|
+
rules = lb_client.describe_rules(ListenerArn=listener_arn)["Rules"]
|
|
426
|
+
|
|
427
|
+
# Get current set of forwarding conditions for the target group
|
|
428
|
+
for rule in rules:
|
|
429
|
+
for action in rule["Actions"]:
|
|
430
|
+
if action["Type"] == "forward" and action["TargetGroupArn"] == target_group_arn:
|
|
431
|
+
conditions = rule["Conditions"]
|
|
432
|
+
|
|
433
|
+
# filter to host-header conditions
|
|
434
|
+
conditions = [
|
|
435
|
+
{i: condition[i] for i in condition if i != "Values"}
|
|
436
|
+
for condition in conditions
|
|
437
|
+
if condition["Field"] == "host-header"
|
|
438
|
+
]
|
|
439
|
+
|
|
440
|
+
# remove internal hosts
|
|
441
|
+
conditions[0]["HostHeaderConfig"]["Values"] = [
|
|
442
|
+
v for v in conditions[0]["HostHeaderConfig"]["Values"]
|
|
443
|
+
]
|
|
444
|
+
|
|
445
|
+
return conditions
|
|
446
|
+
|
|
447
|
+
|
|
448
|
+
def get_env_ips(vpc: str, application_environment: Environment) -> List[str]:
|
|
449
|
+
account_name = f"{application_environment.session.profile_name}-vpc"
|
|
450
|
+
vpc_name = vpc if vpc else account_name
|
|
451
|
+
ssm_client = application_environment.session.client("ssm")
|
|
452
|
+
|
|
453
|
+
try:
|
|
454
|
+
param_value = ssm_client.get_parameter(Name=f"/{vpc_name}/EGRESS_IPS")["Parameter"]["Value"]
|
|
455
|
+
except ssm_client.exceptions.ParameterNotFound:
|
|
456
|
+
click.secho(f"No parameter found with name: /{vpc_name}/EGRESS_IPS")
|
|
457
|
+
raise click.Abort
|
|
458
|
+
|
|
459
|
+
return [ip.strip() for ip in param_value.split(",")]
|