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.
- py_serverless_kit/__init__.py +47 -0
- py_serverless_kit/__main__.py +3 -0
- py_serverless_kit/_version.py +2 -0
- py_serverless_kit/cli/__init__.py +0 -0
- py_serverless_kit/cli/main.py +491 -0
- py_serverless_kit/cli/templates.py +212 -0
- py_serverless_kit/config/__init__.py +12 -0
- py_serverless_kit/config/loader.py +316 -0
- py_serverless_kit/config/schema.py +457 -0
- py_serverless_kit/core/__init__.py +0 -0
- py_serverless_kit/core/deployer.py +180 -0
- py_serverless_kit/core/describe.py +112 -0
- py_serverless_kit/core/project.py +147 -0
- py_serverless_kit/dashboard/__init__.py +3 -0
- py_serverless_kit/dashboard/generator.py +65 -0
- py_serverless_kit/dashboard/template.html +248 -0
- py_serverless_kit/local/__init__.py +3 -0
- py_serverless_kit/local/runner.py +234 -0
- py_serverless_kit/local/server.py +200 -0
- py_serverless_kit/packaging/__init__.py +3 -0
- py_serverless_kit/packaging/builder.py +252 -0
- py_serverless_kit/providers/__init__.py +15 -0
- py_serverless_kit/providers/alibaba.py +268 -0
- py_serverless_kit/providers/aws.py +508 -0
- py_serverless_kit/providers/azure.py +417 -0
- py_serverless_kit/providers/base.py +310 -0
- py_serverless_kit/providers/cloudflare.py +264 -0
- py_serverless_kit/providers/digitalocean.py +211 -0
- py_serverless_kit/providers/gcp.py +480 -0
- py_serverless_kit/providers/huawei.py +292 -0
- py_serverless_kit/providers/ibm.py +132 -0
- py_serverless_kit/providers/oracle.py +304 -0
- py_serverless_kit/providers/registry.py +60 -0
- py_serverless_kit/providers/schedule.py +153 -0
- py_serverless_kit/providers/tencent.py +275 -0
- py_serverless_kit/providers/terraform_doc.py +125 -0
- py_serverless_kit/runtime/__init__.py +6 -0
- py_serverless_kit/runtime/adapters/__init__.py +18 -0
- py_serverless_kit/runtime/adapters/_httpfn.py +91 -0
- py_serverless_kit/runtime/adapters/alibaba.py +147 -0
- py_serverless_kit/runtime/adapters/aws.py +201 -0
- py_serverless_kit/runtime/adapters/azure.py +185 -0
- py_serverless_kit/runtime/adapters/base.py +86 -0
- py_serverless_kit/runtime/adapters/cloudflare.py +126 -0
- py_serverless_kit/runtime/adapters/digitalocean.py +74 -0
- py_serverless_kit/runtime/adapters/gcp.py +176 -0
- py_serverless_kit/runtime/adapters/huawei.py +173 -0
- py_serverless_kit/runtime/adapters/ibm.py +91 -0
- py_serverless_kit/runtime/adapters/oracle.py +161 -0
- py_serverless_kit/runtime/adapters/tencent.py +151 -0
- py_serverless_kit/runtime/batch.py +47 -0
- py_serverless_kit/runtime/context.py +45 -0
- py_serverless_kit/runtime/dispatch.py +277 -0
- py_serverless_kit/runtime/errors.py +83 -0
- py_serverless_kit/runtime/event.py +267 -0
- py_serverless_kit/runtime/invoke.py +119 -0
- py_serverless_kit/runtime/log.py +69 -0
- py_serverless_kit/runtime/manifest.py +121 -0
- py_serverless_kit/runtime/response.py +177 -0
- py_serverless_kit/runtime/routing.py +74 -0
- py_serverless_kit/runtime/workflow.py +312 -0
- py_serverless_kit/terraform/__init__.py +3 -0
- py_serverless_kit/terraform/runner.py +104 -0
- py_serverless_kit/workflows/__init__.py +35 -0
- py_serverless_kit/workflows/asl.py +170 -0
- py_serverless_kit/workflows/gcp.py +328 -0
- py_serverless_kit-0.1.0.dist-info/METADATA +300 -0
- py_serverless_kit-0.1.0.dist-info/RECORD +71 -0
- py_serverless_kit-0.1.0.dist-info/WHEEL +4 -0
- py_serverless_kit-0.1.0.dist-info/entry_points.txt +3 -0
- py_serverless_kit-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
"""Tencent Cloud: SCF + API Gateway + timer / COS triggers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import re
|
|
7
|
+
import shutil
|
|
8
|
+
from typing import Any, Dict, List, Optional
|
|
9
|
+
|
|
10
|
+
from ..config.schema import FunctionConfig, ServiceConfig
|
|
11
|
+
from ..runtime.adapters.base import envelope
|
|
12
|
+
from . import schedule as sched
|
|
13
|
+
from .base import HTTP, SCHEDULE, STORAGE, Issue, Provider, RenderContext
|
|
14
|
+
from .terraform_doc import TerraformDoc, escape, ref, tf_id
|
|
15
|
+
|
|
16
|
+
FUNCTION = "tencentcloud_scf_function"
|
|
17
|
+
_BUCKET_RE = re.compile(r"^[a-z0-9][a-z0-9-]*-\d+$")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _gateway_path(path: str) -> str:
|
|
21
|
+
"""API Gateway has no greedy params; ``/files/{p+}`` becomes prefix ``/files``."""
|
|
22
|
+
segments = [s for s in path.split("/") if s]
|
|
23
|
+
if segments and segments[-1].endswith("+}"):
|
|
24
|
+
segments = segments[:-1]
|
|
25
|
+
return "/" + "/".join(segments)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class TencentProvider(Provider):
|
|
29
|
+
name = "tencent"
|
|
30
|
+
display_name = "Tencent Cloud SCF"
|
|
31
|
+
tier = "beta"
|
|
32
|
+
terraform_source = "tencentcloudstack/tencentcloud"
|
|
33
|
+
terraform_version = ">= 1.81.0"
|
|
34
|
+
terraform_local_name = "tencentcloud"
|
|
35
|
+
default_region = "ap-guangzhou"
|
|
36
|
+
capabilities = frozenset({HTTP, SCHEDULE, STORAGE})
|
|
37
|
+
max_name_length = 60
|
|
38
|
+
name_pattern = r"[^a-z0-9_-]"
|
|
39
|
+
runtimes = {"python3.9": "Python3.9", "python3.10": "Python3.10"}
|
|
40
|
+
vendor_dependencies = True
|
|
41
|
+
max_timeout = 900
|
|
42
|
+
notes = (
|
|
43
|
+
"HTTP via an API Gateway service (one API per route, released to `release`); "
|
|
44
|
+
"timer and COS triggers; COS bucket names must include the APPID suffix (name-1250000000)."
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
# ------------------------------------------------------------------ bundle
|
|
48
|
+
def shim_files(self, config: ServiceConfig) -> Dict[str, str]:
|
|
49
|
+
return {
|
|
50
|
+
"index.py": (
|
|
51
|
+
"# Generated by py-serverless-kit. SCF handler: index.main_handler\n"
|
|
52
|
+
"from py_serverless_kit.runtime.adapters.tencent import main_handler # noqa: F401\n"
|
|
53
|
+
)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
def provider_block(self, ctx: RenderContext) -> Dict[str, Any]:
|
|
57
|
+
block: Dict[str, Any] = {"region": ctx.region}
|
|
58
|
+
if ctx.opt("profile"):
|
|
59
|
+
block["profile"] = ctx.opt("profile")
|
|
60
|
+
return block
|
|
61
|
+
|
|
62
|
+
def physical_name(self, service: str, stage: str, logical: str) -> str:
|
|
63
|
+
name = super().physical_name(service, stage, logical)
|
|
64
|
+
# SCF names must start with a letter and must not end with '-' or '_'.
|
|
65
|
+
return name.rstrip("-_") or "fn"
|
|
66
|
+
|
|
67
|
+
def extra_validation(self, config: ServiceConfig) -> List[Issue]:
|
|
68
|
+
issues = []
|
|
69
|
+
if config.provider.iam.statements:
|
|
70
|
+
issues.append(
|
|
71
|
+
Issue(
|
|
72
|
+
"warning",
|
|
73
|
+
"iam.statements are not translated to CAM policies on Tencent; set iam.role to an existing "
|
|
74
|
+
"CAM role name",
|
|
75
|
+
"provider.iam",
|
|
76
|
+
)
|
|
77
|
+
)
|
|
78
|
+
for name, fn in config.functions.items():
|
|
79
|
+
where = f"functions.{name}"
|
|
80
|
+
for ev in fn.events_of("http"):
|
|
81
|
+
if ev.auth != "none":
|
|
82
|
+
issues.append(
|
|
83
|
+
Issue("error", f"http auth `{ev.auth}` is not supported on Tencent (use none)", where)
|
|
84
|
+
)
|
|
85
|
+
if ev.method == "PATCH":
|
|
86
|
+
issues.append(Issue("error", "Tencent API Gateway does not support PATCH; use ANY", where))
|
|
87
|
+
for ev in fn.events_of("storage"):
|
|
88
|
+
if not _BUCKET_RE.match(ev.bucket):
|
|
89
|
+
issues.append(
|
|
90
|
+
Issue(
|
|
91
|
+
"error",
|
|
92
|
+
f"COS bucket {ev.bucket!r} must be '<name>-<APPID>', e.g. uploads-1250000000",
|
|
93
|
+
where,
|
|
94
|
+
)
|
|
95
|
+
)
|
|
96
|
+
memory = config.memory_of(fn)
|
|
97
|
+
if memory not in (64, 128) and memory % 128 != 0:
|
|
98
|
+
issues.append(
|
|
99
|
+
Issue("error", f"SCF memory must be 64, 128 or a multiple of 128 MB (got {memory})", where)
|
|
100
|
+
)
|
|
101
|
+
return issues
|
|
102
|
+
|
|
103
|
+
# ------------------------------------------------------------------ render
|
|
104
|
+
def render_resources(self, doc: TerraformDoc, ctx: RenderContext) -> None:
|
|
105
|
+
cfg = ctx.config
|
|
106
|
+
zip_name = self._artifact_copy(ctx)
|
|
107
|
+
fn_out: Dict[str, Dict[str, Any]] = {}
|
|
108
|
+
service: Optional[str] = None
|
|
109
|
+
apis: List[str] = []
|
|
110
|
+
for name, fn in ctx.function_items():
|
|
111
|
+
res = self._function(doc, ctx, name, fn, zip_name)
|
|
112
|
+
namespace = ctx.opt("namespace", "default")
|
|
113
|
+
out: Dict[str, Any] = {
|
|
114
|
+
"id": ref(res, "name"),
|
|
115
|
+
"console": "https://console.cloud.tencent.com/scf/list-detail"
|
|
116
|
+
f"?ns={namespace}&id={ctx.physical_name(name)}",
|
|
117
|
+
}
|
|
118
|
+
http_events = fn.events_of("http")
|
|
119
|
+
if http_events:
|
|
120
|
+
service = service or self._service(doc, ctx)
|
|
121
|
+
apis.extend(self._apis(doc, ctx, service, name, res, fn, http_events))
|
|
122
|
+
out["base_url"] = "https://" + ref(service, "outer_sub_domain") + "/release"
|
|
123
|
+
fn_out[name] = out
|
|
124
|
+
|
|
125
|
+
endpoint = None
|
|
126
|
+
if service:
|
|
127
|
+
doc.resource(
|
|
128
|
+
"tencentcloud_api_gateway_service_release",
|
|
129
|
+
"release",
|
|
130
|
+
{
|
|
131
|
+
"service_id": ref(service, "id"),
|
|
132
|
+
"environment_name": "release",
|
|
133
|
+
"release_desc": f"py-serverless-kit {ctx.artifact.sha256[:12]}",
|
|
134
|
+
"depends_on": apis,
|
|
135
|
+
},
|
|
136
|
+
)
|
|
137
|
+
endpoint = "https://" + ref(service, "outer_sub_domain") + "/release"
|
|
138
|
+
|
|
139
|
+
wf_out = {n: {"id": ref(FUNCTION, tf_id(f"{n}-orchestrator"), "name")} for n in sorted(cfg.workflows)}
|
|
140
|
+
doc.output(
|
|
141
|
+
"pskit",
|
|
142
|
+
{"functions": fn_out, "workflows": wf_out, "endpoint": endpoint},
|
|
143
|
+
description="py-serverless-kit deployment summary",
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
# ------------------------------------------------------------------ pieces
|
|
147
|
+
@staticmethod
|
|
148
|
+
def _artifact_copy(ctx: RenderContext) -> str:
|
|
149
|
+
"""SCF only re-uploads when ``zip_file`` *path* changes, so use a content-addressed copy."""
|
|
150
|
+
name = f"artifact-{ctx.artifact.sha256[:16]}.zip"
|
|
151
|
+
target = ctx.work_dir / name
|
|
152
|
+
for old in ctx.work_dir.glob("artifact-*.zip"):
|
|
153
|
+
if old.name != name:
|
|
154
|
+
old.unlink()
|
|
155
|
+
if not target.exists() and ctx.artifact.path.exists():
|
|
156
|
+
shutil.copyfile(ctx.artifact.path, target)
|
|
157
|
+
return name
|
|
158
|
+
|
|
159
|
+
def _function(self, doc: TerraformDoc, ctx: RenderContext, name: str, fn: FunctionConfig, zip_name: str) -> str:
|
|
160
|
+
cfg = ctx.config
|
|
161
|
+
triggers: List[Dict[str, Any]] = []
|
|
162
|
+
configs: List[Dict[str, Any]] = []
|
|
163
|
+
for i, ev in enumerate(fn.events_of("schedule")):
|
|
164
|
+
tname = re.sub(r"[^A-Za-z0-9_-]", "-", ev.name or f"timer-{i}")[:100]
|
|
165
|
+
desc = "0 " + sched.named_cron5(ev) + " *"
|
|
166
|
+
triggers.append({"name": tname, "type": "timer", "trigger_desc": desc})
|
|
167
|
+
if ev.input is not None or not ev.enabled:
|
|
168
|
+
configs.append(
|
|
169
|
+
{
|
|
170
|
+
"trigger_name": tname,
|
|
171
|
+
"type": "timer",
|
|
172
|
+
"trigger_desc": desc,
|
|
173
|
+
"enable": "OPEN" if ev.enabled else "CLOSE",
|
|
174
|
+
"custom_argument": escape(json.dumps(envelope("schedule", ev.input, source="tencent.timer"))),
|
|
175
|
+
}
|
|
176
|
+
)
|
|
177
|
+
for ev in fn.events_of("storage"):
|
|
178
|
+
label = f"b-{ev.bucket}"
|
|
179
|
+
if not ev.existing and not doc.has_resource("tencentcloud_cos_bucket", label):
|
|
180
|
+
doc.resource(
|
|
181
|
+
"tencentcloud_cos_bucket", label, {"bucket": ev.bucket, "acl": "private", "tags": ctx.tags()}
|
|
182
|
+
)
|
|
183
|
+
for action in ev.events:
|
|
184
|
+
desc_obj: Dict[str, Any] = {
|
|
185
|
+
"event": {"created": "cos:ObjectCreated:*", "deleted": "cos:ObjectRemove:*"}[action]
|
|
186
|
+
}
|
|
187
|
+
if ev.prefix or ev.suffix:
|
|
188
|
+
desc_obj["filter"] = {"Prefix": ev.prefix or "", "Suffix": ev.suffix or ""}
|
|
189
|
+
triggers.append(
|
|
190
|
+
{
|
|
191
|
+
"name": f"{ev.bucket}.cos.{ctx.region}.myqcloud.com",
|
|
192
|
+
"type": "cos",
|
|
193
|
+
"cos_region": ctx.region,
|
|
194
|
+
"trigger_desc": json.dumps(desc_obj),
|
|
195
|
+
}
|
|
196
|
+
)
|
|
197
|
+
body: Dict[str, Any] = {
|
|
198
|
+
"name": ctx.physical_name(name),
|
|
199
|
+
"description": re.sub(r"[^\w\s,.一-鿿]", " ", fn.description)[:1000],
|
|
200
|
+
"handler": "index.main_handler",
|
|
201
|
+
"runtime": self.runtime_id(cfg.runtime_of(fn)),
|
|
202
|
+
"mem_size": cfg.memory_of(fn),
|
|
203
|
+
"timeout": cfg.timeout_of(fn),
|
|
204
|
+
"namespace": ctx.opt("namespace", "default"),
|
|
205
|
+
"zip_file": "${path.module}/" + zip_name,
|
|
206
|
+
"enable_public_net": True,
|
|
207
|
+
"environment": {k: escape(v) for k, v in ctx.environment(name, fn).items()},
|
|
208
|
+
"tags": ctx.tags(fn),
|
|
209
|
+
}
|
|
210
|
+
if triggers:
|
|
211
|
+
body["triggers"] = triggers
|
|
212
|
+
role = fn.iam.role or cfg.provider.iam.role
|
|
213
|
+
if role:
|
|
214
|
+
body["role"] = role
|
|
215
|
+
body.update(fn.options) # escape hatch: vpc_id, subnet_id, layers, cls_*...
|
|
216
|
+
res = doc.resource(FUNCTION, name, body)
|
|
217
|
+
for c in configs:
|
|
218
|
+
doc.resource(
|
|
219
|
+
"tencentcloud_scf_trigger_config",
|
|
220
|
+
f"{name}-{c['trigger_name']}",
|
|
221
|
+
{"function_name": ref(res, "name"), "namespace": ref(res, "namespace"), **c},
|
|
222
|
+
)
|
|
223
|
+
return res
|
|
224
|
+
|
|
225
|
+
def _service(self, doc: TerraformDoc, ctx: RenderContext) -> str:
|
|
226
|
+
return doc.resource(
|
|
227
|
+
"tencentcloud_api_gateway_service",
|
|
228
|
+
"http",
|
|
229
|
+
{
|
|
230
|
+
"service_name": re.sub(r"[^A-Za-z0-9_]", "_", ctx.prefix)[:50],
|
|
231
|
+
"protocol": "http&https",
|
|
232
|
+
"service_desc": f"py-serverless-kit {ctx.service} ({ctx.stage})",
|
|
233
|
+
"net_type": ["OUTER"],
|
|
234
|
+
"ip_version": "IPv4",
|
|
235
|
+
"tags": ctx.tags(),
|
|
236
|
+
},
|
|
237
|
+
)
|
|
238
|
+
|
|
239
|
+
def _apis(
|
|
240
|
+
self,
|
|
241
|
+
doc: TerraformDoc,
|
|
242
|
+
ctx: RenderContext,
|
|
243
|
+
service: str,
|
|
244
|
+
name: str,
|
|
245
|
+
fn_res: str,
|
|
246
|
+
fn: FunctionConfig,
|
|
247
|
+
events: List[Any],
|
|
248
|
+
) -> List[str]:
|
|
249
|
+
refs = []
|
|
250
|
+
for i, ev in enumerate(events):
|
|
251
|
+
path = _gateway_path(ev.path)
|
|
252
|
+
params = re.findall(r"\{([A-Za-z_]\w*)\}", path)
|
|
253
|
+
body: Dict[str, Any] = {
|
|
254
|
+
"service_id": ref(service, "id"),
|
|
255
|
+
"api_name": re.sub(r"[^A-Za-z0-9_]", "_", f"{name}_{i}")[:60],
|
|
256
|
+
"api_desc": f"{ev.method} {ev.path} -> {name}",
|
|
257
|
+
"auth_type": "NONE",
|
|
258
|
+
"protocol": "HTTP",
|
|
259
|
+
"enable_cors": ev.cors_config is not None,
|
|
260
|
+
"request_config_path": path,
|
|
261
|
+
"request_config_method": ev.method,
|
|
262
|
+
"service_config_type": "SCF",
|
|
263
|
+
"service_config_scf_function_name": ref(fn_res, "name"),
|
|
264
|
+
"service_config_scf_function_namespace": ref(fn_res, "namespace"),
|
|
265
|
+
"service_config_scf_function_qualifier": "$LATEST",
|
|
266
|
+
"service_config_scf_is_integrated_response": True,
|
|
267
|
+
"service_config_timeout": min(ctx.config.timeout_of(fn), 1800),
|
|
268
|
+
"response_type": "JSON",
|
|
269
|
+
}
|
|
270
|
+
if params:
|
|
271
|
+
body["request_parameters"] = [
|
|
272
|
+
{"name": p, "position": "PATH", "type": "string", "required": True, "desc": p} for p in params
|
|
273
|
+
]
|
|
274
|
+
refs.append(doc.resource("tencentcloud_api_gateway_api", f"{name}-{i}", body))
|
|
275
|
+
return refs
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
"""Builder for Terraform JSON configuration (``main.tf.json``).
|
|
2
|
+
|
|
3
|
+
Terraform reads ``*.tf.json`` natively. Generating JSON (instead of HCL text)
|
|
4
|
+
removes a whole class of quoting / escaping bugs.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import re
|
|
11
|
+
from typing import Any, Dict, List, Optional
|
|
12
|
+
|
|
13
|
+
from ..config.loader import deep_merge
|
|
14
|
+
|
|
15
|
+
_TF_REF = re.compile(r"\$\$\{\s*tf:([^}]+)\}")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def tf_id(name: str) -> str:
|
|
19
|
+
"""Make a valid Terraform block label from an arbitrary logical name."""
|
|
20
|
+
ident = re.sub(r"[^A-Za-z0-9_]", "_", name)
|
|
21
|
+
if not re.match(r"[A-Za-z_]", ident):
|
|
22
|
+
ident = "_" + ident
|
|
23
|
+
return ident
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def ref(*parts: str) -> str:
|
|
27
|
+
"""``ref("aws_lambda_function", "hello", "arn")`` -> ``${aws_lambda_function.hello.arn}``."""
|
|
28
|
+
return "${" + ".".join(parts) + "}"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def escape(value: str) -> str:
|
|
32
|
+
"""Escape literal ``${`` / ``%{`` so Terraform does not treat them as templates.
|
|
33
|
+
|
|
34
|
+
The explicit ``${tf:<expression>}`` form is the one way to reference Terraform
|
|
35
|
+
from user values, e.g. ``${tf:aws_dynamodb_table.orders.arn}`` in an IAM
|
|
36
|
+
statement or environment variable.
|
|
37
|
+
"""
|
|
38
|
+
escaped = value.replace("${", "$${").replace("%{", "%%{")
|
|
39
|
+
return _TF_REF.sub(lambda m: "${" + m.group(1).strip() + "}", escaped)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class TerraformDoc:
|
|
43
|
+
def __init__(self) -> None:
|
|
44
|
+
self.terraform: Dict[str, Any] = {"required_providers": {}}
|
|
45
|
+
self.providers: Dict[str, Any] = {}
|
|
46
|
+
self.resources: Dict[str, Dict[str, Any]] = {}
|
|
47
|
+
self.data_sources: Dict[str, Dict[str, Any]] = {}
|
|
48
|
+
self.outputs: Dict[str, Any] = {}
|
|
49
|
+
self.locals: Dict[str, Any] = {}
|
|
50
|
+
self.variables: Dict[str, Any] = {}
|
|
51
|
+
self._extra: Dict[str, Any] = {}
|
|
52
|
+
self.warnings: List[str] = []
|
|
53
|
+
|
|
54
|
+
# ----------------------------------------------------------------- blocks
|
|
55
|
+
def required_provider(self, name: str, source: str, version: Optional[str] = None) -> None:
|
|
56
|
+
spec: Dict[str, Any] = {"source": source}
|
|
57
|
+
if version:
|
|
58
|
+
spec["version"] = version
|
|
59
|
+
self.terraform["required_providers"][name] = spec
|
|
60
|
+
|
|
61
|
+
def required_version(self, constraint: str) -> None:
|
|
62
|
+
self.terraform["required_version"] = constraint
|
|
63
|
+
|
|
64
|
+
def backend(self, kind: str, config: Dict[str, Any]) -> None:
|
|
65
|
+
self.terraform["backend"] = {kind: config}
|
|
66
|
+
|
|
67
|
+
def provider(self, name: str, body: Dict[str, Any]) -> None:
|
|
68
|
+
self.providers.setdefault(name, []).append(body)
|
|
69
|
+
|
|
70
|
+
def resource(self, rtype: str, name: str, body: Dict[str, Any]) -> str:
|
|
71
|
+
bucket = self.resources.setdefault(rtype, {})
|
|
72
|
+
label = tf_id(name)
|
|
73
|
+
if label in bucket:
|
|
74
|
+
raise ValueError(f"duplicate terraform resource {rtype}.{label}")
|
|
75
|
+
bucket[label] = body
|
|
76
|
+
return f"{rtype}.{label}"
|
|
77
|
+
|
|
78
|
+
def has_resource(self, rtype: str, name: str) -> bool:
|
|
79
|
+
return tf_id(name) in self.resources.get(rtype, {})
|
|
80
|
+
|
|
81
|
+
def data(self, dtype: str, name: str, body: Dict[str, Any]) -> str:
|
|
82
|
+
bucket = self.data_sources.setdefault(dtype, {})
|
|
83
|
+
label = tf_id(name)
|
|
84
|
+
bucket.setdefault(label, body)
|
|
85
|
+
return f"data.{dtype}.{label}"
|
|
86
|
+
|
|
87
|
+
def output(self, name: str, value: Any, *, sensitive: bool = False, description: str = "") -> None:
|
|
88
|
+
body: Dict[str, Any] = {"value": value}
|
|
89
|
+
if sensitive:
|
|
90
|
+
body["sensitive"] = True
|
|
91
|
+
if description:
|
|
92
|
+
body["description"] = description
|
|
93
|
+
self.outputs[tf_id(name)] = body
|
|
94
|
+
|
|
95
|
+
def local(self, name: str, value: Any) -> None:
|
|
96
|
+
self.locals[name] = value
|
|
97
|
+
|
|
98
|
+
def merge(self, extra: Dict[str, Any]) -> None:
|
|
99
|
+
"""Deep-merge user supplied raw Terraform JSON (escape hatch)."""
|
|
100
|
+
self._extra = deep_merge(self._extra, extra)
|
|
101
|
+
|
|
102
|
+
# ----------------------------------------------------------------- output
|
|
103
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
104
|
+
doc: Dict[str, Any] = {"terraform": self.terraform}
|
|
105
|
+
if self.providers:
|
|
106
|
+
doc["provider"] = self.providers
|
|
107
|
+
if self.variables:
|
|
108
|
+
doc["variable"] = self.variables
|
|
109
|
+
if self.locals:
|
|
110
|
+
doc["locals"] = self.locals
|
|
111
|
+
if self.data_sources:
|
|
112
|
+
doc["data"] = self.data_sources
|
|
113
|
+
if self.resources:
|
|
114
|
+
doc["resource"] = self.resources
|
|
115
|
+
if self.outputs:
|
|
116
|
+
doc["output"] = self.outputs
|
|
117
|
+
if self._extra:
|
|
118
|
+
doc = deep_merge(doc, self._extra)
|
|
119
|
+
return doc
|
|
120
|
+
|
|
121
|
+
def to_json(self) -> str:
|
|
122
|
+
return json.dumps(self.to_dict(), indent=2, sort_keys=False) + "\n"
|
|
123
|
+
|
|
124
|
+
def count(self) -> int:
|
|
125
|
+
return sum(len(v) for v in self.resources.values())
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
"""Portable function runtime.
|
|
2
|
+
|
|
3
|
+
Everything in this package MUST stay standard-library only and compatible with
|
|
4
|
+
Python 3.9+, because it is vendored into every deployment artifact and executed
|
|
5
|
+
on runtimes we do not control (Tencent / Huawei still default to 3.9).
|
|
6
|
+
"""
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""Per-provider translation between native payloads and the portable model.
|
|
2
|
+
|
|
3
|
+
Each module exposes at least ``to_event(native, native_context)`` and a
|
|
4
|
+
provider-specific entrypoint used by the generated shim in the bundle.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
ADAPTERS = (
|
|
8
|
+
"aws",
|
|
9
|
+
"azure",
|
|
10
|
+
"gcp",
|
|
11
|
+
"alibaba",
|
|
12
|
+
"oracle",
|
|
13
|
+
"ibm",
|
|
14
|
+
"digitalocean",
|
|
15
|
+
"tencent",
|
|
16
|
+
"huawei",
|
|
17
|
+
"cloudflare",
|
|
18
|
+
)
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
"""Shared logic for providers where *every* invocation arrives as an HTTP-ish
|
|
2
|
+
request (IBM Code Engine, DigitalOcean web functions, Cloudflare Workers,
|
|
3
|
+
OCI behind API Gateway).
|
|
4
|
+
|
|
5
|
+
A request whose JSON body carries the ``__pskit`` envelope is treated as a
|
|
6
|
+
non-HTTP event (``invoke()``, workflow task, scheduler with input): the handler
|
|
7
|
+
result is returned as JSON 200, and failures as JSON 500
|
|
8
|
+
``{"errorType", "cause"}`` - the same contract the GCP adapter uses.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import base64
|
|
14
|
+
from typing import Any, Dict, Optional, Tuple
|
|
15
|
+
|
|
16
|
+
from ..context import Context
|
|
17
|
+
from ..dispatch import execute, execute_async
|
|
18
|
+
from ..event import Event, EventType, HttpRequest
|
|
19
|
+
from ..response import Response, dumps
|
|
20
|
+
from .base import maybe_json, unwrap_envelope
|
|
21
|
+
|
|
22
|
+
NativeHttp = Tuple[int, Dict[str, str], str, bool] # status, headers, body, is_base64
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def event_from_request(req: HttpRequest, provider: str, function: str, raw: Any = None) -> Event:
|
|
26
|
+
if req.method == "POST" and req.body[:1] == b"{":
|
|
27
|
+
wrapped = unwrap_envelope(req.body)
|
|
28
|
+
if wrapped is not None:
|
|
29
|
+
kind, data, meta = wrapped
|
|
30
|
+
return Event(type=kind, provider=provider, function=function, data=data, source=meta.get("source"), raw=raw)
|
|
31
|
+
return Event(type=EventType.HTTP, provider=provider, function=function, http=req, raw=raw)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def error_body(exc: BaseException) -> Dict[str, Any]:
|
|
35
|
+
return {"errorType": getattr(exc, "error", None) or type(exc).__name__, "cause": str(exc)}
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def result_to_http(event: Event, result: Any) -> NativeHttp:
|
|
39
|
+
if isinstance(result, Response):
|
|
40
|
+
return result.render_for_gateway()
|
|
41
|
+
return 200, {"content-type": "application/json"}, dumps(result), False
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def run(event: Event, context: Context) -> NativeHttp:
|
|
45
|
+
"""Execute and always return an HTTP-shaped result."""
|
|
46
|
+
if event.http is not None:
|
|
47
|
+
return execute(event, context).render_for_gateway()
|
|
48
|
+
try:
|
|
49
|
+
result = execute(event, context)
|
|
50
|
+
except Exception as exc: # noqa: BLE001 - reported to the caller as JSON
|
|
51
|
+
return 500, {"content-type": "application/json"}, dumps(error_body(exc)), False
|
|
52
|
+
return result_to_http(event, result)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
async def run_async(event: Event, context: Context) -> NativeHttp:
|
|
56
|
+
if event.http is not None:
|
|
57
|
+
resp = await execute_async(event, context)
|
|
58
|
+
return resp.render_for_gateway()
|
|
59
|
+
try:
|
|
60
|
+
result = await execute_async(event, context)
|
|
61
|
+
except Exception as exc: # noqa: BLE001
|
|
62
|
+
return 500, {"content-type": "application/json"}, dumps(error_body(exc)), False
|
|
63
|
+
return result_to_http(event, result)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def decode_body(body: Any, headers: Dict[str, Any], is_base64: Optional[bool] = None) -> bytes:
|
|
67
|
+
"""Decode a body that may be base64 when the content type is binary."""
|
|
68
|
+
if body is None:
|
|
69
|
+
return b""
|
|
70
|
+
if isinstance(body, (dict, list)):
|
|
71
|
+
return dumps(body).encode("utf-8")
|
|
72
|
+
if isinstance(body, bytes):
|
|
73
|
+
return body
|
|
74
|
+
text = str(body)
|
|
75
|
+
if is_base64 is None:
|
|
76
|
+
ctype = ""
|
|
77
|
+
for k, v in headers.items():
|
|
78
|
+
if str(k).lower() == "content-type":
|
|
79
|
+
ctype = str(v).lower()
|
|
80
|
+
textual = (not ctype) or ctype.startswith("text/") or "json" in ctype or "xml" in ctype or "form" in ctype
|
|
81
|
+
is_base64 = not textual
|
|
82
|
+
if is_base64:
|
|
83
|
+
try:
|
|
84
|
+
return base64.b64decode(text, validate=True)
|
|
85
|
+
except (ValueError, TypeError):
|
|
86
|
+
return text.encode("utf-8")
|
|
87
|
+
return text.encode("utf-8")
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def json_or_text(text: str) -> Any:
|
|
91
|
+
return maybe_json(text)
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
"""Alibaba Cloud Function Compute 3.0 adapter.
|
|
2
|
+
|
|
3
|
+
Handles HTTP triggers (event handler mode), timer triggers, OSS triggers,
|
|
4
|
+
MNS topic triggers and direct invocation. FC passes the event as ``bytes``.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import time
|
|
10
|
+
from typing import Any, Dict, List
|
|
11
|
+
|
|
12
|
+
from ..context import Context
|
|
13
|
+
from ..dispatch import execute
|
|
14
|
+
from ..event import Event, EventType, Headers, HttpRequest, Record, parse_time
|
|
15
|
+
from ..response import Response
|
|
16
|
+
from .base import action_from, function_name, maybe_json, unwrap_envelope
|
|
17
|
+
|
|
18
|
+
PROVIDER = "alibaba"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _http(evt: Dict[str, Any]) -> HttpRequest:
|
|
22
|
+
rc = evt.get("requestContext") or {}
|
|
23
|
+
http = rc.get("http") or {}
|
|
24
|
+
query: Dict[str, Any] = {}
|
|
25
|
+
multi: Dict[str, List[str]] = {}
|
|
26
|
+
for key, value in (evt.get("queryParameters") or {}).items():
|
|
27
|
+
values = [str(v) for v in value] if isinstance(value, list) else [str(value)]
|
|
28
|
+
multi[key] = values
|
|
29
|
+
query[key] = values[-1] if values else ""
|
|
30
|
+
return HttpRequest(
|
|
31
|
+
method=http.get("method", "GET"),
|
|
32
|
+
path=evt.get("rawPath") or http.get("path") or "/",
|
|
33
|
+
headers=Headers(evt.get("headers") or {}),
|
|
34
|
+
query=query,
|
|
35
|
+
multi_query=multi,
|
|
36
|
+
body=HttpRequest.decode_body(evt.get("body"), bool(evt.get("isBase64Encoded"))),
|
|
37
|
+
source_ip=http.get("sourceIp"),
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _oss(evt: Dict[str, Any], fn: str) -> Event:
|
|
42
|
+
records = []
|
|
43
|
+
for r in evt.get("events") or []:
|
|
44
|
+
oss = r.get("oss") or {}
|
|
45
|
+
obj = oss.get("object") or {}
|
|
46
|
+
records.append(
|
|
47
|
+
Record(
|
|
48
|
+
id=(r.get("responseElements") or {}).get("requestId"),
|
|
49
|
+
bucket=(oss.get("bucket") or {}).get("name"),
|
|
50
|
+
key=obj.get("key"),
|
|
51
|
+
size=obj.get("size"),
|
|
52
|
+
action=action_from(r.get("eventName", "")),
|
|
53
|
+
attributes={"event_name": r.get("eventName"), "etag": obj.get("eTag")},
|
|
54
|
+
source="acs:oss",
|
|
55
|
+
raw=r,
|
|
56
|
+
)
|
|
57
|
+
)
|
|
58
|
+
first = (evt.get("events") or [{}])[0]
|
|
59
|
+
return Event(
|
|
60
|
+
type=EventType.STORAGE,
|
|
61
|
+
provider=PROVIDER,
|
|
62
|
+
function=fn,
|
|
63
|
+
records=records,
|
|
64
|
+
source="acs:oss",
|
|
65
|
+
time=parse_time(first.get("eventTime")),
|
|
66
|
+
raw=evt,
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def to_event(native: Any, ctx: Any = None) -> Event:
|
|
71
|
+
fn = function_name(getattr(getattr(ctx, "function", None), "name", "") or "")
|
|
72
|
+
evt = maybe_json(native)
|
|
73
|
+
wrapped = unwrap_envelope(evt)
|
|
74
|
+
if wrapped is not None:
|
|
75
|
+
kind, data, meta = wrapped
|
|
76
|
+
return Event(type=kind, provider=PROVIDER, function=fn, data=data, source=meta.get("source"), raw=evt)
|
|
77
|
+
if isinstance(evt, dict):
|
|
78
|
+
rc = evt.get("requestContext")
|
|
79
|
+
if isinstance(rc, dict) and isinstance(rc.get("http"), dict):
|
|
80
|
+
return Event(type=EventType.HTTP, provider=PROVIDER, function=fn, http=_http(evt), raw=evt)
|
|
81
|
+
if "triggerTime" in evt and "triggerName" in evt:
|
|
82
|
+
payload = evt.get("payload")
|
|
83
|
+
inner = unwrap_envelope(payload) if payload else None
|
|
84
|
+
return Event(
|
|
85
|
+
type=EventType.SCHEDULE,
|
|
86
|
+
provider=PROVIDER,
|
|
87
|
+
function=fn,
|
|
88
|
+
data=inner[1] if inner else maybe_json(payload) if payload else None,
|
|
89
|
+
time=parse_time(evt.get("triggerTime")),
|
|
90
|
+
source=evt.get("triggerName"),
|
|
91
|
+
raw=evt,
|
|
92
|
+
)
|
|
93
|
+
events = evt.get("events")
|
|
94
|
+
if isinstance(events, list) and events and isinstance(events[0], dict) and "oss" in events[0]:
|
|
95
|
+
return _oss(evt, fn)
|
|
96
|
+
if "TopicName" in evt and "Message" in evt: # MNS topic, JSON notify format
|
|
97
|
+
record = Record(
|
|
98
|
+
id=evt.get("MessageId"),
|
|
99
|
+
body=evt.get("Message"),
|
|
100
|
+
attributes={k: v for k, v in evt.items() if k not in ("Message",)},
|
|
101
|
+
source=evt.get("TopicOwner", "") + ":" + evt.get("TopicName", ""),
|
|
102
|
+
raw=evt,
|
|
103
|
+
)
|
|
104
|
+
return Event(
|
|
105
|
+
type=EventType.TOPIC,
|
|
106
|
+
provider=PROVIDER,
|
|
107
|
+
function=fn,
|
|
108
|
+
records=[record],
|
|
109
|
+
source=evt.get("TopicName"),
|
|
110
|
+
time=parse_time(evt.get("PublishTime")),
|
|
111
|
+
raw=evt,
|
|
112
|
+
)
|
|
113
|
+
return Event(type=EventType.INVOKE, provider=PROVIDER, function=fn, data=evt, raw=native)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def to_context(ctx: Any, fn: str) -> Context:
|
|
117
|
+
if ctx is None:
|
|
118
|
+
return Context(function=fn, provider=PROVIDER)
|
|
119
|
+
func = getattr(ctx, "function", None)
|
|
120
|
+
memory = getattr(func, "memory", None)
|
|
121
|
+
timeout = getattr(func, "timeout", None)
|
|
122
|
+
return Context(
|
|
123
|
+
request_id=getattr(ctx, "request_id", "") or "",
|
|
124
|
+
function=fn,
|
|
125
|
+
provider=PROVIDER,
|
|
126
|
+
region=getattr(ctx, "region", "") or "",
|
|
127
|
+
memory_mb=int(memory) if memory else None,
|
|
128
|
+
deadline=(time.time() + float(timeout)) if timeout else None,
|
|
129
|
+
raw=ctx,
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def to_native(result: Any, event: Event) -> Any:
|
|
134
|
+
if event.http is not None:
|
|
135
|
+
resp: Response = result
|
|
136
|
+
status, headers, body, is_b64 = resp.render_for_gateway()
|
|
137
|
+
if resp.cookies:
|
|
138
|
+
headers["set-cookie"] = resp.cookies[0] if len(resp.cookies) == 1 else ", ".join(resp.cookies)
|
|
139
|
+
return {"statusCode": status, "headers": headers, "body": body, "isBase64Encoded": is_b64}
|
|
140
|
+
return result
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def handler(native: Any, ctx: Any = None) -> Any:
|
|
144
|
+
"""FC 3.0 entrypoint - the generated ``index.handler`` points here."""
|
|
145
|
+
event = to_event(native, ctx)
|
|
146
|
+
context = to_context(ctx, event.function)
|
|
147
|
+
return to_native(execute(event, context), event)
|