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.
@@ -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