py-serverless-kit 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.
Files changed (71) hide show
  1. py_serverless_kit/__init__.py +47 -0
  2. py_serverless_kit/__main__.py +3 -0
  3. py_serverless_kit/_version.py +2 -0
  4. py_serverless_kit/cli/__init__.py +0 -0
  5. py_serverless_kit/cli/main.py +491 -0
  6. py_serverless_kit/cli/templates.py +212 -0
  7. py_serverless_kit/config/__init__.py +12 -0
  8. py_serverless_kit/config/loader.py +316 -0
  9. py_serverless_kit/config/schema.py +457 -0
  10. py_serverless_kit/core/__init__.py +0 -0
  11. py_serverless_kit/core/deployer.py +180 -0
  12. py_serverless_kit/core/describe.py +112 -0
  13. py_serverless_kit/core/project.py +147 -0
  14. py_serverless_kit/dashboard/__init__.py +3 -0
  15. py_serverless_kit/dashboard/generator.py +65 -0
  16. py_serverless_kit/dashboard/template.html +248 -0
  17. py_serverless_kit/local/__init__.py +3 -0
  18. py_serverless_kit/local/runner.py +234 -0
  19. py_serverless_kit/local/server.py +200 -0
  20. py_serverless_kit/packaging/__init__.py +3 -0
  21. py_serverless_kit/packaging/builder.py +252 -0
  22. py_serverless_kit/providers/__init__.py +15 -0
  23. py_serverless_kit/providers/alibaba.py +268 -0
  24. py_serverless_kit/providers/aws.py +508 -0
  25. py_serverless_kit/providers/azure.py +417 -0
  26. py_serverless_kit/providers/base.py +310 -0
  27. py_serverless_kit/providers/cloudflare.py +264 -0
  28. py_serverless_kit/providers/digitalocean.py +211 -0
  29. py_serverless_kit/providers/gcp.py +480 -0
  30. py_serverless_kit/providers/huawei.py +292 -0
  31. py_serverless_kit/providers/ibm.py +132 -0
  32. py_serverless_kit/providers/oracle.py +304 -0
  33. py_serverless_kit/providers/registry.py +60 -0
  34. py_serverless_kit/providers/schedule.py +153 -0
  35. py_serverless_kit/providers/tencent.py +275 -0
  36. py_serverless_kit/providers/terraform_doc.py +125 -0
  37. py_serverless_kit/runtime/__init__.py +6 -0
  38. py_serverless_kit/runtime/adapters/__init__.py +18 -0
  39. py_serverless_kit/runtime/adapters/_httpfn.py +91 -0
  40. py_serverless_kit/runtime/adapters/alibaba.py +147 -0
  41. py_serverless_kit/runtime/adapters/aws.py +201 -0
  42. py_serverless_kit/runtime/adapters/azure.py +185 -0
  43. py_serverless_kit/runtime/adapters/base.py +86 -0
  44. py_serverless_kit/runtime/adapters/cloudflare.py +126 -0
  45. py_serverless_kit/runtime/adapters/digitalocean.py +74 -0
  46. py_serverless_kit/runtime/adapters/gcp.py +176 -0
  47. py_serverless_kit/runtime/adapters/huawei.py +173 -0
  48. py_serverless_kit/runtime/adapters/ibm.py +91 -0
  49. py_serverless_kit/runtime/adapters/oracle.py +161 -0
  50. py_serverless_kit/runtime/adapters/tencent.py +151 -0
  51. py_serverless_kit/runtime/batch.py +47 -0
  52. py_serverless_kit/runtime/context.py +45 -0
  53. py_serverless_kit/runtime/dispatch.py +277 -0
  54. py_serverless_kit/runtime/errors.py +83 -0
  55. py_serverless_kit/runtime/event.py +267 -0
  56. py_serverless_kit/runtime/invoke.py +119 -0
  57. py_serverless_kit/runtime/log.py +69 -0
  58. py_serverless_kit/runtime/manifest.py +121 -0
  59. py_serverless_kit/runtime/response.py +177 -0
  60. py_serverless_kit/runtime/routing.py +74 -0
  61. py_serverless_kit/runtime/workflow.py +312 -0
  62. py_serverless_kit/terraform/__init__.py +3 -0
  63. py_serverless_kit/terraform/runner.py +104 -0
  64. py_serverless_kit/workflows/__init__.py +35 -0
  65. py_serverless_kit/workflows/asl.py +170 -0
  66. py_serverless_kit/workflows/gcp.py +328 -0
  67. py_serverless_kit-0.1.0.dist-info/METADATA +300 -0
  68. py_serverless_kit-0.1.0.dist-info/RECORD +71 -0
  69. py_serverless_kit-0.1.0.dist-info/WHEEL +4 -0
  70. py_serverless_kit-0.1.0.dist-info/entry_points.txt +3 -0
  71. py_serverless_kit-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,508 @@
