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
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())
|