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/deploy_aws.py ADDED
@@ -0,0 +1,613 @@
1
+ #!/usr/bin/env -S uv run --script
2
+ # /// script
3
+ # requires-python = ">=3.12"
4
+ # dependencies = [
5
+ # "boto3",
6
+ # "pyyaml",
7
+ # "python-dotenv",
8
+ # ]
9
+ # ///
10
+ """Deploy an app to AWS.
11
+
12
+ Shared login, IAM, secret, log, and schedule code lives here. The
13
+ runtime-specific parts are in deploy_aws_lambda.py and deploy_aws_fargate.py.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import argparse
19
+ import json
20
+ import os
21
+ import shutil
22
+ import subprocess
23
+ import sys
24
+ import time
25
+ import urllib.request
26
+ from pathlib import Path
27
+
28
+ import boto3
29
+ from botocore.exceptions import ClientError
30
+
31
+ sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
32
+ from pdt import config
33
+ from pdt.deploy_common import fail
34
+
35
+ MANAGED_TAGS = {"managed-by": "pdt"}
36
+ SCHEDULE_GROUP = "pdt"
37
+ ASSUMED_RUN_MINUTES = 5.0
38
+ RECENT_RUNS = 3
39
+ PRICE_LIST_URL = "https://pricing.us-east-1.amazonaws.com/offers/v1.0/aws/{offer}/current/{region}/index.json"
40
+ ROLE_PROPAGATION_DELAYS = (1, 2, 4, 8)
41
+ COMMON_ACTIONS = [
42
+ "iam:CreateRole",
43
+ "iam:DeleteRole",
44
+ "iam:DeleteRolePolicy",
45
+ "iam:GetRole",
46
+ "iam:ListRolePolicies",
47
+ "iam:ListRoleTags",
48
+ "iam:PassRole",
49
+ "iam:PutRolePolicy",
50
+ "iam:SimulatePrincipalPolicy",
51
+ "iam:TagRole",
52
+ "iam:UpdateAssumeRolePolicy",
53
+ "logs:CreateLogGroup",
54
+ "logs:DeleteLogGroup",
55
+ "logs:DescribeLogGroups",
56
+ "logs:DescribeLogStreams",
57
+ "logs:FilterLogEvents",
58
+ "logs:ListTagsForResource",
59
+ "logs:PutRetentionPolicy",
60
+ "logs:TagResource",
61
+ "scheduler:CreateSchedule",
62
+ "scheduler:CreateScheduleGroup",
63
+ "scheduler:DeleteSchedule",
64
+ "scheduler:DeleteScheduleGroup",
65
+ "scheduler:GetSchedule",
66
+ "scheduler:GetScheduleGroup",
67
+ "scheduler:ListSchedules",
68
+ "scheduler:TagResource",
69
+ "scheduler:UpdateSchedule",
70
+ "secretsmanager:CreateSecret",
71
+ "secretsmanager:DeleteSecret",
72
+ "secretsmanager:DescribeSecret",
73
+ "secretsmanager:GetSecretValue",
74
+ "secretsmanager:PutSecretValue",
75
+ "secretsmanager:RestoreSecret",
76
+ "secretsmanager:TagResource",
77
+ ]
78
+ def error_code(exc: Exception) -> str:
79
+ return getattr(exc, "response", {}).get("Error", {}).get("Code", "")
80
+
81
+
82
+ def not_found(exc: Exception) -> bool:
83
+ return error_code(exc) in {
84
+ "NoSuchEntity", "ResourceNotFoundException", "ResourceNotFound",
85
+ "ClusterNotFoundException", "RepositoryNotFoundException",
86
+ }
87
+
88
+
89
+ def role_propagation_error(exc: Exception) -> bool:
90
+ code = error_code(exc)
91
+ response_message = getattr(exc, "response", {}).get("Error", {}).get("Message", "")
92
+ message = f"{exc} {response_message}".lower()
93
+ return (
94
+ code in {"InvalidParameterValueException", "ValidationException",
95
+ "ClientException", "InvalidParameterException"}
96
+ and "role" in message
97
+ and any(fragment in message for fragment in (
98
+ "cannot be assumed", "could not be assumed", "does not exist",
99
+ "not valid", "unable to assume", "assume the role", "pass role",
100
+ ))
101
+ )
102
+
103
+
104
+ def with_role_propagation_retry(operation, sleep=time.sleep):
105
+ for delay in (*ROLE_PROPAGATION_DELAYS, None):
106
+ try:
107
+ return operation()
108
+ except Exception as exc:
109
+ if delay is None or not role_propagation_error(exc):
110
+ raise
111
+ print(f" IAM role is not visible yet; retrying in {delay}s...")
112
+ sleep(delay)
113
+ raise AssertionError("unreachable")
114
+
115
+
116
+ def adopt_account(app: dict, session) -> str:
117
+ # AWS gives one account per credential set, so there is nothing to pick.
118
+ identity = session.client("sts").get_caller_identity()
119
+ account = identity["Account"]
120
+ print(f"These credentials belong to AWS account {account}.")
121
+ print(f" {identity['Arn']}")
122
+ saved = config.save_platform_key(app, "account", account)
123
+ print(f"Saved account {account} to {saved.relative_to(config.find_project())}.")
124
+ return account
125
+
126
+
127
+ def aws_settings(app: dict, session) -> tuple[str, str]:
128
+ platform = app["platform"]
129
+ account = str(platform.get("account") or "").strip()
130
+ if not account:
131
+ account = adopt_account(app, session)
132
+ region = str(platform.get("region") or session.region_name or
133
+ os.environ.get("AWS_DEFAULT_REGION") or "")
134
+ if not region:
135
+ fail("AWS region is required; set platform.region, AWS_REGION, or AWS_DEFAULT_REGION")
136
+ return account, region
137
+
138
+
139
+ def iam_tags(extra: dict[str, str] | None = None) -> list[dict[str, str]]:
140
+ return [{"Key": key, "Value": value}
141
+ for key, value in {**MANAGED_TAGS, **(extra or {})}.items()]
142
+
143
+
144
+ def has_managed_tag(tags: list[dict], key_name: str, value_name: str) -> bool:
145
+ return any(tag.get(key_name) == "managed-by" and tag.get(value_name) == "pdt"
146
+ for tag in tags)
147
+
148
+
149
+ def deployer_policy(actions: list[str]) -> dict:
150
+ return {
151
+ "Version": "2012-10-17",
152
+ "Statement": [{
153
+ "Effect": "Allow",
154
+ "Action": actions,
155
+ "Resource": "*",
156
+ }],
157
+ }
158
+
159
+
160
+ def print_permission_help(identity: str, detail: str, actions: list[str]) -> None:
161
+ print("AWS blocked this deployment because the current login lacks a permission.")
162
+ if detail:
163
+ print(f"AWS said: {detail}")
164
+ print(f"Current AWS login: {identity}")
165
+ print("Send the policy below to the person who manages your AWS account.")
166
+ print("Ask them to add it to this login, then run the same command again.")
167
+ print(json.dumps(deployer_policy(actions), indent=2))
168
+
169
+
170
+ def principal_arn(identity_arn: str, account: str) -> str:
171
+ marker = f"arn:aws:sts::{account}:assumed-role/"
172
+ if identity_arn.startswith(marker):
173
+ role_name = identity_arn[len(marker):].split("/", 1)[0]
174
+ return f"arn:aws:iam::{account}:role/{role_name}"
175
+ return identity_arn
176
+
177
+
178
+ def ask(prompt: str) -> str:
179
+ try:
180
+ return input(prompt).strip()
181
+ except EOFError:
182
+ return ""
183
+
184
+
185
+ def choose_profile(session) -> str:
186
+ profiles = session.available_profiles
187
+ if not profiles:
188
+ print("No AWS credentials or profiles were found on this computer.")
189
+ print("Create a profile first: aws configure sso (or: aws configure)")
190
+ print("Then select it: export AWS_PROFILE=<profile-name>")
191
+ fail("run the same command again after you set AWS_PROFILE")
192
+ if len(profiles) == 1:
193
+ print(f"Using the only AWS profile on this computer: {profiles[0]}")
194
+ return profiles[0]
195
+ print("No AWS profile is selected. Profiles on this computer:")
196
+ for number, profile in enumerate(profiles, start=1):
197
+ print(f" {number}) {profile}")
198
+ answer = ask(f"Which profile do you want to use? [1-{len(profiles)}] ")
199
+ if answer.isdigit() and 1 <= int(answer) <= len(profiles):
200
+ profile = profiles[int(answer) - 1]
201
+ elif answer in profiles:
202
+ profile = answer
203
+ else:
204
+ fail("no AWS profile selected; run again with --profile <name> "
205
+ "or set AWS_PROFILE=<name>")
206
+ print(f"To skip this question next time: --profile {profile} "
207
+ f"or export AWS_PROFILE={profile}")
208
+ return profile
209
+
210
+
211
+ def sso_login(profile: str | None) -> bool:
212
+ aws_cli = shutil.which("aws")
213
+ if not aws_cli:
214
+ return False
215
+ command = [aws_cli, "sso", "login"]
216
+ if profile:
217
+ command += ["--profile", profile]
218
+ print("Your AWS login has expired or is missing.")
219
+ shown = " ".join(["aws", *command[1:]])
220
+ answer = ask(f"Log in now with `{shown}` (opens a browser)? [y/N] ")
221
+ if answer.lower() not in ("y", "yes"):
222
+ return False
223
+ return subprocess.run(command, check=False).returncode == 0
224
+
225
+
226
+ def relogin(profile: str | None) -> int:
227
+ session = boto3.Session()
228
+ name = profile or os.environ.get("AWS_PROFILE") or ""
229
+ if name not in session.available_profiles:
230
+ name = choose_profile(session)
231
+ aws_cli = shutil.which("aws")
232
+ if not aws_cli:
233
+ print("The AWS CLI is not installed, so pdt cannot refresh an SSO login here.")
234
+ print("Install it from https://docs.aws.amazon.com/cli/latest/"
235
+ "userguide/getting-started-install.html")
236
+ fail(f"then run: aws sso login --profile {name}")
237
+ print(f"Logging in to AWS profile {name}...")
238
+ if subprocess.run([aws_cli, "sso", "login", "--profile", name]).returncode != 0:
239
+ print(f"If {name} uses access keys instead of SSO there is no login to "
240
+ f"refresh; run `aws configure --profile {name}` to replace the keys.")
241
+ fail("aws sso login failed")
242
+ identity = boto3.Session(profile_name=name).client("sts").get_caller_identity()
243
+ print(f"Signed in as {identity['Arn']}")
244
+ print(f"Account {identity['Account']}")
245
+ return 0
246
+
247
+
248
+ def login_error(exc: Exception) -> bool:
249
+ name = type(exc).__name__
250
+ return ("SSO" in name or "Token" in name
251
+ or error_code(exc) in {"ExpiredToken", "ExpiredTokenException",
252
+ "InvalidClientTokenId", "UnrecognizedClientException"})
253
+
254
+
255
+ def ensure_session(app: dict, profile: str | None = None):
256
+ region = app["platform"].get("region") or None
257
+ if profile and profile not in boto3.Session().available_profiles:
258
+ fail(f"AWS profile {profile!r} not found; profiles on this computer: "
259
+ + ", ".join(boto3.Session().available_profiles))
260
+ session = boto3.Session(region_name=region, profile_name=profile)
261
+ if session.get_credentials() is None:
262
+ session = boto3.Session(region_name=region, profile_name=choose_profile(session))
263
+ try:
264
+ session.client("sts").get_caller_identity()
265
+ except Exception as exc: # noqa: BLE001 - credential providers raise several types
266
+ if not login_error(exc) or not sso_login(session.profile_name):
267
+ profile = session.profile_name or "<profile-name>"
268
+ fail(f"AWS credentials are unavailable or invalid: {exc}\n"
269
+ f"Log in first (for example: aws sso login --profile {profile}), "
270
+ f"then run the same command again.")
271
+ session = boto3.Session(region_name=region, profile_name=session.profile_name)
272
+ return session
273
+
274
+
275
+ def preflight(sts, iam, expected_account: str, actions: list[str]) -> tuple[str, str]:
276
+ try:
277
+ identity = sts.get_caller_identity()
278
+ except Exception as exc: # noqa: BLE001 - credential providers raise several types
279
+ fail(f"AWS credentials are unavailable or invalid: {exc}")
280
+ account = identity["Account"]
281
+ identity_arn = identity["Arn"]
282
+ if expected_account != account:
283
+ fail(f"configured AWS account {expected_account} does not match credentials ({account})")
284
+ source_arn = principal_arn(identity_arn, account)
285
+ if source_arn.endswith(":root"):
286
+ return account, identity_arn
287
+ try:
288
+ result = iam.simulate_principal_policy(
289
+ PolicySourceArn=source_arn,
290
+ ActionNames=actions,
291
+ )
292
+ except ClientError as exc:
293
+ if error_code(exc) in {"AccessDenied", "AccessDeniedException"}:
294
+ print_permission_help(identity_arn, str(exc), actions)
295
+ raise SystemExit(1) from exc
296
+ raise
297
+ denied = sorted(item["EvalActionName"] for item in result["EvaluationResults"]
298
+ if item["EvalDecision"] != "allowed")
299
+ if denied:
300
+ print_permission_help(identity_arn, "These required actions are not allowed: "
301
+ + ", ".join(denied), actions)
302
+ raise SystemExit(1)
303
+ return account, identity_arn
304
+
305
+
306
+ def trust_policy(service: str) -> str:
307
+ return json.dumps({
308
+ "Version": "2012-10-17",
309
+ "Statement": [{"Effect": "Allow", "Principal": {"Service": service},
310
+ "Action": "sts:AssumeRole"}],
311
+ }, sort_keys=True)
312
+
313
+
314
+ def ensure_role(iam, name: str, service: str, policy_name: str,
315
+ statements: list[dict]) -> str:
316
+ try:
317
+ role = iam.get_role(RoleName=name)["Role"]
318
+ if not has_managed_tag(role.get("Tags", []), "Key", "Value"):
319
+ fail(f"IAM role {name} exists but is not managed by PDT")
320
+ iam.update_assume_role_policy(
321
+ RoleName=name, PolicyDocument=trust_policy(service))
322
+ iam.tag_role(RoleName=name, Tags=iam_tags())
323
+ except Exception as exc:
324
+ if not not_found(exc):
325
+ raise
326
+ role = iam.create_role(
327
+ RoleName=name,
328
+ AssumeRolePolicyDocument=trust_policy(service),
329
+ Description="Managed by PDT",
330
+ Tags=iam_tags(),
331
+ )["Role"]
332
+ for existing in iam.list_role_policies(RoleName=name).get("PolicyNames", []):
333
+ if existing != policy_name or not statements:
334
+ iam.delete_role_policy(RoleName=name, PolicyName=existing)
335
+ if not statements:
336
+ return role["Arn"]
337
+ document = json.dumps({
338
+ "Version": "2012-10-17", "Statement": statements,
339
+ }, sort_keys=True)
340
+ iam.put_role_policy(
341
+ RoleName=name, PolicyName=policy_name, PolicyDocument=document)
342
+ return role["Arn"]
343
+
344
+
345
+ def ensure_log_group(logs, name: str) -> None:
346
+ groups = logs.describe_log_groups(logGroupNamePrefix=name).get("logGroups", [])
347
+ exists = any(group["logGroupName"] == name for group in groups)
348
+ if exists:
349
+ group = next(group for group in groups if group["logGroupName"] == name)
350
+ arn = group.get("logGroupArn") or group["arn"].removesuffix(":*")
351
+ tags = logs.list_tags_for_resource(resourceArn=arn).get("tags", {})
352
+ if tags.get("managed-by") != "pdt":
353
+ fail(f"CloudWatch log group {name} exists but is not managed by PDT")
354
+ else:
355
+ logs.create_log_group(logGroupName=name, tags=MANAGED_TAGS)
356
+ logs.put_retention_policy(logGroupName=name, retentionInDays=30)
357
+
358
+
359
+ def ensure_secret(secrets, name: str, payload: str) -> str:
360
+ try:
361
+ current = secrets.describe_secret(SecretId=name)
362
+ arn = current["ARN"]
363
+ if not has_managed_tag(current.get("Tags", []), "Key", "Value"):
364
+ fail(f"Secrets Manager secret {name} exists but is not managed by PDT")
365
+ if current.get("DeletedDate"):
366
+ secrets.restore_secret(SecretId=name)
367
+ try:
368
+ value = secrets.get_secret_value(SecretId=name).get("SecretString", "")
369
+ except Exception: # noqa: BLE001 - a missing or inaccessible value is replaced
370
+ value = None
371
+ if value != payload:
372
+ secrets.put_secret_value(SecretId=name, SecretString=payload)
373
+ secrets.tag_resource(SecretId=arn, Tags=iam_tags())
374
+ return arn
375
+ except Exception as exc:
376
+ if not not_found(exc):
377
+ raise
378
+ return secrets.create_secret(
379
+ Name=name,
380
+ SecretString=payload,
381
+ Tags=iam_tags(),
382
+ Description="PDT_ENV_JSON for a PDT Lambda function",
383
+ )["ARN"]
384
+
385
+
386
+ def list_price(offer: str, region: str, usagetype_suffix: str) -> float:
387
+ url = PRICE_LIST_URL.format(offer=offer, region=region)
388
+ with urllib.request.urlopen(url, timeout=60) as resp:
389
+ data = json.load(resp)
390
+ for sku, product in data["products"].items():
391
+ if not product["attributes"].get("usagetype", "").endswith(usagetype_suffix):
392
+ continue
393
+ for term in data["terms"]["OnDemand"].get(sku, {}).values():
394
+ for dimension in term["priceDimensions"].values():
395
+ if dimension.get("beginRange", "0") == "0":
396
+ return float(dimension["pricePerUnit"]["USD"])
397
+ raise LookupError(f"no {usagetype_suffix!r} price for {offer} in region {region}")
398
+
399
+
400
+ def recent_stream_seconds(logs, log_group: str) -> float | None:
401
+ # One log stream per run (Fargate task); its first and last event bound the run.
402
+ try:
403
+ streams = logs.describe_log_streams(
404
+ logGroupName=log_group, orderBy="LastEventTime", descending=True,
405
+ limit=RECENT_RUNS).get("logStreams", [])
406
+ except ClientError as exc:
407
+ if not_found(exc):
408
+ return None
409
+ raise
410
+ durations = [(s["lastEventTimestamp"] - s["firstEventTimestamp"]) / 1000
411
+ for s in streams if "firstEventTimestamp" in s and "lastEventTimestamp" in s]
412
+ if not durations:
413
+ return None
414
+ return sum(durations) / len(durations)
415
+
416
+
417
+ def cost_estimate_lines(region: str, items: list[tuple[str, float]],
418
+ excludes: str) -> list[str]:
419
+ total = sum(cost for _, cost in items)
420
+ width = max(len(label) for label, _ in items)
421
+ lines = [f"Estimated monthly cost ({region} list prices, before free tiers):"]
422
+ for label, cost in items:
423
+ lines.append(f" {label:<{width}} ${cost:>7.2f}")
424
+ lines.append(f" {'total':<{width}} ${total:>7.2f}")
425
+ lines.append(f" ({excludes})")
426
+ return lines
427
+
428
+
429
+ def run_basis(seconds: float | None) -> tuple[float, str]:
430
+ if seconds is None:
431
+ return ASSUMED_RUN_MINUTES * 60, f"{ASSUMED_RUN_MINUTES:g} min assumed"
432
+ return seconds, f"{seconds / 60:.1f} min avg of recent runs"
433
+
434
+
435
+ def aws_schedule_expression(cron: str) -> str:
436
+ minute, hour, dom, month, dow = cron.split()
437
+ if dom != "*" and dow != "*":
438
+ raise config.ConfigError(
439
+ "AWS schedules cannot restrict both day-of-month and day-of-week")
440
+ if dow != "*":
441
+ names = ["SUN", "MON", "TUE", "WED", "THU", "FRI", "SAT", "SUN"]
442
+ parts: list[str] = []
443
+ for part in dow.split(","):
444
+ span, slash, step = part.partition("/")
445
+ if "-" in span:
446
+ start, end = span.split("-", 1)
447
+ if start.isdigit() and end.isdigit():
448
+ if not (0 <= int(start) <= 7 and 0 <= int(end) <= 7):
449
+ raise config.ConfigError(
450
+ f"AWS day-of-week values must be between 0 and 7: {dow}")
451
+ span = f"{names[int(start)]}-{names[int(end)]}"
452
+ elif span.isdigit():
453
+ if not 0 <= int(span) <= 7:
454
+ raise config.ConfigError(
455
+ f"AWS day-of-week values must be between 0 and 7: {dow}")
456
+ span = names[int(span)]
457
+ parts.append(span + (slash + step if slash else ""))
458
+ dow = ",".join(parts)
459
+ if dom == "*":
460
+ dom = "?"
461
+ else:
462
+ dow = "?"
463
+ return f"cron({minute} {hour} {dom} {month} {dow} *)"
464
+
465
+
466
+ def ensure_schedule_group(scheduler) -> None:
467
+ try:
468
+ scheduler.get_schedule_group(Name=SCHEDULE_GROUP)
469
+ except Exception as exc:
470
+ if not not_found(exc):
471
+ raise
472
+ scheduler.create_schedule_group(Name=SCHEDULE_GROUP, Tags=iam_tags())
473
+
474
+
475
+ def ensure_schedule(scheduler, name: str, expression: str, timezone: str,
476
+ role_arn: str, target: dict) -> None:
477
+ # Scheduler tags live on groups, not schedules: membership in the
478
+ # tagged pdt group is the ownership marker.
479
+ ensure_schedule_group(scheduler)
480
+ request = {
481
+ "Name": name,
482
+ "GroupName": SCHEDULE_GROUP,
483
+ "ScheduleExpression": expression,
484
+ "ScheduleExpressionTimezone": timezone,
485
+ "FlexibleTimeWindow": {"Mode": "OFF"},
486
+ "State": "ENABLED",
487
+ "Target": {
488
+ **target,
489
+ "RoleArn": role_arn,
490
+ "RetryPolicy": {"MaximumRetryAttempts": 1},
491
+ },
492
+ }
493
+ try:
494
+ scheduler.get_schedule(Name=name, GroupName=SCHEDULE_GROUP)
495
+ with_role_propagation_retry(lambda: scheduler.update_schedule(**request))
496
+ except Exception as exc:
497
+ if not not_found(exc):
498
+ raise
499
+ with_role_propagation_retry(lambda: scheduler.create_schedule(
500
+ **request, Description="Managed by PDT", ActionAfterCompletion="NONE"))
501
+
502
+
503
+ def resource_exists(client, operation: str, **kwargs) -> bool:
504
+ try:
505
+ getattr(client, operation)(**kwargs)
506
+ return True
507
+ except Exception as exc:
508
+ if not not_found(exc):
509
+ raise
510
+ return False
511
+
512
+
513
+ def clients_for(session) -> dict:
514
+ return {name: session.client(name) for name in
515
+ ("sts", "lambda", "logs", "secretsmanager", "iam", "scheduler")}
516
+
517
+
518
+ def delete_secret(secrets, name: str) -> None:
519
+ try:
520
+ current = secrets.describe_secret(SecretId=name)
521
+ except Exception as exc:
522
+ if not not_found(exc):
523
+ raise
524
+ return
525
+ if has_managed_tag(current.get("Tags", []), "Key", "Value"):
526
+ secrets.delete_secret(SecretId=name, ForceDeleteWithoutRecovery=True)
527
+
528
+
529
+ def delete_log_group(logs, name: str) -> None:
530
+ groups = logs.describe_log_groups(logGroupNamePrefix=name).get("logGroups", [])
531
+ for group in groups:
532
+ if group["logGroupName"] != name:
533
+ continue
534
+ arn = group.get("logGroupArn") or group["arn"].removesuffix(":*")
535
+ tags = logs.list_tags_for_resource(resourceArn=arn).get("tags", {})
536
+ if tags.get("managed-by") == "pdt":
537
+ logs.delete_log_group(logGroupName=name)
538
+
539
+
540
+ def other_schedules(scheduler, name: str) -> list[str] | None:
541
+ """Names of other schedules in the pdt group; None when the group is absent."""
542
+ try:
543
+ schedules = scheduler.list_schedules(GroupName=SCHEDULE_GROUP).get("Schedules", [])
544
+ except Exception as exc:
545
+ if not not_found(exc):
546
+ raise
547
+ return None
548
+ return [item["Name"] for item in schedules if item["Name"] != name]
549
+
550
+
551
+ def delete_schedule_group(scheduler) -> None:
552
+ try:
553
+ scheduler.delete_schedule_group(Name=SCHEDULE_GROUP)
554
+ except Exception as exc:
555
+ if not not_found(exc):
556
+ raise
557
+
558
+
559
+ def delete_role(iam, name: str) -> None:
560
+ try:
561
+ role = iam.get_role(RoleName=name)["Role"]
562
+ if not has_managed_tag(role.get("Tags", []), "Key", "Value"):
563
+ return
564
+ for policy in iam.list_role_policies(RoleName=name).get("PolicyNames", []):
565
+ iam.delete_role_policy(RoleName=name, PolicyName=policy)
566
+ iam.delete_role(RoleName=name)
567
+ except Exception as exc:
568
+ if not not_found(exc):
569
+ raise
570
+
571
+
572
+ def load_app(app_name: str) -> dict:
573
+ try:
574
+ app = config.merged_app(app_name)
575
+ except config.ConfigError as exc:
576
+ fail(str(exc))
577
+ config.load_env(app["dir"])
578
+ return app
579
+
580
+
581
+ def main() -> int:
582
+ parser = argparse.ArgumentParser(description=__doc__)
583
+ parser.add_argument("command", choices=("deploy", "destroy", "login"))
584
+ parser.add_argument("app")
585
+ parser.add_argument("--yes", action="store_true")
586
+ parser.add_argument("--profile", help="AWS profile name")
587
+ args = parser.parse_args()
588
+ if args.command == "login":
589
+ return relogin(args.profile)
590
+ app = load_app(args.app)
591
+ if app["platform"].get("runtime", "lambda") == "fargate":
592
+ from pdt import deploy_aws_fargate as runtime
593
+ else:
594
+ from pdt import deploy_aws_lambda as runtime
595
+ try:
596
+ if args.command == "deploy":
597
+ return runtime.deploy(app, args.yes, args.profile)
598
+ return runtime.destroy(app, args.yes, args.profile)
599
+ except ClientError as exc:
600
+ if error_code(exc) in {"AccessDenied", "AccessDeniedException",
601
+ "UnauthorizedOperation"}:
602
+ identity = "the current AWS login"
603
+ try:
604
+ identity = boto3.client("sts").get_caller_identity()["Arn"]
605
+ except Exception: # noqa: BLE001,S110 - retain the original permission error
606
+ pass
607
+ print_permission_help(identity, str(exc), runtime.DEPLOYER_ACTIONS)
608
+ return 1
609
+ fail(f"AWS returned {error_code(exc) or 'an error'}: {exc}")
610
+
611
+
612
+ if __name__ == "__main__":
613
+ sys.exit(main())