1
+ """AWS: Lambda + API Gateway HTTP API + EventBridge Scheduler + SQS + S3 + SNS + Step Functions."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from typing import Any, Dict, List, Optional, Tuple
7
+
8
+ from ..config.schema import FunctionConfig, ServiceConfig
9
+ from ..runtime.adapters.base import envelope
10
+ from ..workflows import definition_dict
11
+ from ..workflows.asl import compile_asl
12
+ from . import schedule as sched
13
+ from .base import ALL_CAPABILITIES, Issue, Provider, RenderContext
14
+ from .terraform_doc import TerraformDoc, escape, ref, tf_id
15
+
16
+ LAMBDA = "aws_lambda_function"
17
+ BASIC_EXEC = "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole"
18
+ SQS_EXEC = "arn:aws:iam::aws:policy/service-role/AWSLambdaSQSQueueExecutionRole"
19
+
20
+
21
+ def _assume(service: str) -> str:
22
+ return json.dumps(
23
+ {
24
+ "Version": "2012-10-17",
25
+ "Statement": [{"Effect": "Allow", "Principal": {"Service": service}, "Action": "sts:AssumeRole"}],
26
+ }
27
+ )
28
+
29
+
30
+ def _policy(statements: List[Dict[str, Any]]) -> str:
31
+ return json.dumps({"Version": "2012-10-17", "Statement": statements})
32
+
33
+
34
+ class AwsProvider(Provider):
35
+ name = "aws"
36
+ display_name = "AWS Lambda"
37
+ tier = "stable"
38
+ terraform_source = "hashicorp/aws"
39
+ terraform_version = ">= 5.40"
40
+ default_region = "us-east-1"
41
+ capabilities = frozenset(ALL_CAPABILITIES)
42
+ max_name_length = 64
43
+ name_pattern = r"[^a-z0-9_-]"
44
+ runtimes = {f"python3.{v}": f"python3.{v}" for v in (9, 10, 11, 12, 13)}
45
+ vendor_dependencies = True
46
+ max_timeout = 900
47
+ notes = "HTTP via API Gateway HTTP API; schedules via EventBridge Scheduler; workflows via Step Functions."
48
+
49
+ # ------------------------------------------------------------------ bundle
50
+ def shim_files(self, config: ServiceConfig) -> Dict[str, str]:
51
+ return {
52
+ "pskit_entry.py": (
53
+ "# Generated by py-serverless-kit. Lambda handler: pskit_entry.handler\n"
54
+ "from py_serverless_kit.runtime.adapters.aws import handler # noqa: F401\n"
55
+ )
56
+ }
57
+
58
+ def provider_block(self, ctx: RenderContext) -> Dict[str, Any]:
59
+ block: Dict[str, Any] = {"region": ctx.region, "default_tags": {"tags": ctx.tags()}}
60
+ for key in ("profile", "allowed_account_ids"):
61
+ if ctx.opt(key):
62
+ block[key] = ctx.opt(key)
63
+ if ctx.opt("assume_role_arn"):
64
+ block["assume_role"] = {"role_arn": ctx.opt("assume_role_arn")}
65
+ return block
66
+
67
+ def extra_validation(self, config: ServiceConfig) -> List[Issue]:
68
+ issues = []
69
+ for name, fn in config.functions.items():
70
+ for ev in fn.events_of("http"):
71
+ if ev.auth == "key":
72
+ issues.append(
73
+ Issue(
74
+ "error",
75
+ "API keys are not available on API Gateway HTTP APIs; use jwt or iam",
76
+ f"functions.{name}",
77
+ )
78
+ )
79
+ if ev.auth == "jwt" and not {"issuer", "audience"} <= set(ev.authorizer):
80
+ issues.append(
81
+ Issue("error", "jwt auth needs authorizer.issuer and authorizer.audience", f"functions.{name}")
82
+ )
83
+ return issues
84
+
85
+ # ------------------------------------------------------------------ render
86
+ def render_resources(self, doc: TerraformDoc, ctx: RenderContext) -> None:
87
+ cfg = ctx.config
88
+ has = {e.type for fn in cfg.functions.values() for e in fn.events}
89
+ shared_role = self._role(doc, ctx, "lambda", cfg.provider.iam.statements, "queue" in has, cfg.provider.iam.role)
90
+
91
+ fn_out: Dict[str, Dict[str, Any]] = {}
92
+ arns: Dict[str, str] = {}
93
+ api_id: Optional[str] = None
94
+ s3_notifications: Dict[str, List[Tuple[str, Any]]] = {}
95
+
96
+ for name, fn in ctx.function_items():
97
+ role = shared_role
98
+ if fn.iam.statements or fn.iam.role:
99
+ role = self._role(
100
+ doc,
101
+ ctx,
102
+ f"fn-{name}",
103
+ cfg.provider.iam.statements + fn.iam.statements,
104
+ bool(fn.events_of("queue")),
105
+ fn.iam.role,
106
+ )
107
+ lam = self._function(doc, ctx, name, fn, role)
108
+ arns[name] = ref(lam, "arn")
109
+ out: Dict[str, Any] = {
110
+ "id": ref(lam, "function_name"),
111
+ "arn": ref(lam, "arn"),
112
+ "console": f"https://{ctx.region}.console.aws.amazon.com/lambda/home?region={ctx.region}#/functions/{ctx.physical_name(name)}",
113
+ }
114
+ http_events = fn.events_of("http")
115
+ if http_events:
116
+ api_id = api_id or self._api(doc, ctx)
117
+ self._http(doc, ctx, api_id, name, lam, http_events)
118
+ out["base_url"] = ref(api_id, "api_endpoint")
119
+ for i, ev in enumerate(fn.events_of("schedule")):
120
+ self._schedule(doc, ctx, name, lam, i, ev)
121
+ for ev in fn.events_of("queue"):
122
+ self._queue(doc, ctx, name, lam, ev)
123
+ for ev in fn.events_of("storage"):
124
+ s3_notifications.setdefault(ev.bucket, []).append((name, ev))
125
+ for ev in fn.events_of("topic"):
126
+ self._topic(doc, ctx, name, lam, ev)
127
+ fn_out[name] = out
128
+
129
+ for bucket, subs in sorted(s3_notifications.items()):
130
+ self._bucket_notifications(doc, ctx, bucket, subs, arns)
131
+
132
+ wf_out = self._workflows(doc, ctx, arns)
133
+ doc.output(
134
+ "pskit",
135
+ {
136
+ "functions": fn_out,
137
+ "workflows": wf_out,
138
+ "endpoint": ref(api_id, "api_endpoint") if api_id else None,
139
+ },
140
+ description="py-serverless-kit deployment summary",
141
+ )
142
+
143
+ # ------------------------------------------------------------------ pieces
144
+ def _role(
145
+ self,
146
+ doc: TerraformDoc,
147
+ ctx: RenderContext,
148
+ label: str,
149
+ statements: List[Any],
150
+ sqs: bool,
151
+ existing: Optional[str],
152
+ ) -> str:
153
+ """Return a Terraform expression for the role ARN."""
154
+ if existing:
155
+ return existing
156
+ role = doc.resource(
157
+ "aws_iam_role",
158
+ label,
159
+ {"name": ctx.physical_name(label)[:64], "assume_role_policy": _assume("lambda.amazonaws.com")},
160
+ )
161
+ doc.resource(
162
+ "aws_iam_role_policy_attachment", f"{label}-basic", {"role": ref(role, "name"), "policy_arn": BASIC_EXEC}
163
+ )
164
+ if sqs:
165
+ doc.resource(
166
+ "aws_iam_role_policy_attachment", f"{label}-sqs", {"role": ref(role, "name"), "policy_arn": SQS_EXEC}
167
+ )
168
+ if statements:
169
+ doc.resource(
170
+ "aws_iam_role_policy",
171
+ f"{label}-custom",
172
+ {
173
+ "name": "pskit-custom",
174
+ "role": ref(role, "id"),
175
+ "policy": escape(
176
+ _policy(
177
+ [{"Effect": s.effect, "Action": s.actions, "Resource": s.resources} for s in statements]
178
+ )
179
+ ),
180
+ },
181
+ )
182
+ return ref(role, "arn")
183
+
184
+ def _function(self, doc: TerraformDoc, ctx: RenderContext, name: str, fn: FunctionConfig, role_arn: str) -> str:
185
+ cfg = ctx.config
186
+ phys = ctx.physical_name(name)
187
+ depends: List[str] = []
188
+ if cfg.provider.log_retention_days:
189
+ lg = doc.resource(
190
+ "aws_cloudwatch_log_group",
191
+ f"logs-{name}",
192
+ {"name": f"/aws/lambda/{phys}", "retention_in_days": cfg.provider.log_retention_days},
193
+ )
194
+ depends.append(lg)
195
+ body: Dict[str, Any] = {
196
+ "function_name": phys,
197
+ "description": fn.description[:256],
198
+ "role": role_arn,
199
+ "handler": "pskit_entry.handler",
200
+ "runtime": self.runtime_id(cfg.runtime_of(fn)),
201
+ "filename": "${path.module}/artifact.zip",
202
+ "source_code_hash": ctx.artifact.sha256_b64,
203
+ "memory_size": cfg.memory_of(fn),
204
+ "timeout": cfg.timeout_of(fn),
205
+ "architectures": [cfg.architecture_of(fn)],
206
+ "environment": {"variables": {k: escape(v) for k, v in ctx.environment(name, fn).items()}},
207
+ "tags": ctx.tags(fn),
208
+ }
209
+ if fn.concurrency is not None:
210
+ body["reserved_concurrent_executions"] = fn.concurrency
211
+ if depends:
212
+ body["depends_on"] = depends
213
+ body.update(fn.options) # escape hatch: vpc_config, layers, tracing_config, ...
214
+ return doc.resource(LAMBDA, name, body)
215
+
216
+ def _api(self, doc: TerraformDoc, ctx: RenderContext) -> str:
217
+ cors_cfgs = [
218
+ ev.cors_config
219
+ for fn in ctx.config.functions.values()
220
+ for ev in fn.events_of("http")
221
+ if ev.cors_config is not None
222
+ ]
223
+ body: Dict[str, Any] = {"name": ctx.prefix, "protocol_type": "HTTP", "tags": ctx.tags()}
224
+ if cors_cfgs:
225
+ origins = sorted({o for c in cors_cfgs for o in c.origins})
226
+ methods = sorted({m for c in cors_cfgs for m in (c.methods or ["*"])})
227
+ headers = sorted({h for c in cors_cfgs for h in (c.headers or ["*"])})
228
+ body["cors_configuration"] = {
229
+ "allow_origins": origins,
230
+ "allow_methods": methods,
231
+ "allow_headers": headers,
232
+ "expose_headers": sorted({h for c in cors_cfgs for h in c.expose_headers}),
233
+ "allow_credentials": any(c.allow_credentials for c in cors_cfgs),
234
+ "max_age": max(c.max_age for c in cors_cfgs),
235
+ }
236
+ api = doc.resource("aws_apigatewayv2_api", "http", body)
237
+ doc.resource(
238
+ "aws_apigatewayv2_stage",
239
+ "default",
240
+ {"api_id": ref(api, "id"), "name": "$default", "auto_deploy": True, "tags": ctx.tags()},
241
+ )
242
+ return api
243
+
244
+ def _http(self, doc: TerraformDoc, ctx: RenderContext, api: str, name: str, lam: str, events: List[Any]) -> None:
245
+ integ = doc.resource(
246
+ "aws_apigatewayv2_integration",
247
+ name,
248
+ {
249
+ "api_id": ref(api, "id"),
250
+ "integration_type": "AWS_PROXY",
251
+ "integration_uri": ref(lam, "invoke_arn"),
252
+ "payload_format_version": "2.0",
253
+ "timeout_milliseconds": min(30000, ctx.config.timeout_of(ctx.config.functions[name]) * 1000),
254
+ },
255
+ )
256
+ for i, ev in enumerate(events):
257
+ route: Dict[str, Any] = {
258
+ "api_id": ref(api, "id"),
259
+ "route_key": f"{ev.method} {ev.path}",
260
+ "target": "integrations/" + ref(integ, "id"),
261
+ }
262
+ if ev.auth == "iam":
263
+ route["authorization_type"] = "AWS_IAM"
264
+ elif ev.auth == "jwt":
265
+ authz = doc.resource(
266
+ "aws_apigatewayv2_authorizer",
267
+ f"{name}-jwt-{i}",
268
+ {
269
+ "api_id": ref(api, "id"),
270
+ "authorizer_type": "JWT",
271
+ "name": f"{name}-jwt-{i}",
272
+ "identity_sources": [ev.authorizer.get("identity_source", "$request.header.Authorization")],
273
+ "jwt_configuration": {
274
+ "issuer": ev.authorizer["issuer"],
275
+ "audience": list(ev.authorizer["audience"])
276
+ if isinstance(ev.authorizer["audience"], list)
277
+ else [ev.authorizer["audience"]],
278
+ },
279
+ },
280
+ )
281
+ route["authorization_type"] = "JWT"
282
+ route["authorizer_id"] = ref(authz, "id")
283
+ if ev.authorizer.get("scopes"):
284
+ route["authorization_scopes"] = list(ev.authorizer["scopes"])
285
+ doc.resource("aws_apigatewayv2_route", f"{name}-{i}", route)
286
+ doc.resource(
287
+ "aws_lambda_permission",
288
+ f"{name}-apigw",
289
+ {
290
+ "statement_id": "AllowAPIGatewayInvoke",
291
+ "action": "lambda:InvokeFunction",
292
+ "function_name": ref(lam, "function_name"),
293
+ "principal": "apigateway.amazonaws.com",
294
+ "source_arn": ref(api, "execution_arn") + "/*/*",
295
+ },
296
+ )
297
+
298
+ def _scheduler_role(self, doc: TerraformDoc, ctx: RenderContext) -> str:
299
+ if not doc.has_resource("aws_iam_role", "scheduler"):
300
+ role = doc.resource(
301
+ "aws_iam_role",
302
+ "scheduler",
303
+ {"name": ctx.physical_name("scheduler")[:64], "assume_role_policy": _assume("scheduler.amazonaws.com")},
304
+ )
305
+ doc.resource(
306
+ "aws_iam_role_policy",
307
+ "scheduler-invoke",
308
+ {
309
+ "name": "invoke-lambda",
310
+ "role": ref(role, "id"),
311
+ "policy": _policy([{"Effect": "Allow", "Action": "lambda:InvokeFunction", "Resource": "*"}]),
312
+ },
313
+ )
314
+ return ref("aws_iam_role", "scheduler", "arn")
315
+
316
+ def _schedule(self, doc: TerraformDoc, ctx: RenderContext, name: str, lam: str, i: int, ev: Any) -> None:
317
+ role = self._scheduler_role(doc, ctx)
318
+ doc.resource(
319
+ "aws_scheduler_schedule",
320
+ f"{name}-{i}",
321
+ {
322
+ "name": ctx.physical_name(f"{name}-{ev.name or i}")[:64],
323
+ "group_name": "default",
324
+ "schedule_expression": sched.aws_expression(ev),
325
+ "schedule_expression_timezone": ev.timezone,
326
+ "state": "ENABLED" if ev.enabled else "DISABLED",
327
+ "flexible_time_window": {"mode": "OFF"},
328
+ "target": {
329
+ "arn": ref(lam, "arn"),
330
+ "role_arn": role,
331
+ "input": escape(json.dumps(envelope("schedule", ev.input, source="aws.scheduler"))),
332
+ "retry_policy": {"maximum_retry_attempts": 2},
333
+ },
334
+ },
335
+ )
336
+
337
+ def _queue_ref(self, doc: TerraformDoc, ctx: RenderContext, ev: Any, timeout: int) -> str:
338
+ qname = ev.name + (".fifo" if ev.fifo and not ev.name.endswith(".fifo") else "")
339
+ label = f"q-{qname}"
340
+ if ev.existing:
341
+ return doc.data("aws_sqs_queue", label, {"name": qname}) + ""
342
+ if not doc.has_resource("aws_sqs_queue", label):
343
+ body: Dict[str, Any] = {
344
+ "name": qname,
345
+ "visibility_timeout_seconds": ev.visibility_timeout or max(30, timeout * 6),
346
+ "message_retention_seconds": 1209600,
347
+ "tags": ctx.tags(),
348
+ }
349
+ if ev.fifo:
350
+ body["fifo_queue"] = True
351
+ body["content_based_deduplication"] = True
352
+ if ev.dead_letter:
353
+ dlq_name = ev.dead_letter + (".fifo" if ev.fifo and not ev.dead_letter.endswith(".fifo") else "")
354
+ dlq_label = f"q-{dlq_name}"
355
+ if not doc.has_resource("aws_sqs_queue", dlq_label):
356
+ dlq_body: Dict[str, Any] = {
357
+ "name": dlq_name,
358
+ "message_retention_seconds": 1209600,
359
+ "tags": ctx.tags(),
360
+ }
361
+ if ev.fifo:
362
+ dlq_body["fifo_queue"] = True
363
+ doc.resource("aws_sqs_queue", dlq_label, dlq_body)
364
+ body["redrive_policy"] = (
365
+ '{"deadLetterTargetArn":"'
366
+ + ref("aws_sqs_queue", tf_id(dlq_label), "arn")
367
+ + '","maxReceiveCount":'
368
+ + str(ev.max_receive)
369
+ + "}"
370
+ )
371
+ doc.resource("aws_sqs_queue", label, body)
372
+ return f"aws_sqs_queue.{tf_id(label)}"
373
+
374
+ def _queue(self, doc: TerraformDoc, ctx: RenderContext, name: str, lam: str, ev: Any) -> None:
375
+ q = self._queue_ref(doc, ctx, ev, ctx.config.timeout_of(ctx.config.functions[name]))
376
+ body: Dict[str, Any] = {
377
+ "event_source_arn": ref(q, "arn"),
378
+ "function_name": ref(lam, "arn"),
379
+ "batch_size": ev.batch_size,
380
+ "function_response_types": ["ReportBatchItemFailures"],
381
+ }
382
+ if ev.batch_window:
383
+ body["maximum_batching_window_in_seconds"] = ev.batch_window
384
+ doc.resource("aws_lambda_event_source_mapping", f"{name}-{ev.name}", body)
385
+
386
+ def _bucket_notifications(
387
+ self, doc: TerraformDoc, ctx: RenderContext, bucket: str, subs: List[Tuple[str, Any]], arns: Dict[str, str]
388
+ ) -> None:
389
+ label = f"b-{bucket}"
390
+ if any(not ev.existing for _, ev in subs):
391
+ if not doc.has_resource("aws_s3_bucket", label):
392
+ b = doc.resource("aws_s3_bucket", label, {"bucket": bucket, "tags": ctx.tags()})
393
+ doc.resource(
394
+ "aws_s3_bucket_public_access_block",
395
+ label,
396
+ {
397
+ "bucket": ref(b, "id"),
398
+ "block_public_acls": True,
399
+ "block_public_policy": True,
400
+ "ignore_public_acls": True,
401
+ "restrict_public_buckets": True,
402
+ },
403
+ )
404
+ bucket_ref = f"aws_s3_bucket.{tf_id(label)}"
405
+ else:
406
+ bucket_ref = doc.data("aws_s3_bucket", label, {"bucket": bucket})
407
+ perms = []
408
+ configs = []
409
+ for fname, ev in subs:
410
+ perm = doc.resource(
411
+ "aws_lambda_permission",
412
+ f"{fname}-s3-{bucket}",
413
+ {
414
+ "statement_id": f"AllowS3-{tf_id(bucket)}",
415
+ "action": "lambda:InvokeFunction",
416
+ "function_name": arns[fname],
417
+ "principal": "s3.amazonaws.com",
418
+ "source_arn": ref(bucket_ref, "arn"),
419
+ },
420
+ )
421
+ perms.append(perm)
422
+ events = [{"created": "s3:ObjectCreated:*", "deleted": "s3:ObjectRemoved:*"}[e] for e in ev.events]
423
+ entry: Dict[str, Any] = {"lambda_function_arn": arns[fname], "events": events}
424
+ if ev.prefix:
425
+ entry["filter_prefix"] = ev.prefix
426
+ if ev.suffix:
427
+ entry["filter_suffix"] = ev.suffix
428
+ configs.append(entry)
429
+ doc.resource(
430
+ "aws_s3_bucket_notification",
431
+ label,
432
+ {"bucket": ref(bucket_ref, "id"), "lambda_function": configs, "depends_on": perms},
433
+ )
434
+
435
+ def _topic(self, doc: TerraformDoc, ctx: RenderContext, name: str, lam: str, ev: Any) -> None:
436
+ label = f"t-{ev.name}"
437
+ if ev.existing:
438
+ topic = doc.data("aws_sns_topic", label, {"name": ev.name})
439
+ else:
440
+ if not doc.has_resource("aws_sns_topic", label):
441
+ doc.resource("aws_sns_topic", label, {"name": ev.name, "tags": ctx.tags()})
442
+ topic = f"aws_sns_topic.{tf_id(label)}"
443
+ sub: Dict[str, Any] = {"topic_arn": ref(topic, "arn"), "protocol": "lambda", "endpoint": ref(lam, "arn")}
444
+ if ev.filter:
445
+ sub["filter_policy"] = escape(json.dumps(ev.filter))
446
+ doc.resource("aws_sns_topic_subscription", f"{name}-{ev.name}", sub)
447
+ doc.resource(
448
+ "aws_lambda_permission",
449
+ f"{name}-sns-{ev.name}",
450
+ {
451
+ "statement_id": f"AllowSNS-{tf_id(ev.name)}",
452
+ "action": "lambda:InvokeFunction",
453
+ "function_name": ref(lam, "function_name"),
454
+ "principal": "sns.amazonaws.com",
455
+ "source_arn": ref(topic, "arn"),
456
+ },
457
+ )
458
+
459
+ def _workflows(self, doc: TerraformDoc, ctx: RenderContext, arns: Dict[str, str]) -> Dict[str, Any]:
460
+ out: Dict[str, Any] = {}
461
+ native = {n: wf for n, wf in ctx.config.workflows.items() if ctx.workflow_engines.get(n) == "native"}
462
+ if not native:
463
+ for n in ctx.config.workflows:
464
+ out[n] = {"id": ref(LAMBDA, tf_id(f"{n}-orchestrator"), "function_name")}
465
+ return out
466
+ role = doc.resource(
467
+ "aws_iam_role",
468
+ "states",
469
+ {"name": ctx.physical_name("states")[:64], "assume_role_policy": _assume("states.amazonaws.com")},
470
+ )
471
+ used = sorted({fn for wf in native.values() for fn in wf.task_functions()})
472
+ doc.resource(
473
+ "aws_iam_role_policy",
474
+ "states-invoke",
475
+ {
476
+ "name": "invoke-tasks",
477
+ "role": ref(role, "id"),
478
+ "policy": '{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"lambda:InvokeFunction","Resource":['
479
+ + ",".join(f'"{arns[f]}","{arns[f]}:*"' for f in used)
480
+ + "]}]}",
481
+ },
482
+ )
483
+ for name, wf in sorted(ctx.config.workflows.items()):
484
+ if name not in native:
485
+ out[name] = {"id": ref(LAMBDA, tf_id(f"{name}-orchestrator"), "function_name")}
486
+ continue
487
+ placeholders = {f: f"__PSKIT_ARN_{i}__" for i, f in enumerate(sorted(arns))}
488
+ asl = compile_asl(definition_dict(wf), placeholders.__getitem__, wf.description, wf.timeout or 0)
489
+ text = escape(json.dumps(asl, indent=2))
490
+ for f, token in placeholders.items():
491
+ text = text.replace(token, arns[f])
492
+ sm = doc.resource(
493
+ "aws_sfn_state_machine",
494
+ f"wf-{name}",
495
+ {
496
+ "name": ctx.physical_name(name)[:80],
497
+ "role_arn": ref(role, "arn"),
498
+ "type": str(wf.options.get("type", "STANDARD")).upper(),
499
+ "definition": text,
500
+ "tags": ctx.tags(),
501
+ },
502
+ )
503
+ out[name] = {
504
+ "id": ref(sm, "arn"),
505
+ "console": f"https://{ctx.region}.console.aws.amazon.com/states/home?region={ctx.region}#/statemachines/view/"
506
+ + ref(sm, "arn"),
507
+ }
508
+ return out