pdt-cli 0.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.
- pdt/__init__.py +6 -0
- pdt/cli.py +177 -0
- pdt/config.py +394 -0
- pdt/deploy.py +106 -0
- pdt/deploy_aws.py +613 -0
- pdt/deploy_aws_fargate.py +449 -0
- pdt/deploy_aws_lambda.py +359 -0
- pdt/deploy_azure.py +531 -0
- pdt/deploy_azure_container_apps.py +328 -0
- pdt/deploy_azure_functions.py +307 -0
- pdt/deploy_common.py +94 -0
- pdt/deploy_google_cloud.py +756 -0
- pdt/deploy_windows.py +376 -0
- pdt/examples/hello-world/config.yml +20 -0
- pdt/examples/hello-world/run.py +41 -0
- pdt/examples/impossible-travel-report/config.yml +48 -0
- pdt/examples/impossible-travel-report/env.template +57 -0
- pdt/examples/impossible-travel-report/run.py +232 -0
- pdt/examples/monday-orphaned-account-report/config.yml +47 -0
- pdt/examples/monday-orphaned-account-report/env.template +57 -0
- pdt/examples/monday-orphaned-account-report/run.py +333 -0
- pdt/gcloud_sdk.py +170 -0
- pdt/scaffold.py +278 -0
- pdt/utils/__init__.py +0 -0
- pdt/utils/email_auth.py +406 -0
- pdt/utils/entra.py +114 -0
- pdt/utils/log.py +34 -0
- pdt/utils/send_email.py +467 -0
- pdt/utils/send_email_graph.py +266 -0
- pdt/utils/web.py +28 -0
- pdt_cli-0.1.0.dist-info/METADATA +325 -0
- pdt_cli-0.1.0.dist-info/RECORD +34 -0
- pdt_cli-0.1.0.dist-info/WHEEL +4 -0
- pdt_cli-0.1.0.dist-info/entry_points.txt +2 -0
|
@@ -0,0 +1,449 @@
|
|
|
1
|
+
"""Deploy an app as an ECS Fargate task run by EventBridge Scheduler.
|
|
2
|
+
|
|
3
|
+
Selected with platform.runtime: fargate. Entered through deploy_aws.py,
|
|
4
|
+
which owns the uv script header, login, and permission handling.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import base64
|
|
10
|
+
import json
|
|
11
|
+
import shutil
|
|
12
|
+
import subprocess
|
|
13
|
+
|
|
14
|
+
from pdt import config
|
|
15
|
+
from pdt.deploy import confirm
|
|
16
|
+
from pdt.deploy_aws import (
|
|
17
|
+
COMMON_ACTIONS, SCHEDULE_GROUP, aws_schedule_expression,
|
|
18
|
+
aws_settings, clients_for, cost_estimate_lines, delete_log_group, delete_role,
|
|
19
|
+
delete_secret, list_price, recent_stream_seconds, run_basis,
|
|
20
|
+
ensure_log_group, ensure_role, ensure_schedule, ensure_secret,
|
|
21
|
+
ensure_session, has_managed_tag, iam_tags, not_found,
|
|
22
|
+
delete_schedule_group, other_schedules, preflight, resource_exists,
|
|
23
|
+
with_role_propagation_retry,
|
|
24
|
+
)
|
|
25
|
+
from pdt.deploy_common import DOCKERFILE, fail, gather_secrets, stage_build_context
|
|
26
|
+
|
|
27
|
+
CLUSTER = "pdt"
|
|
28
|
+
REPOSITORY = "pdt"
|
|
29
|
+
TASK_CPU = "256"
|
|
30
|
+
TASK_MEMORY = "512"
|
|
31
|
+
TASK_ARCHITECTURE = "ARM64"
|
|
32
|
+
DOCKER_PLATFORM = "linux/arm64"
|
|
33
|
+
FARGATE_MIN_SECONDS = 60
|
|
34
|
+
FARGATE_ACTIONS = [
|
|
35
|
+
"ec2:DescribeSecurityGroups",
|
|
36
|
+
"ec2:DescribeSubnets",
|
|
37
|
+
"ec2:DescribeVpcs",
|
|
38
|
+
"ecr:BatchCheckLayerAvailability",
|
|
39
|
+
"ecr:BatchDeleteImage",
|
|
40
|
+
"ecr:CompleteLayerUpload",
|
|
41
|
+
"ecr:CreateRepository",
|
|
42
|
+
"ecr:DescribeImages",
|
|
43
|
+
"ecr:DeleteRepository",
|
|
44
|
+
"ecr:DescribeRepositories",
|
|
45
|
+
"ecr:GetAuthorizationToken",
|
|
46
|
+
"ecr:InitiateLayerUpload",
|
|
47
|
+
"ecr:ListImages",
|
|
48
|
+
"ecr:PutImage",
|
|
49
|
+
"ecr:UploadLayerPart",
|
|
50
|
+
"ecs:CreateCluster",
|
|
51
|
+
"ecs:DeleteCluster",
|
|
52
|
+
"ecs:DeregisterTaskDefinition",
|
|
53
|
+
"ecs:DescribeClusters",
|
|
54
|
+
"ecs:DescribeTaskDefinition",
|
|
55
|
+
"ecs:ListTagsForResource",
|
|
56
|
+
"ecs:ListTaskDefinitionFamilies",
|
|
57
|
+
"ecs:ListTaskDefinitions",
|
|
58
|
+
"ecs:ListTasks",
|
|
59
|
+
"ecs:RegisterTaskDefinition",
|
|
60
|
+
"ecs:TagResource",
|
|
61
|
+
]
|
|
62
|
+
DEPLOYER_ACTIONS = sorted(COMMON_ACTIONS + FARGATE_ACTIONS)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def resource_names(app_name: str) -> dict[str, str]:
|
|
66
|
+
base = f"pdt-{app_name}"
|
|
67
|
+
return {
|
|
68
|
+
"family": base,
|
|
69
|
+
"schedule": base,
|
|
70
|
+
"secret": f"{base}-env",
|
|
71
|
+
"log_group": f"/pdt/{app_name}",
|
|
72
|
+
"execution_role": f"{base}-execution",
|
|
73
|
+
"task_role": f"{base}-task",
|
|
74
|
+
"scheduler_role": f"{base}-scheduler",
|
|
75
|
+
"image_tag": f"{app_name}-latest",
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def tags_list(extra: dict[str, str] | None = None) -> list[dict[str, str]]:
|
|
80
|
+
return [{"key": tag["Key"], "value": tag["Value"]} for tag in iam_tags(extra)]
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def docker_preflight() -> None:
|
|
84
|
+
if not shutil.which("docker"):
|
|
85
|
+
fail("Docker is required for platform.runtime: fargate; install Docker Desktop "
|
|
86
|
+
"or set platform.runtime: lambda")
|
|
87
|
+
proc = subprocess.run(["docker", "info"], capture_output=True, text=True, check=False)
|
|
88
|
+
if proc.returncode:
|
|
89
|
+
fail("Docker is installed but not running; start Docker and run the same command again")
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def default_network(ec2) -> tuple[list[str], str]:
|
|
93
|
+
vpcs = ec2.describe_vpcs(Filters=[{"Name": "is-default", "Values": ["true"]}])["Vpcs"]
|
|
94
|
+
if not vpcs:
|
|
95
|
+
fail("this account/region has no default VPC; custom VPC configuration is not supported yet")
|
|
96
|
+
vpc_id = vpcs[0]["VpcId"]
|
|
97
|
+
subnets = ec2.describe_subnets(Filters=[
|
|
98
|
+
{"Name": "vpc-id", "Values": [vpc_id]},
|
|
99
|
+
{"Name": "state", "Values": ["available"]},
|
|
100
|
+
{"Name": "default-for-az", "Values": ["true"]},
|
|
101
|
+
])["Subnets"]
|
|
102
|
+
subnet_ids = sorted(subnet["SubnetId"] for subnet in subnets)
|
|
103
|
+
if not subnet_ids:
|
|
104
|
+
fail(f"default VPC {vpc_id} has no available default subnets")
|
|
105
|
+
groups = ec2.describe_security_groups(Filters=[
|
|
106
|
+
{"Name": "vpc-id", "Values": [vpc_id]},
|
|
107
|
+
{"Name": "group-name", "Values": ["default"]},
|
|
108
|
+
])["SecurityGroups"]
|
|
109
|
+
if not groups:
|
|
110
|
+
fail(f"default VPC {vpc_id} has no default security group")
|
|
111
|
+
return subnet_ids, groups[0]["GroupId"]
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def ensure_repository(ecr) -> str:
|
|
115
|
+
try:
|
|
116
|
+
repos = ecr.describe_repositories(repositoryNames=[REPOSITORY])["repositories"]
|
|
117
|
+
return repos[0]["repositoryUri"]
|
|
118
|
+
except Exception as exc:
|
|
119
|
+
if not not_found(exc):
|
|
120
|
+
raise
|
|
121
|
+
repo = ecr.create_repository(
|
|
122
|
+
repositoryName=REPOSITORY,
|
|
123
|
+
imageScanningConfiguration={"scanOnPush": True},
|
|
124
|
+
encryptionConfiguration={"encryptionType": "AES256"},
|
|
125
|
+
tags=iam_tags({"shared": "true"}),
|
|
126
|
+
)["repository"]
|
|
127
|
+
return repo["repositoryUri"]
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def ensure_cluster(ecs) -> str:
|
|
131
|
+
response = ecs.describe_clusters(clusters=[CLUSTER])
|
|
132
|
+
active = [item for item in response.get("clusters", []) if item.get("status") == "ACTIVE"]
|
|
133
|
+
if active:
|
|
134
|
+
return active[0]["clusterArn"]
|
|
135
|
+
created = ecs.create_cluster(
|
|
136
|
+
clusterName=CLUSTER,
|
|
137
|
+
capacityProviders=["FARGATE"],
|
|
138
|
+
tags=tags_list({"shared": "true"}),
|
|
139
|
+
)
|
|
140
|
+
return created["cluster"]["clusterArn"]
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def ensure_roles(iam, names: dict[str, str], account: str, region: str,
|
|
144
|
+
secret_arn: str) -> tuple[str, str, str]:
|
|
145
|
+
execution = ensure_role(
|
|
146
|
+
iam, names["execution_role"], "ecs-tasks.amazonaws.com", "pdt-execution", [
|
|
147
|
+
{"Effect": "Allow",
|
|
148
|
+
"Action": ["ecr:GetAuthorizationToken"], "Resource": "*"},
|
|
149
|
+
{"Effect": "Allow",
|
|
150
|
+
"Action": ["ecr:BatchCheckLayerAvailability", "ecr:GetDownloadUrlForLayer",
|
|
151
|
+
"ecr:BatchGetImage"],
|
|
152
|
+
"Resource": f"arn:aws:ecr:{region}:{account}:repository/{REPOSITORY}"},
|
|
153
|
+
{"Effect": "Allow",
|
|
154
|
+
"Action": ["logs:CreateLogStream", "logs:PutLogEvents"],
|
|
155
|
+
"Resource": f"arn:aws:logs:{region}:{account}:log-group:{names['log_group']}:*"},
|
|
156
|
+
{"Effect": "Allow", "Action": ["secretsmanager:GetSecretValue"],
|
|
157
|
+
"Resource": secret_arn},
|
|
158
|
+
])
|
|
159
|
+
# The task role carries no application permissions yet.
|
|
160
|
+
task = ensure_role(iam, names["task_role"], "ecs-tasks.amazonaws.com", "pdt-task", [])
|
|
161
|
+
task_definition = f"arn:aws:ecs:{region}:{account}:task-definition/{names['family']}:*"
|
|
162
|
+
scheduler = ensure_role(
|
|
163
|
+
iam, names["scheduler_role"], "scheduler.amazonaws.com", "pdt-scheduler", [
|
|
164
|
+
{"Effect": "Allow", "Action": ["ecs:RunTask"], "Resource": task_definition},
|
|
165
|
+
{"Effect": "Allow", "Action": ["iam:PassRole"],
|
|
166
|
+
"Resource": [execution, task]},
|
|
167
|
+
])
|
|
168
|
+
return execution, task, scheduler
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def desired_task(names: dict[str, str], image: str, region: str,
|
|
172
|
+
execution_role: str, task_role: str, secret_arn: str) -> dict:
|
|
173
|
+
return {
|
|
174
|
+
"family": names["family"],
|
|
175
|
+
"taskRoleArn": task_role,
|
|
176
|
+
"executionRoleArn": execution_role,
|
|
177
|
+
"networkMode": "awsvpc",
|
|
178
|
+
"requiresCompatibilities": ["FARGATE"],
|
|
179
|
+
"cpu": TASK_CPU,
|
|
180
|
+
"memory": TASK_MEMORY,
|
|
181
|
+
"runtimePlatform": {"cpuArchitecture": TASK_ARCHITECTURE,
|
|
182
|
+
"operatingSystemFamily": "LINUX"},
|
|
183
|
+
"containerDefinitions": [{
|
|
184
|
+
"name": names["family"],
|
|
185
|
+
"image": image,
|
|
186
|
+
"essential": True,
|
|
187
|
+
"secrets": [{"name": "PDT_ENV_JSON", "valueFrom": secret_arn}],
|
|
188
|
+
"logConfiguration": {
|
|
189
|
+
"logDriver": "awslogs",
|
|
190
|
+
"options": {
|
|
191
|
+
"awslogs-group": names["log_group"],
|
|
192
|
+
"awslogs-region": region,
|
|
193
|
+
"awslogs-stream-prefix": "ecs",
|
|
194
|
+
},
|
|
195
|
+
},
|
|
196
|
+
}],
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def normalized_task(task: dict) -> dict:
|
|
201
|
+
keys = ("family", "taskRoleArn", "executionRoleArn", "networkMode",
|
|
202
|
+
"requiresCompatibilities", "cpu", "memory", "runtimePlatform")
|
|
203
|
+
result = {key: task.get(key) for key in keys}
|
|
204
|
+
container_keys = ("name", "image", "essential", "secrets", "logConfiguration")
|
|
205
|
+
result["containerDefinitions"] = [
|
|
206
|
+
{key: container.get(key) for key in container_keys}
|
|
207
|
+
for container in task.get("containerDefinitions", [])
|
|
208
|
+
]
|
|
209
|
+
return result
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def ensure_task_definition(ecs, desired: dict, image_digest: str) -> str:
|
|
213
|
+
try:
|
|
214
|
+
current = ecs.describe_task_definition(
|
|
215
|
+
taskDefinition=desired["family"], include=["TAGS"])
|
|
216
|
+
definition = current["taskDefinition"]
|
|
217
|
+
if not has_managed_tag(current.get("tags", []), "key", "value"):
|
|
218
|
+
fail(f"ECS task family {desired['family']} exists but is not managed by PDT")
|
|
219
|
+
same_image = any(tag.get("key") == "image-digest" and tag.get("value") == image_digest
|
|
220
|
+
for tag in current.get("tags", []))
|
|
221
|
+
if same_image and normalized_task(definition) == normalized_task(desired):
|
|
222
|
+
return definition["taskDefinitionArn"]
|
|
223
|
+
except Exception as exc:
|
|
224
|
+
if not not_found(exc):
|
|
225
|
+
raise
|
|
226
|
+
registered = with_role_propagation_retry(
|
|
227
|
+
lambda: ecs.register_task_definition(
|
|
228
|
+
**desired, tags=tags_list({"image-digest": image_digest})))
|
|
229
|
+
return registered["taskDefinition"]["taskDefinitionArn"]
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def build_and_push(app: dict, image: str, ecr) -> str:
|
|
233
|
+
stage = stage_build_context(app)
|
|
234
|
+
try:
|
|
235
|
+
(stage / "Dockerfile").write_text(DOCKERFILE.format(app=app["name"]))
|
|
236
|
+
auth = ecr.get_authorization_token()["authorizationData"][0]
|
|
237
|
+
username, password = base64.b64decode(auth["authorizationToken"]).decode().split(":", 1)
|
|
238
|
+
registry = auth["proxyEndpoint"]
|
|
239
|
+
commands = [
|
|
240
|
+
(["docker", "login", "--username", username, "--password-stdin", registry], password),
|
|
241
|
+
(["docker", "build", "--platform", DOCKER_PLATFORM, "-t", image, str(stage)], None),
|
|
242
|
+
(["docker", "push", image], None),
|
|
243
|
+
]
|
|
244
|
+
for command, stdin in commands:
|
|
245
|
+
proc = subprocess.run(command, input=stdin, text=True, check=False)
|
|
246
|
+
if proc.returncode:
|
|
247
|
+
fail(f"{' '.join(command[:2])} failed")
|
|
248
|
+
finally:
|
|
249
|
+
shutil.rmtree(stage, ignore_errors=True)
|
|
250
|
+
images = ecr.describe_images(
|
|
251
|
+
repositoryName=REPOSITORY, imageIds=[{"imageTag": image.rsplit(":", 1)[1]}])
|
|
252
|
+
return images["imageDetails"][0]["imageDigest"]
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def cost_lines(logs, names: dict[str, str], region: str, cron: str,
|
|
256
|
+
schedule_exists: bool) -> list[str]:
|
|
257
|
+
print("Fetching list prices from the AWS price list...")
|
|
258
|
+
try:
|
|
259
|
+
runs = config.runs_per_month(cron)
|
|
260
|
+
seconds, basis = run_basis(
|
|
261
|
+
recent_stream_seconds(logs, names["log_group"]) if schedule_exists else None)
|
|
262
|
+
seconds = max(seconds, FARGATE_MIN_SECONDS)
|
|
263
|
+
vcpu = int(TASK_CPU) / 1024
|
|
264
|
+
gib = int(TASK_MEMORY) / 1024
|
|
265
|
+
vcpu_hour = list_price("AmazonECS", region, "Fargate-ARM-vCPU-Hours:perCPU")
|
|
266
|
+
gib_hour = list_price("AmazonECS", region, "Fargate-ARM-GB-Hours")
|
|
267
|
+
compute = runs * seconds / 3600 * (vcpu * vcpu_hour + gib * gib_hour)
|
|
268
|
+
secret = list_price("AWSSecretsManager", region, "AWSSecretsManager-Secrets")
|
|
269
|
+
items = [
|
|
270
|
+
(f"Fargate (arm64): ~{runs:.0f} runs x {basis} x {vcpu:g} vCPU / {gib:g} GiB", compute),
|
|
271
|
+
("Secrets Manager: 1 secret", secret),
|
|
272
|
+
]
|
|
273
|
+
except Exception as exc:
|
|
274
|
+
fail(f"could not calculate the required monthly cost estimate: {exc}")
|
|
275
|
+
return cost_estimate_lines(
|
|
276
|
+
region, items,
|
|
277
|
+
"excludes EventBridge Scheduler free tier, ECR storage, and CloudWatch Logs usage")
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def fargate_clients(session) -> dict:
|
|
281
|
+
clients = clients_for(session)
|
|
282
|
+
clients.update({name: session.client(name) for name in ("ec2", "ecr", "ecs")})
|
|
283
|
+
return clients
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def deploy(app: dict, assume_yes: bool, profile: str | None = None) -> int:
|
|
287
|
+
docker_preflight()
|
|
288
|
+
session = ensure_session(app, profile)
|
|
289
|
+
expected_account, region = aws_settings(app, session)
|
|
290
|
+
clients = fargate_clients(session)
|
|
291
|
+
account, _identity = preflight(
|
|
292
|
+
clients["sts"], clients["iam"], expected_account, DEPLOYER_ACTIONS)
|
|
293
|
+
names = resource_names(app["name"])
|
|
294
|
+
cron = config.cron_expression(app["schedule"])
|
|
295
|
+
expression = aws_schedule_expression(cron)
|
|
296
|
+
payload = json.dumps(gather_secrets(app), sort_keys=True)
|
|
297
|
+
image = f"{account}.dkr.ecr.{region}.amazonaws.com/{REPOSITORY}:{names['image_tag']}"
|
|
298
|
+
|
|
299
|
+
print(f"Checking current state in account {account} ({region})...")
|
|
300
|
+
subnets, security_group = default_network(clients["ec2"])
|
|
301
|
+
schedule_exists = resource_exists(
|
|
302
|
+
clients["scheduler"], "get_schedule",
|
|
303
|
+
Name=names["schedule"], GroupName=SCHEDULE_GROUP)
|
|
304
|
+
secret_exists = resource_exists(
|
|
305
|
+
clients["secretsmanager"], "describe_secret", SecretId=names["secret"])
|
|
306
|
+
actions = [
|
|
307
|
+
f"reconcile shared ECR repository {REPOSITORY} and ECS cluster {CLUSTER}",
|
|
308
|
+
f"build and push Docker image {image} ({DOCKER_PLATFORM})",
|
|
309
|
+
("update" if secret_exists else "create")
|
|
310
|
+
+ f" Secrets Manager secret {names['secret']}",
|
|
311
|
+
"reconcile the execution, task, and scheduler IAM roles",
|
|
312
|
+
f"reconcile Fargate task definition {names['family']} "
|
|
313
|
+
f"({int(TASK_CPU) / 1024:g} vCPU, {TASK_MEMORY} MiB, no time limit)",
|
|
314
|
+
("update" if schedule_exists else "create")
|
|
315
|
+
+ f" EventBridge schedule {names['schedule']}: {expression} ({app['timezone']})",
|
|
316
|
+
f"use default VPC subnets and security group {security_group} with a public IP",
|
|
317
|
+
]
|
|
318
|
+
if not confirm(actions, assume_yes, cost_lines(
|
|
319
|
+
clients["logs"], names, region, cron, schedule_exists)):
|
|
320
|
+
print("Aborted; nothing was changed.")
|
|
321
|
+
return 1
|
|
322
|
+
|
|
323
|
+
print(f"==> reconciling AWS resources in {account} ({region})")
|
|
324
|
+
repository_uri = ensure_repository(clients["ecr"])
|
|
325
|
+
cluster_arn = ensure_cluster(clients["ecs"])
|
|
326
|
+
ensure_log_group(clients["logs"], names["log_group"])
|
|
327
|
+
secret_arn = ensure_secret(clients["secretsmanager"], names["secret"], payload)
|
|
328
|
+
execution, task, scheduler_role = ensure_roles(
|
|
329
|
+
clients["iam"], names, account, region, secret_arn)
|
|
330
|
+
image = f"{repository_uri}:{names['image_tag']}"
|
|
331
|
+
print(f"==> building and pushing {image}")
|
|
332
|
+
image_digest = build_and_push(app, image, clients["ecr"])
|
|
333
|
+
print("==> reconciling task definition and schedule")
|
|
334
|
+
desired = desired_task(names, image, region, execution, task, secret_arn)
|
|
335
|
+
task_arn = ensure_task_definition(clients["ecs"], desired, image_digest)
|
|
336
|
+
target = {
|
|
337
|
+
"Arn": cluster_arn,
|
|
338
|
+
"EcsParameters": {
|
|
339
|
+
"TaskDefinitionArn": task_arn,
|
|
340
|
+
"LaunchType": "FARGATE",
|
|
341
|
+
"TaskCount": 1,
|
|
342
|
+
"NetworkConfiguration": {
|
|
343
|
+
"awsvpcConfiguration": {
|
|
344
|
+
"Subnets": subnets,
|
|
345
|
+
"SecurityGroups": [security_group],
|
|
346
|
+
"AssignPublicIp": "ENABLED",
|
|
347
|
+
},
|
|
348
|
+
},
|
|
349
|
+
},
|
|
350
|
+
}
|
|
351
|
+
ensure_schedule(clients["scheduler"], names["schedule"], expression,
|
|
352
|
+
app["timezone"], scheduler_role, target)
|
|
353
|
+
print(f"Deployed {app['name']}.")
|
|
354
|
+
print(f"Run it once: aws ecs run-task --cluster {CLUSTER} "
|
|
355
|
+
f"--task-definition {names['family']} --launch-type FARGATE "
|
|
356
|
+
f"--network-configuration 'awsvpcConfiguration={{subnets=[{subnets[0]}],"
|
|
357
|
+
f"securityGroups=[{security_group}],assignPublicIp=ENABLED}}' --region {region}")
|
|
358
|
+
return 0
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
def cluster_unused_after(ecs, family: str) -> bool:
|
|
362
|
+
clusters = ecs.describe_clusters(clusters=[CLUSTER]).get("clusters", [])
|
|
363
|
+
if not any(item.get("status") == "ACTIVE" for item in clusters):
|
|
364
|
+
return False
|
|
365
|
+
if ecs.list_tasks(cluster=CLUSTER).get("taskArns"):
|
|
366
|
+
return False
|
|
367
|
+
families = ecs.list_task_definition_families(
|
|
368
|
+
familyPrefix="pdt-", status="ACTIVE").get("families", [])
|
|
369
|
+
return all(item == family for item in families)
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
def repository_unused_after(ecr, image_tag: str) -> bool:
|
|
373
|
+
try:
|
|
374
|
+
images = ecr.list_images(repositoryName=REPOSITORY,
|
|
375
|
+
filter={"tagStatus": "TAGGED"}).get("imageIds", [])
|
|
376
|
+
except Exception as exc:
|
|
377
|
+
if not not_found(exc):
|
|
378
|
+
raise
|
|
379
|
+
return False
|
|
380
|
+
return all(item.get("imageTag") == image_tag for item in images)
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
def destroy(app: dict, assume_yes: bool, profile: str | None = None) -> int:
|
|
384
|
+
session = ensure_session(app, profile)
|
|
385
|
+
expected_account, region = aws_settings(app, session)
|
|
386
|
+
clients = fargate_clients(session)
|
|
387
|
+
account, _identity = preflight(
|
|
388
|
+
clients["sts"], clients["iam"], expected_account, DEPLOYER_ACTIONS)
|
|
389
|
+
names = resource_names(app["name"])
|
|
390
|
+
schedule_exists = resource_exists(
|
|
391
|
+
clients["scheduler"], "get_schedule",
|
|
392
|
+
Name=names["schedule"], GroupName=SCHEDULE_GROUP)
|
|
393
|
+
task_arns = clients["ecs"].list_task_definitions(
|
|
394
|
+
familyPrefix=names["family"], status="ACTIVE").get("taskDefinitionArns", [])
|
|
395
|
+
secret_exists = resource_exists(
|
|
396
|
+
clients["secretsmanager"], "describe_secret", SecretId=names["secret"])
|
|
397
|
+
actions = []
|
|
398
|
+
if schedule_exists:
|
|
399
|
+
actions.append(f"delete EventBridge schedule {names['schedule']}")
|
|
400
|
+
if task_arns:
|
|
401
|
+
actions.append(f"deregister {len(task_arns)} tagged task definition(s) "
|
|
402
|
+
f"in family {names['family']}")
|
|
403
|
+
if secret_exists:
|
|
404
|
+
actions.append(f"delete secret {names['secret']}")
|
|
405
|
+
actions += [
|
|
406
|
+
f"delete tagged log group {names['log_group']}",
|
|
407
|
+
"delete tagged per-app IAM roles",
|
|
408
|
+
f"delete image tag {names['image_tag']} from ECR repository {REPOSITORY}",
|
|
409
|
+
]
|
|
410
|
+
others = other_schedules(clients["scheduler"], names["schedule"])
|
|
411
|
+
if others == []:
|
|
412
|
+
actions.append(f"delete schedule group {SCHEDULE_GROUP} (no other apps use it)")
|
|
413
|
+
cluster_unused = cluster_unused_after(clients["ecs"], names["family"])
|
|
414
|
+
if cluster_unused:
|
|
415
|
+
actions.append(f"delete ECS cluster {CLUSTER} (no other apps use it)")
|
|
416
|
+
repository_unused = repository_unused_after(clients["ecr"], names["image_tag"])
|
|
417
|
+
if repository_unused:
|
|
418
|
+
actions.append(f"delete ECR repository {REPOSITORY} (no other apps use it)")
|
|
419
|
+
if not confirm(actions, assume_yes):
|
|
420
|
+
print("Aborted; nothing was changed.")
|
|
421
|
+
return 1
|
|
422
|
+
|
|
423
|
+
if schedule_exists:
|
|
424
|
+
clients["scheduler"].delete_schedule(
|
|
425
|
+
Name=names["schedule"], GroupName=SCHEDULE_GROUP)
|
|
426
|
+
ecs = clients["ecs"]
|
|
427
|
+
for arn in task_arns:
|
|
428
|
+
tags = ecs.list_tags_for_resource(resourceArn=arn).get("tags", [])
|
|
429
|
+
if has_managed_tag(tags, "key", "value"):
|
|
430
|
+
ecs.deregister_task_definition(taskDefinition=arn)
|
|
431
|
+
delete_secret(clients["secretsmanager"], names["secret"])
|
|
432
|
+
delete_log_group(clients["logs"], names["log_group"])
|
|
433
|
+
iam = clients["iam"]
|
|
434
|
+
for role in (names["scheduler_role"], names["task_role"], names["execution_role"]):
|
|
435
|
+
delete_role(iam, role)
|
|
436
|
+
try:
|
|
437
|
+
clients["ecr"].batch_delete_image(
|
|
438
|
+
repositoryName=REPOSITORY, imageIds=[{"imageTag": names["image_tag"]}])
|
|
439
|
+
except Exception as exc:
|
|
440
|
+
if not not_found(exc):
|
|
441
|
+
raise
|
|
442
|
+
if others == []:
|
|
443
|
+
delete_schedule_group(clients["scheduler"])
|
|
444
|
+
if cluster_unused:
|
|
445
|
+
ecs.delete_cluster(cluster=CLUSTER)
|
|
446
|
+
if repository_unused:
|
|
447
|
+
clients["ecr"].delete_repository(repositoryName=REPOSITORY, force=True)
|
|
448
|
+
print(f"Removed {app['name']} from account {account} ({region}).")
|
|
449
|
+
return 0
|