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,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