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_lambda.py
ADDED
|
@@ -0,0 +1,359 @@
|
|
|
1
|
+
"""Deploy an app as a scheduled AWS Lambda function.
|
|
2
|
+
|
|
3
|
+
Selected with platform.runtime: lambda (the default). Entered through
|
|
4
|
+
deploy_aws.py, which owns the uv script header, login, and permission handling.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import base64
|
|
10
|
+
import hashlib
|
|
11
|
+
import json
|
|
12
|
+
import re
|
|
13
|
+
import shutil
|
|
14
|
+
import tempfile
|
|
15
|
+
import time
|
|
16
|
+
import zipfile
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
from botocore.exceptions import ClientError
|
|
20
|
+
|
|
21
|
+
from pdt import config
|
|
22
|
+
from pdt.deploy import confirm
|
|
23
|
+
from pdt.deploy_aws import (
|
|
24
|
+
COMMON_ACTIONS, MANAGED_TAGS, RECENT_RUNS, SCHEDULE_GROUP,
|
|
25
|
+
aws_schedule_expression, aws_settings, clients_for, cost_estimate_lines,
|
|
26
|
+
delete_log_group, list_price, run_basis,
|
|
27
|
+
delete_role, delete_secret, ensure_log_group, ensure_role, ensure_schedule,
|
|
28
|
+
ensure_secret, ensure_session, not_found, preflight,
|
|
29
|
+
resource_exists, other_schedules, delete_schedule_group,
|
|
30
|
+
with_role_propagation_retry,
|
|
31
|
+
)
|
|
32
|
+
from pdt.deploy_common import fail, gather_secrets, run_build, stage_build_context
|
|
33
|
+
|
|
34
|
+
LAMBDA_MEMORY_MB = 512
|
|
35
|
+
LAMBDA_TIMEOUT_SECONDS = 900
|
|
36
|
+
LAMBDA_RUNTIME = "python3.12"
|
|
37
|
+
LAMBDA_ARCHITECTURE = "arm64"
|
|
38
|
+
LAMBDA_PLATFORM = "aarch64-manylinux2014"
|
|
39
|
+
MAX_ZIP_BYTES = 50 * 1024 * 1024
|
|
40
|
+
MAX_UNZIPPED_BYTES = 250 * 1024 * 1024
|
|
41
|
+
LAMBDA_ACTIONS = [
|
|
42
|
+
"lambda:CreateFunction",
|
|
43
|
+
"lambda:DeleteFunction",
|
|
44
|
+
"lambda:GetFunction",
|
|
45
|
+
"lambda:ListTags",
|
|
46
|
+
"lambda:TagResource",
|
|
47
|
+
"lambda:UpdateFunctionCode",
|
|
48
|
+
"lambda:UpdateFunctionConfiguration",
|
|
49
|
+
]
|
|
50
|
+
DEPLOYER_ACTIONS = sorted(COMMON_ACTIONS + LAMBDA_ACTIONS)
|
|
51
|
+
LAMBDA_HANDLER = """\
|
|
52
|
+
from __future__ import annotations
|
|
53
|
+
|
|
54
|
+
import importlib.util
|
|
55
|
+
import json
|
|
56
|
+
import os
|
|
57
|
+
from pathlib import Path
|
|
58
|
+
|
|
59
|
+
import boto3
|
|
60
|
+
|
|
61
|
+
_APP = None
|
|
62
|
+
os.environ.setdefault("PDT_PROJECT", str(Path(__file__).parent))
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _app():
|
|
66
|
+
global _APP
|
|
67
|
+
if _APP is None:
|
|
68
|
+
path = Path(__file__).parent / {app_name!r} / "run.py"
|
|
69
|
+
spec = importlib.util.spec_from_file_location("pdt_app", path)
|
|
70
|
+
module = importlib.util.module_from_spec(spec)
|
|
71
|
+
spec.loader.exec_module(module)
|
|
72
|
+
_APP = module
|
|
73
|
+
return _APP
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def lambda_handler(_event, _context):
|
|
77
|
+
secret = boto3.client("secretsmanager").get_secret_value(
|
|
78
|
+
SecretId=os.environ["PDT_SECRET_ARN"]
|
|
79
|
+
)["SecretString"]
|
|
80
|
+
os.environ["PDT_ENV_JSON"] = secret
|
|
81
|
+
for name, value in json.loads(secret).items():
|
|
82
|
+
os.environ[name] = str(value)
|
|
83
|
+
status = _app().main()
|
|
84
|
+
if status:
|
|
85
|
+
raise RuntimeError(f"app exited with status {{status}}")
|
|
86
|
+
return {{"status": "ok"}}
|
|
87
|
+
"""
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def resource_names(app_name: str) -> dict[str, str]:
|
|
92
|
+
base = f"pdt-{app_name}"
|
|
93
|
+
return {
|
|
94
|
+
"function": base,
|
|
95
|
+
"schedule": base,
|
|
96
|
+
"secret": f"{base}-env",
|
|
97
|
+
"log_group": f"/aws/lambda/{base}",
|
|
98
|
+
"function_role": f"{base}-function",
|
|
99
|
+
"scheduler_role": f"{base}-scheduler",
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def ensure_roles(iam, names: dict[str, str], account: str, region: str,
|
|
104
|
+
secret_arn: str) -> tuple[str, str]:
|
|
105
|
+
log_arn = f"arn:aws:logs:{region}:{account}:log-group:{names['log_group']}:*"
|
|
106
|
+
function = ensure_role(
|
|
107
|
+
iam, names["function_role"], "lambda.amazonaws.com", "pdt-function", [
|
|
108
|
+
{"Effect": "Allow", "Action": ["logs:CreateLogStream", "logs:PutLogEvents"],
|
|
109
|
+
"Resource": log_arn},
|
|
110
|
+
{"Effect": "Allow", "Action": ["secretsmanager:GetSecretValue"],
|
|
111
|
+
"Resource": secret_arn},
|
|
112
|
+
])
|
|
113
|
+
function_arn = f"arn:aws:lambda:{region}:{account}:function:{names['function']}"
|
|
114
|
+
scheduler = ensure_role(
|
|
115
|
+
iam, names["scheduler_role"], "scheduler.amazonaws.com", "pdt-scheduler", [
|
|
116
|
+
{"Effect": "Allow", "Action": ["lambda:InvokeFunction"],
|
|
117
|
+
"Resource": function_arn},
|
|
118
|
+
])
|
|
119
|
+
return function, scheduler
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def build_package(app: dict) -> tuple[bytes, str, int]:
|
|
123
|
+
stage = stage_build_context(app)
|
|
124
|
+
(stage / "lambda_function.py").write_text(
|
|
125
|
+
LAMBDA_HANDLER.format(app_name=app["name"]))
|
|
126
|
+
work = Path(tempfile.mkdtemp(prefix="pdt-requirements-"))
|
|
127
|
+
archive = work / "function.zip"
|
|
128
|
+
requirements = work / "requirements.txt"
|
|
129
|
+
try:
|
|
130
|
+
run_build([
|
|
131
|
+
"uv", "export", "--script", str(app["dir"] / "run.py"),
|
|
132
|
+
"--format", "requirements-txt", "--no-hashes",
|
|
133
|
+
"--output-file", str(requirements),
|
|
134
|
+
])
|
|
135
|
+
run_build([
|
|
136
|
+
"uv", "pip", "install", "--requirements", str(requirements),
|
|
137
|
+
"--target", str(stage), "--python", "3.12",
|
|
138
|
+
"--python-platform", LAMBDA_PLATFORM, "--only-binary", ":all:",
|
|
139
|
+
"--no-compile-bytecode", "--no-installer-metadata",
|
|
140
|
+
])
|
|
141
|
+
uncompressed = sum(path.stat().st_size for path in stage.rglob("*")
|
|
142
|
+
if path.is_file())
|
|
143
|
+
if uncompressed > MAX_UNZIPPED_BYTES:
|
|
144
|
+
fail("Lambda package exceeds the 250 MB uncompressed limit")
|
|
145
|
+
with zipfile.ZipFile(archive, "w", zipfile.ZIP_DEFLATED, compresslevel=9) as zf:
|
|
146
|
+
for path in sorted(item for item in stage.rglob("*") if item.is_file()):
|
|
147
|
+
info = zipfile.ZipInfo(path.relative_to(stage).as_posix())
|
|
148
|
+
info.date_time = (2020, 1, 1, 0, 0, 0)
|
|
149
|
+
info.compress_type = zipfile.ZIP_DEFLATED
|
|
150
|
+
info.external_attr = 0o644 << 16
|
|
151
|
+
zf.writestr(info, path.read_bytes())
|
|
152
|
+
package = archive.read_bytes()
|
|
153
|
+
if len(package) > MAX_ZIP_BYTES:
|
|
154
|
+
fail("Lambda zip exceeds the 50 MB direct-upload limit")
|
|
155
|
+
digest = base64.b64encode(hashlib.sha256(package).digest()).decode("ascii")
|
|
156
|
+
return package, digest, uncompressed
|
|
157
|
+
finally:
|
|
158
|
+
shutil.rmtree(stage, ignore_errors=True)
|
|
159
|
+
shutil.rmtree(work, ignore_errors=True)
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def ensure_function(lambda_client, names: dict[str, str], role_arn: str,
|
|
163
|
+
secret_arn: str, package: bytes, digest: str) -> str:
|
|
164
|
+
function_name = names["function"]
|
|
165
|
+
try:
|
|
166
|
+
current = lambda_client.get_function(FunctionName=function_name)
|
|
167
|
+
arn = current["Configuration"]["FunctionArn"]
|
|
168
|
+
tags = lambda_client.list_tags(Resource=arn).get("Tags", {})
|
|
169
|
+
if tags.get("managed-by") != "pdt":
|
|
170
|
+
fail(f"Lambda function {function_name} exists but is not managed by PDT")
|
|
171
|
+
architectures = current["Configuration"].get("Architectures", ["x86_64"])
|
|
172
|
+
if (current["Configuration"].get("CodeSha256") != digest
|
|
173
|
+
or architectures != [LAMBDA_ARCHITECTURE]):
|
|
174
|
+
lambda_client.update_function_code(
|
|
175
|
+
FunctionName=function_name, ZipFile=package,
|
|
176
|
+
Architectures=[LAMBDA_ARCHITECTURE])
|
|
177
|
+
lambda_client.get_waiter("function_updated").wait(
|
|
178
|
+
FunctionName=function_name)
|
|
179
|
+
lambda_client.update_function_configuration(
|
|
180
|
+
FunctionName=function_name,
|
|
181
|
+
Role=role_arn,
|
|
182
|
+
Runtime=LAMBDA_RUNTIME,
|
|
183
|
+
Handler="lambda_function.lambda_handler",
|
|
184
|
+
Timeout=LAMBDA_TIMEOUT_SECONDS,
|
|
185
|
+
MemorySize=LAMBDA_MEMORY_MB,
|
|
186
|
+
Environment={"Variables": {"PDT_SECRET_ARN": secret_arn}},
|
|
187
|
+
)
|
|
188
|
+
lambda_client.get_waiter("function_updated").wait(
|
|
189
|
+
FunctionName=function_name)
|
|
190
|
+
lambda_client.tag_resource(Resource=arn, Tags=MANAGED_TAGS)
|
|
191
|
+
return arn
|
|
192
|
+
except Exception as exc:
|
|
193
|
+
if not not_found(exc):
|
|
194
|
+
raise
|
|
195
|
+
created = with_role_propagation_retry(lambda: lambda_client.create_function(
|
|
196
|
+
FunctionName=function_name,
|
|
197
|
+
Runtime=LAMBDA_RUNTIME,
|
|
198
|
+
Role=role_arn,
|
|
199
|
+
Handler="lambda_function.lambda_handler",
|
|
200
|
+
Code={"ZipFile": package},
|
|
201
|
+
Description="Managed by PDT",
|
|
202
|
+
Timeout=LAMBDA_TIMEOUT_SECONDS,
|
|
203
|
+
MemorySize=LAMBDA_MEMORY_MB,
|
|
204
|
+
Publish=False,
|
|
205
|
+
Environment={"Variables": {"PDT_SECRET_ARN": secret_arn}},
|
|
206
|
+
Architectures=[LAMBDA_ARCHITECTURE],
|
|
207
|
+
Tags=MANAGED_TAGS,
|
|
208
|
+
))
|
|
209
|
+
return created["FunctionArn"]
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def recent_report_seconds(logs, log_group: str) -> float | None:
|
|
213
|
+
# Lambda reuses one log stream for many runs; each run ends with a REPORT line.
|
|
214
|
+
try:
|
|
215
|
+
events = logs.filter_log_events(
|
|
216
|
+
logGroupName=log_group, filterPattern="REPORT",
|
|
217
|
+
startTime=int((time.time() - 30 * 86400) * 1000)).get("events", [])
|
|
218
|
+
except ClientError as exc:
|
|
219
|
+
if not_found(exc):
|
|
220
|
+
return None
|
|
221
|
+
raise
|
|
222
|
+
durations = []
|
|
223
|
+
for event in events[-RECENT_RUNS:]:
|
|
224
|
+
match = re.search(r"Duration: ([\d.]+) ms", event["message"])
|
|
225
|
+
if match:
|
|
226
|
+
durations.append(float(match.group(1)) / 1000)
|
|
227
|
+
if not durations:
|
|
228
|
+
return None
|
|
229
|
+
return sum(durations) / len(durations)
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def cost_lines(logs, names: dict[str, str], region: str, cron: str,
|
|
233
|
+
function_exists: bool) -> list[str]:
|
|
234
|
+
print("Fetching list prices from the AWS price list...")
|
|
235
|
+
try:
|
|
236
|
+
runs = config.runs_per_month(cron)
|
|
237
|
+
seconds, basis = run_basis(
|
|
238
|
+
recent_report_seconds(logs, names["log_group"]) if function_exists else None)
|
|
239
|
+
gib = LAMBDA_MEMORY_MB / 1024
|
|
240
|
+
compute = runs * seconds * gib * list_price("AWSLambda", region, "Lambda-GB-Second-ARM")
|
|
241
|
+
secret = list_price("AWSSecretsManager", region, "AWSSecretsManager-Secrets")
|
|
242
|
+
items = [
|
|
243
|
+
(f"Lambda ({LAMBDA_ARCHITECTURE}): ~{runs:.0f} runs x {basis} x {gib:g} GiB", compute),
|
|
244
|
+
("Secrets Manager: 1 secret", secret),
|
|
245
|
+
]
|
|
246
|
+
except Exception as exc:
|
|
247
|
+
fail(f"could not calculate the required monthly cost estimate: {exc}")
|
|
248
|
+
return cost_estimate_lines(
|
|
249
|
+
region, items, "excludes EventBridge Scheduler free tier and CloudWatch Logs usage")
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def deploy(app: dict, assume_yes: bool, profile: str | None = None) -> int:
|
|
253
|
+
session = ensure_session(app, profile)
|
|
254
|
+
expected_account, region = aws_settings(app, session)
|
|
255
|
+
clients = clients_for(session)
|
|
256
|
+
account, _identity = preflight(
|
|
257
|
+
clients["sts"], clients["iam"], expected_account, DEPLOYER_ACTIONS)
|
|
258
|
+
names = resource_names(app["name"])
|
|
259
|
+
cron = config.cron_expression(app["schedule"])
|
|
260
|
+
expression = aws_schedule_expression(cron)
|
|
261
|
+
payload = json.dumps(gather_secrets(app), sort_keys=True)
|
|
262
|
+
|
|
263
|
+
print(f"Checking current state in account {account} ({region})...")
|
|
264
|
+
function_exists = resource_exists(
|
|
265
|
+
clients["lambda"], "get_function", FunctionName=names["function"])
|
|
266
|
+
schedule_exists = resource_exists(
|
|
267
|
+
clients["scheduler"], "get_schedule",
|
|
268
|
+
Name=names["schedule"], GroupName=SCHEDULE_GROUP)
|
|
269
|
+
secret_exists = resource_exists(
|
|
270
|
+
clients["secretsmanager"], "describe_secret", SecretId=names["secret"])
|
|
271
|
+
actions = [
|
|
272
|
+
"build a Docker-free Lambda zip on this computer",
|
|
273
|
+
("update" if secret_exists else "create")
|
|
274
|
+
+ f" Secrets Manager secret {names['secret']}",
|
|
275
|
+
"reconcile the Lambda function and scheduler IAM roles",
|
|
276
|
+
("update" if function_exists else "create")
|
|
277
|
+
+ f" Lambda function {names['function']} "
|
|
278
|
+
+ f"({LAMBDA_ARCHITECTURE}, {LAMBDA_MEMORY_MB} MiB, 15-minute limit)",
|
|
279
|
+
("update" if schedule_exists else "create")
|
|
280
|
+
+ f" EventBridge schedule {names['schedule']}: {expression} ({app['timezone']})",
|
|
281
|
+
]
|
|
282
|
+
if not confirm(actions, assume_yes, cost_lines(
|
|
283
|
+
clients["logs"], names, region, cron, function_exists)):
|
|
284
|
+
print("Aborted; nothing was changed.")
|
|
285
|
+
return 1
|
|
286
|
+
|
|
287
|
+
print("==> building Lambda zip")
|
|
288
|
+
package, digest, uncompressed = build_package(app)
|
|
289
|
+
print(f" {len(package) / 1024 / 1024:.1f} MB compressed; "
|
|
290
|
+
f"{uncompressed / 1024 / 1024:.1f} MB uncompressed")
|
|
291
|
+
print(f"==> reconciling AWS resources in {account} ({region})")
|
|
292
|
+
ensure_log_group(clients["logs"], names["log_group"])
|
|
293
|
+
secret_arn = ensure_secret(
|
|
294
|
+
clients["secretsmanager"], names["secret"], payload)
|
|
295
|
+
function_role, scheduler_role = ensure_roles(
|
|
296
|
+
clients["iam"], names, account, region, secret_arn)
|
|
297
|
+
function_arn = ensure_function(
|
|
298
|
+
clients["lambda"], names, function_role, secret_arn, package, digest)
|
|
299
|
+
ensure_schedule(
|
|
300
|
+
clients["scheduler"], names["schedule"], expression, app["timezone"],
|
|
301
|
+
scheduler_role, {"Arn": function_arn})
|
|
302
|
+
print(f"Deployed {app['name']}.")
|
|
303
|
+
print(f"Run it once: aws lambda invoke --function-name {names['function']} "
|
|
304
|
+
f"--region {region} response.json")
|
|
305
|
+
return 0
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def destroy(app: dict, assume_yes: bool, profile: str | None = None) -> int:
|
|
309
|
+
session = ensure_session(app, profile)
|
|
310
|
+
expected_account, region = aws_settings(app, session)
|
|
311
|
+
clients = clients_for(session)
|
|
312
|
+
account, _identity = preflight(
|
|
313
|
+
clients["sts"], clients["iam"], expected_account, DEPLOYER_ACTIONS)
|
|
314
|
+
names = resource_names(app["name"])
|
|
315
|
+
schedule_exists = resource_exists(
|
|
316
|
+
clients["scheduler"], "get_schedule",
|
|
317
|
+
Name=names["schedule"], GroupName=SCHEDULE_GROUP)
|
|
318
|
+
function_exists = resource_exists(
|
|
319
|
+
clients["lambda"], "get_function", FunctionName=names["function"])
|
|
320
|
+
secret_exists = resource_exists(
|
|
321
|
+
clients["secretsmanager"], "describe_secret", SecretId=names["secret"])
|
|
322
|
+
actions = []
|
|
323
|
+
if schedule_exists:
|
|
324
|
+
actions.append(f"delete EventBridge schedule {names['schedule']}")
|
|
325
|
+
if function_exists:
|
|
326
|
+
actions.append(f"delete Lambda function {names['function']}")
|
|
327
|
+
if secret_exists:
|
|
328
|
+
actions.append(f"delete secret {names['secret']}")
|
|
329
|
+
actions += [
|
|
330
|
+
f"delete tagged log group {names['log_group']}",
|
|
331
|
+
"delete tagged per-app IAM roles",
|
|
332
|
+
]
|
|
333
|
+
others = other_schedules(clients["scheduler"], names["schedule"])
|
|
334
|
+
if others == []:
|
|
335
|
+
actions.append(f"delete schedule group {SCHEDULE_GROUP} (no other apps use it)")
|
|
336
|
+
if not confirm(actions, assume_yes):
|
|
337
|
+
print("Aborted; nothing was changed.")
|
|
338
|
+
return 1
|
|
339
|
+
|
|
340
|
+
scheduler = clients["scheduler"]
|
|
341
|
+
if schedule_exists:
|
|
342
|
+
scheduler.delete_schedule(
|
|
343
|
+
Name=names["schedule"], GroupName=SCHEDULE_GROUP)
|
|
344
|
+
lambda_client = clients["lambda"]
|
|
345
|
+
if function_exists:
|
|
346
|
+
current = lambda_client.get_function(FunctionName=names["function"])
|
|
347
|
+
arn = current["Configuration"]["FunctionArn"]
|
|
348
|
+
tags = lambda_client.list_tags(Resource=arn).get("Tags", {})
|
|
349
|
+
if tags.get("managed-by") == "pdt":
|
|
350
|
+
lambda_client.delete_function(FunctionName=names["function"])
|
|
351
|
+
delete_secret(clients["secretsmanager"], names["secret"])
|
|
352
|
+
delete_log_group(clients["logs"], names["log_group"])
|
|
353
|
+
iam = clients["iam"]
|
|
354
|
+
delete_role(iam, names["scheduler_role"])
|
|
355
|
+
delete_role(iam, names["function_role"])
|
|
356
|
+
if others == []:
|
|
357
|
+
delete_schedule_group(clients["scheduler"])
|
|
358
|
+
print(f"Removed {app['name']} from account {account} ({region}).")
|
|
359
|
+
return 0
|