fufu-cloud-cli 0.5.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.
- fufu_cloud_cli/__init__.py +3 -0
- fufu_cloud_cli/__main__.py +9 -0
- fufu_cloud_cli/api_backend.py +302 -0
- fufu_cloud_cli/capabilities.json +570 -0
- fufu_cloud_cli/cli.py +421 -0
- fufu_cloud_cli/cloud_data.py +99 -0
- fufu_cloud_cli/control.py +185 -0
- fufu_cloud_cli/docker_backend.py +578 -0
- fufu_cloud_cli/errors.py +11 -0
- fufu_cloud_cli/lambda_adapter.py +30 -0
- fufu_cloud_cli/manifest.py +120 -0
- fufu_cloud_cli/remote_compute.py +93 -0
- fufu_cloud_cli/runtime/functions/Dockerfile +6 -0
- fufu_cloud_cli/runtime/ingress/Dockerfile +6 -0
- fufu_cloud_cli/runtime/ingress/proxy.py +49 -0
- fufu_cloud_cli/runtime_sdk.py +114 -0
- fufu_cloud_cli/serverless.py +214 -0
- fufu_cloud_cli-0.5.0.dist-info/METADATA +125 -0
- fufu_cloud_cli-0.5.0.dist-info/RECORD +23 -0
- fufu_cloud_cli-0.5.0.dist-info/WHEEL +5 -0
- fufu_cloud_cli-0.5.0.dist-info/entry_points.txt +5 -0
- fufu_cloud_cli-0.5.0.dist-info/licenses/LICENSE +6 -0
- fufu_cloud_cli-0.5.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
"""嚴格解析 Cloud Run Knative Service 子集,未支援欄位一律拒絕。"""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import re
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from .errors import FufuError, require
|
|
8
|
+
|
|
9
|
+
NAME = re.compile(r"[a-z][a-z0-9-]{0,31}\Z")
|
|
10
|
+
ENV_NAME = re.compile(r"[A-Za-z_][A-Za-z0-9_]*\Z")
|
|
11
|
+
RESERVED = {"PORT", "K_SERVICE", "K_REVISION", "K_CONFIGURATION", "FUNCTION_TARGET",
|
|
12
|
+
"FUFU_HANDLER",
|
|
13
|
+
"FUNCTION_SIGNATURE_TYPE", "AWS_LAMBDA_FUNCTION_NAME", "AWS_REGION",
|
|
14
|
+
"AWS_DEFAULT_REGION", "AWS_EC2_METADATA_DISABLED"}
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def name(value):
|
|
18
|
+
require(isinstance(value, str) and NAME.fullmatch(value), "INVALID_NAME",
|
|
19
|
+
"名稱限小寫英文開頭、數字與連字號,最多 32 字元。")
|
|
20
|
+
return value
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def fields(value, allowed, location):
|
|
24
|
+
require(isinstance(value, dict), "INVALID_MANIFEST", f"{location} 必須是物件。")
|
|
25
|
+
require(not (value.keys() - set(allowed)), "UNSUPPORTED_CONFIG",
|
|
26
|
+
f"{location} 含未支援欄位;請參閱支援矩陣。")
|
|
27
|
+
return value
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def environment(value):
|
|
31
|
+
require(isinstance(value, dict) and len(value) <= 64, "INVALID_ENV", "環境變數格式或數量不符。")
|
|
32
|
+
for k, v in value.items():
|
|
33
|
+
require(isinstance(k, str) and ENV_NAME.fullmatch(k) and k not in RESERVED and not k.startswith('FUFU_'),
|
|
34
|
+
"INVALID_ENV", "環境變數名稱無效或屬於保留欄位。")
|
|
35
|
+
require(isinstance(v, str) and "\x00" not in v and len(v) <= 32768,
|
|
36
|
+
"INVALID_ENV", "環境變數值格式不符。")
|
|
37
|
+
return value
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def load(path):
|
|
41
|
+
path = Path(path)
|
|
42
|
+
require(path.is_file() and path.stat().st_size <= 1048576,
|
|
43
|
+
"INVALID_MANIFEST", "描述檔必須存在且不超過 1 MiB。")
|
|
44
|
+
try:
|
|
45
|
+
text = path.read_text(encoding="utf-8")
|
|
46
|
+
def unique_json(pairs):
|
|
47
|
+
result = {}
|
|
48
|
+
for key, value in pairs:
|
|
49
|
+
require(key not in result, "INVALID_MANIFEST", "JSON 不接受重複鍵。")
|
|
50
|
+
result[key] = value
|
|
51
|
+
return result
|
|
52
|
+
|
|
53
|
+
try:
|
|
54
|
+
document = json.loads(text, object_pairs_hook=unique_json)
|
|
55
|
+
except json.JSONDecodeError:
|
|
56
|
+
try:
|
|
57
|
+
import yaml
|
|
58
|
+
except ImportError as exc:
|
|
59
|
+
raise FufuError("YAML_DEPENDENCY_MISSING", "YAML 需要安裝 fufu-cloud-cli[yaml];JSON 無額外相依。") from exc
|
|
60
|
+
|
|
61
|
+
class UniqueLoader(yaml.SafeLoader):
|
|
62
|
+
pass
|
|
63
|
+
|
|
64
|
+
def mapping(loader, node, deep=False):
|
|
65
|
+
result = {}
|
|
66
|
+
for key_node, value_node in node.value:
|
|
67
|
+
key = loader.construct_object(key_node, deep=deep)
|
|
68
|
+
if not isinstance(key, str) or key in result:
|
|
69
|
+
raise FufuError("INVALID_MANIFEST", "YAML 不接受非字串鍵或重複鍵。")
|
|
70
|
+
result[key] = loader.construct_object(value_node, deep=deep)
|
|
71
|
+
return result
|
|
72
|
+
|
|
73
|
+
UniqueLoader.add_constructor(yaml.resolver.BaseResolver.DEFAULT_MAPPING_TAG, mapping)
|
|
74
|
+
document = yaml.load(text, Loader=UniqueLoader)
|
|
75
|
+
return parse(document)
|
|
76
|
+
except FufuError:
|
|
77
|
+
raise
|
|
78
|
+
except Exception as exc:
|
|
79
|
+
raise FufuError("INVALID_MANIFEST", "無法解析描述檔;原始設定不會輸出。") from exc
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def parse(d):
|
|
83
|
+
fields(d, {"apiVersion", "kind", "metadata", "spec"}, "root")
|
|
84
|
+
require(d.get("apiVersion") == "serving.knative.dev/v1" and d.get("kind") == "Service",
|
|
85
|
+
"UNSUPPORTED_CONFIG", "目前只支援 serving.knative.dev/v1 Service 的明列子集。")
|
|
86
|
+
metadata = fields(d.get("metadata"), {"name"}, "metadata")
|
|
87
|
+
service = name(metadata.get("name"))
|
|
88
|
+
spec = fields(d.get("spec"), {"template"}, "spec")
|
|
89
|
+
template = fields(spec.get("template"), {"spec"}, "spec.template")
|
|
90
|
+
pod = fields(template.get("spec"), {"containers", 'serviceAccountName'}, "spec.template.spec")
|
|
91
|
+
role = pod.get('serviceAccountName')
|
|
92
|
+
require(role is None or (isinstance(role, str) and len(role) <= 256 and '@' in role), 'INVALID_ROLE', 'serviceAccountName 必須是有效的帳戶 email。')
|
|
93
|
+
containers = pod.get("containers")
|
|
94
|
+
require(isinstance(containers, list) and len(containers) == 1,
|
|
95
|
+
"UNSUPPORTED_CONFIG", "目前只支援單一 HTTP 容器。")
|
|
96
|
+
c = fields(containers[0], {"image", "ports", "env"}, "container")
|
|
97
|
+
image = c.get("image")
|
|
98
|
+
require(isinstance(image, str) and image and len(image) <= 256 and not image.startswith("-"),
|
|
99
|
+
"INVALID_IMAGE", "必須提供已存在於本機的映像。")
|
|
100
|
+
ports = c.get("ports", [{"containerPort": 8080}])
|
|
101
|
+
require(isinstance(ports, list) and len(ports) == 1, "UNSUPPORTED_CONFIG", "僅支援一個 HTTP port。")
|
|
102
|
+
p = fields(ports[0], {"containerPort"}, "ports")
|
|
103
|
+
port = p.get("containerPort")
|
|
104
|
+
require(type(port) is int and 1024 <= port <= 65535, "INVALID_PORT", "本機容器 port 範圍為 1024–65535。")
|
|
105
|
+
env = {}
|
|
106
|
+
items = c.get("env", [])
|
|
107
|
+
require(isinstance(items, list), "INVALID_ENV", "env 必須是陣列。")
|
|
108
|
+
for item in items:
|
|
109
|
+
fields(item, {"name", "value"}, "env")
|
|
110
|
+
k = item.get("name")
|
|
111
|
+
require(isinstance(k, str) and k not in env, "INVALID_ENV", "環境變數名稱重複或無效。")
|
|
112
|
+
env[k] = item.get("value")
|
|
113
|
+
environment(env)
|
|
114
|
+
return {"name": service, "image": image, "port": port, "env": env, **({'role': role} if role else {})}
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def public_plan(spec):
|
|
118
|
+
return {"backend": "local-docker", "kind": "cloud-run-http", "name": spec["name"],
|
|
119
|
+
"port": spec["port"], "environment_names": sorted(spec["env"]),
|
|
120
|
+
"checks": "strict-supported-subset", "image_resolution": "at-deploy"}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
"""Authenticated compute transport. No Docker calls execute on the CI runner."""
|
|
2
|
+
import base64
|
|
3
|
+
import io
|
|
4
|
+
import json
|
|
5
|
+
import time
|
|
6
|
+
import zipfile
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from urllib.parse import urlencode
|
|
9
|
+
|
|
10
|
+
from .api_backend import ApiClient, segment
|
|
11
|
+
from .errors import FufuError, require
|
|
12
|
+
|
|
13
|
+
MAX_ARCHIVE = 6 * 1024**2
|
|
14
|
+
KIND_PROVIDER = {"cloud-run-http": "gcp", "cloud-run-functions-http": "gcp",
|
|
15
|
+
"cloud-functions-gen1-http": "gcp", "aws-lambda-sync": "aws"}
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def source_archive(source):
|
|
19
|
+
root = Path(source).resolve()
|
|
20
|
+
require(root.is_dir() and (root / "main.py").is_file(), "INVALID_SOURCE", "HTTP Function 來源需包含 main.py。")
|
|
21
|
+
data = io.BytesIO()
|
|
22
|
+
count, total = 0, 0
|
|
23
|
+
with zipfile.ZipFile(data, "w", zipfile.ZIP_DEFLATED) as archive:
|
|
24
|
+
for p in sorted(root.rglob("*")):
|
|
25
|
+
relative = p.relative_to(root)
|
|
26
|
+
if any(v.startswith('.') or v in {'__pycache__', 'node_modules'} for v in relative.parts):
|
|
27
|
+
continue
|
|
28
|
+
require(not p.is_symlink(), "INVALID_SOURCE", "Function 來源不可含符號連結。")
|
|
29
|
+
if not p.is_file():
|
|
30
|
+
continue
|
|
31
|
+
require(p.suffix == '.py' or p.name == 'requirements.txt', "UNSUPPORTED_SOURCE", "HTTP Function 來源只接受 Python 與 requirements.txt。")
|
|
32
|
+
count += 1
|
|
33
|
+
total += p.stat().st_size
|
|
34
|
+
require(count <= 1000 and total <= 32 * 1024**2, "SOURCE_LIMIT", "Function 來源超過上限。")
|
|
35
|
+
archive.write(p, relative.as_posix())
|
|
36
|
+
require(data.tell() <= MAX_ARCHIVE, "SOURCE_LIMIT", "Function ZIP 上限為 6 MiB。")
|
|
37
|
+
return base64.b64encode(data.getvalue()).decode()
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def lambda_archive(path):
|
|
41
|
+
p = Path(path)
|
|
42
|
+
require(p.is_file() and 0 < p.stat().st_size <= MAX_ARCHIVE, "SOURCE_LIMIT", "遠端 Lambda ZIP 上限為 6 MiB。")
|
|
43
|
+
return base64.b64encode(p.read_bytes()).decode()
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class RemoteBackend:
|
|
47
|
+
def __init__(self, kind):
|
|
48
|
+
require(kind in KIND_PROVIDER, "CAPABILITY_NOT_SUPPORTED", "此類型尚無遠端 compute。")
|
|
49
|
+
self.provider = KIND_PROVIDER[kind]
|
|
50
|
+
|
|
51
|
+
def wait(self, api, operation, timeout=600):
|
|
52
|
+
require(isinstance(operation, dict) and isinstance(operation.get('id'), str), "INVALID_RESPONSE", "部署 Operation 無效。")
|
|
53
|
+
deadline = time.monotonic() + timeout
|
|
54
|
+
while operation.get('status') in {'PENDING', 'RUNNING'}:
|
|
55
|
+
require(time.monotonic() < deadline, "DEPLOYMENT_WAIT_TIMEOUT", "等待部署超過期限;部署可能仍在執行,請查詢服務狀態。")
|
|
56
|
+
time.sleep(min(0.5, max(0, deadline - time.monotonic())))
|
|
57
|
+
operation = api.request('GET', '/compute/operations/' + segment(operation['id']))
|
|
58
|
+
if operation.get('status') != 'SUCCEEDED':
|
|
59
|
+
error = operation.get('error', {})
|
|
60
|
+
raise FufuError(error.get('code', 'DEPLOYMENT_FAILED'), error.get('message', '遠端部署未成功。'))
|
|
61
|
+
return operation['result']
|
|
62
|
+
|
|
63
|
+
def deploy(self, *, kind, service, image=None, port=8080, env=None, startup_timeout=30,
|
|
64
|
+
replace=True, require_existing=False, archive=None, runtime=None, handler=None, preserve_environment=False, role=None):
|
|
65
|
+
body = dict(kind=kind, name=service, port=port, env=env or {}, startup_timeout=startup_timeout,
|
|
66
|
+
replace=replace, require_existing=require_existing, preserve_environment=preserve_environment)
|
|
67
|
+
if image is not None:
|
|
68
|
+
body['image'] = image
|
|
69
|
+
if archive is not None:
|
|
70
|
+
body.update(archive=archive, runtime=runtime, handler=handler)
|
|
71
|
+
if role: body['role'] = role
|
|
72
|
+
with ApiClient(self.provider) as api:
|
|
73
|
+
operation = api.request('POST', '/compute/deploy', body)
|
|
74
|
+
return self.wait(api, operation)
|
|
75
|
+
|
|
76
|
+
def describe(self, kind, service):
|
|
77
|
+
with ApiClient(self.provider, read_only=True) as api:
|
|
78
|
+
return api.request('GET', '/compute/services/' + segment(kind) + '/' + segment(service))
|
|
79
|
+
|
|
80
|
+
def listing(self, kind):
|
|
81
|
+
with ApiClient(self.provider, read_only=True) as api:
|
|
82
|
+
return api.request('GET', '/compute/services?' + urlencode({'kind': kind}))['services']
|
|
83
|
+
|
|
84
|
+
def delete(self, kind, service):
|
|
85
|
+
with ApiClient(self.provider) as api:
|
|
86
|
+
return self.wait(api, api.request('DELETE', '/compute/services/' + segment(kind) + '/' + segment(service)), timeout=120)
|
|
87
|
+
|
|
88
|
+
def invoke(self, kind, service, payload, *, path='/', timeout=15):
|
|
89
|
+
require(1 <= timeout <= 15, 'INVALID_TIMEOUT', '遠端呼叫期限須介於 1–15 秒。')
|
|
90
|
+
with ApiClient(self.provider) as api:
|
|
91
|
+
response = api.request('POST', '/compute/services/' + segment(kind) + '/' + segment(service) + '/invoke',
|
|
92
|
+
{'payload': base64.b64encode(payload).decode(), 'path': path, 'timeout': timeout})
|
|
93
|
+
return response['status'], base64.b64decode(response['payload'], validate=True), response.get('function_error')
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
FROM python:3.12-slim@sha256:57cd7c3a7a273101a6485ba99423ee568157882804b1124b4dd04266317710de
|
|
2
|
+
ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1
|
|
3
|
+
RUN python -m pip install --index-url https://pypi.org/simple --disable-pip-version-check --no-cache-dir --retries 1 --timeout 20 functions-framework==3.10.2
|
|
4
|
+
WORKDIR /app
|
|
5
|
+
USER 10001:10001
|
|
6
|
+
EXPOSE 8080
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""固定目的地的本機 ingress;不依 HTTP header、URL 或 payload 選擇 upstream。"""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import select
|
|
5
|
+
import socket
|
|
6
|
+
import socketserver
|
|
7
|
+
import threading
|
|
8
|
+
import time
|
|
9
|
+
|
|
10
|
+
TARGET = (os.environ["FUFU_UPSTREAM_HOST"], int(os.environ["FUFU_UPSTREAM_PORT"]))
|
|
11
|
+
SLOTS = threading.BoundedSemaphore(32)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class Proxy(socketserver.BaseRequestHandler):
|
|
15
|
+
def handle(self):
|
|
16
|
+
if not SLOTS.acquire(blocking=False):
|
|
17
|
+
return
|
|
18
|
+
try:
|
|
19
|
+
with socket.create_connection(TARGET, timeout=3) as upstream:
|
|
20
|
+
self.request.settimeout(5)
|
|
21
|
+
upstream.settimeout(5)
|
|
22
|
+
deadline = time.monotonic() + 180
|
|
23
|
+
while time.monotonic() < deadline:
|
|
24
|
+
readable, _, _ = select.select([self.request, upstream], [], [], 15)
|
|
25
|
+
if not readable:
|
|
26
|
+
return
|
|
27
|
+
for source in readable:
|
|
28
|
+
body = source.recv(65536)
|
|
29
|
+
if not body:
|
|
30
|
+
return
|
|
31
|
+
(upstream if source is self.request else self.request).sendall(body)
|
|
32
|
+
except OSError:
|
|
33
|
+
return
|
|
34
|
+
finally:
|
|
35
|
+
SLOTS.release()
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class Server(socketserver.ThreadingTCPServer):
|
|
39
|
+
allow_reuse_address = True
|
|
40
|
+
daemon_threads = True
|
|
41
|
+
request_queue_size = 32
|
|
42
|
+
|
|
43
|
+
def handle_error(self, *args):
|
|
44
|
+
pass
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
if __name__ == "__main__":
|
|
48
|
+
with Server(("0.0.0.0", 8080), Proxy) as server:
|
|
49
|
+
server.serve_forever(poll_interval=0.25)
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
"""Opt-in FUFU SDK transport. AWS request serialization stays in botocore.
|
|
2
|
+
|
|
3
|
+
The bridge uses a tenant-bound workload token, not AWS SigV4. Unsupported
|
|
4
|
+
operations fail explicitly. It never falls back to a real cloud endpoint.
|
|
5
|
+
"""
|
|
6
|
+
import base64
|
|
7
|
+
import io
|
|
8
|
+
import json
|
|
9
|
+
import os
|
|
10
|
+
from urllib.error import HTTPError
|
|
11
|
+
from urllib.parse import urlsplit
|
|
12
|
+
from urllib.request import Request, ProxyHandler, HTTPRedirectHandler, build_opener
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class FufuCapabilityError(RuntimeError):
|
|
16
|
+
pass
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class NoRedirect(HTTPRedirectHandler):
|
|
20
|
+
def redirect_request(self, *args, **kwargs):
|
|
21
|
+
return None
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def encode(value):
|
|
25
|
+
if isinstance(value, (bytes, bytearray)):
|
|
26
|
+
return {"__fufu_bytes": base64.b64encode(value).decode()}
|
|
27
|
+
if hasattr(value, 'read'):
|
|
28
|
+
data = value.read(6 * 1024**2 + 1)
|
|
29
|
+
if len(data) > 6 * 1024**2:
|
|
30
|
+
raise FufuCapabilityError('FUFU object exceeds 6 MiB')
|
|
31
|
+
return encode(data)
|
|
32
|
+
if isinstance(value, dict):
|
|
33
|
+
return {k: encode(v) for k, v in value.items()}
|
|
34
|
+
if isinstance(value, (list, tuple)):
|
|
35
|
+
return [encode(v) for v in value]
|
|
36
|
+
return value
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def rpc(service, operation, params):
|
|
40
|
+
endpoint = os.environ.get('FUFU_RUNTIME_ENDPOINT', '')
|
|
41
|
+
parsed = urlsplit(endpoint)
|
|
42
|
+
if parsed.scheme != 'http' or parsed.hostname != 'fufu-api' or parsed.port != 8080 or parsed.path not in {'/api/runtime/aws', '/api/runtime/gcp'} or parsed.query or parsed.fragment:
|
|
43
|
+
raise FufuCapabilityError('FUFU runtime gateway is not configured')
|
|
44
|
+
token = os.environ.get('FUFU_WORKLOAD_TOKEN')
|
|
45
|
+
if not token:
|
|
46
|
+
raise FufuCapabilityError('FUFU workload credential is required')
|
|
47
|
+
data = json.dumps({'service': service, 'operation': operation, 'params': encode(params)}, allow_nan=False).encode()
|
|
48
|
+
if len(data) > 9 * 1024**2:
|
|
49
|
+
raise FufuCapabilityError('FUFU SDK payload exceeds limit')
|
|
50
|
+
req = Request(endpoint, data=data, headers={'Content-Type': 'application/json', 'Authorization': 'Bearer ' + token}, method='POST')
|
|
51
|
+
try:
|
|
52
|
+
with build_opener(ProxyHandler({}), NoRedirect()).open(req, timeout=10) as response:
|
|
53
|
+
body = response.read(9 * 1024**2 + 1)
|
|
54
|
+
if len(body) > 9 * 1024**2:
|
|
55
|
+
raise FufuCapabilityError('FUFU SDK response exceeds limit')
|
|
56
|
+
return json.loads(body)
|
|
57
|
+
except HTTPError as exc:
|
|
58
|
+
# Error payload contains only the safe AWS error name, never backend details.
|
|
59
|
+
status = exc.code
|
|
60
|
+
try:
|
|
61
|
+
body = json.loads(exc.read(4096))
|
|
62
|
+
except (ValueError, OSError):
|
|
63
|
+
body = {}
|
|
64
|
+
finally:
|
|
65
|
+
exc.close()
|
|
66
|
+
code = body.get('code') or ('AccessDeniedException' if status in {401, 403} else 'FufuUnsupportedCapability' if status == 422 else 'FufuServiceError')
|
|
67
|
+
if service in {'dynamodb', 's3', 'sqs', 'ssm'}:
|
|
68
|
+
from botocore.exceptions import ClientError
|
|
69
|
+
raise ClientError({'Error': {'Code': code, 'Message': 'FUFU SDK request failed; see the documented capability subset'}, 'ResponseMetadata': {'HTTPStatusCode': status}}, operation) from None
|
|
70
|
+
raise FufuCapabilityError('FUFU runtime request rejected (HTTP %s)' % status) from None
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
_installed = False
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def install():
|
|
77
|
+
global _installed
|
|
78
|
+
if _installed or not os.environ.get('FUFU_RUNTIME_ENDPOINT'):
|
|
79
|
+
return
|
|
80
|
+
import botocore.session
|
|
81
|
+
from botocore.awsrequest import AWSResponse
|
|
82
|
+
from botocore.response import StreamingBody
|
|
83
|
+
original = botocore.session.Session.create_client
|
|
84
|
+
|
|
85
|
+
def create_client(session, service_name, *args, **kwargs):
|
|
86
|
+
if service_name not in {'dynamodb', 's3', 'sqs', 'ssm'}:
|
|
87
|
+
raise FufuCapabilityError('FUFU SDK service is not implemented: ' + service_name)
|
|
88
|
+
kwargs['aws_access_key_id'] = 'FUFU_WORKLOAD'
|
|
89
|
+
kwargs['aws_secret_access_key'] = 'fufu-transport-does-not-use-sigv4'
|
|
90
|
+
kwargs['endpoint_url'] = 'http://fufu-api:8080'
|
|
91
|
+
client = original(session, service_name, *args, **kwargs)
|
|
92
|
+
def no_presign(*args, **kwargs):
|
|
93
|
+
raise FufuCapabilityError('FUFU presigned URLs are not implemented')
|
|
94
|
+
client.generate_presigned_url = no_presign
|
|
95
|
+
if hasattr(client, 'generate_presigned_post'): client.generate_presigned_post = no_presign
|
|
96
|
+
|
|
97
|
+
def capture(params, context, **unused):
|
|
98
|
+
# Keep the reference: later botocore/boto3 parameter handlers transform it.
|
|
99
|
+
context['fufu_params'] = params
|
|
100
|
+
|
|
101
|
+
def call(model, context, **unused):
|
|
102
|
+
result = rpc(service_name, model.name, context['fufu_params'])
|
|
103
|
+
if service_name == 's3' and isinstance(result.get('Body'), dict):
|
|
104
|
+
data = base64.b64decode(result['Body']['__fufu_bytes'], validate=True)
|
|
105
|
+
result['Body'] = StreamingBody(io.BytesIO(data), len(data))
|
|
106
|
+
result['ResponseMetadata'] = {'HTTPStatusCode': 200, 'HTTPHeaders': {'x-fufu-mode': 'simplified'}, 'RetryAttempts': 0}
|
|
107
|
+
return AWSResponse('http://fufu-api', 200, {}, None), result
|
|
108
|
+
|
|
109
|
+
client.meta.events.register('before-parameter-build', capture)
|
|
110
|
+
client.meta.events.register('before-call', call)
|
|
111
|
+
return client
|
|
112
|
+
|
|
113
|
+
botocore.session.Session.create_client = create_client
|
|
114
|
+
_installed = True
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
"""Explicit Serverless source/profile adapter; never runs provider plugins.
|
|
2
|
+
|
|
3
|
+
The source YAML supplies selected handler paths and HTTP events. A FUFU profile
|
|
4
|
+
replaces environment, IAM and infrastructure explicitly. A plan reports every
|
|
5
|
+
omitted function and control-plane simplification before deployment.
|
|
6
|
+
"""
|
|
7
|
+
import ast
|
|
8
|
+
import base64
|
|
9
|
+
import io
|
|
10
|
+
import json
|
|
11
|
+
import os
|
|
12
|
+
import re
|
|
13
|
+
import zipfile
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from .api_backend import ApiClient
|
|
16
|
+
from .control import AWS_OPERATIONS
|
|
17
|
+
from .errors import FufuError, require
|
|
18
|
+
from .manifest import environment, fields, name
|
|
19
|
+
from .remote_compute import RemoteBackend
|
|
20
|
+
|
|
21
|
+
ACKNOWLEDGEMENTS = {'selected-functions-only', 'replace-environment', 'replace-iam-and-resources',
|
|
22
|
+
'fixed-runtime-limits', 'no-serverless-plugins', 'mock-cognito-authorizer'}
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def load_document(path):
|
|
26
|
+
p = Path(path)
|
|
27
|
+
require(p.is_file() and p.stat().st_size <= 1024**2, 'INVALID_CONFIG', '部署描述檔必須存在且不超過 1 MiB。')
|
|
28
|
+
try: import yaml
|
|
29
|
+
except ImportError: raise FufuError('YAML_DEPENDENCY_MISSING', '請安裝 fufu-cloud-cli[yaml] 以解析 Serverless YAML。') from None
|
|
30
|
+
class Loader(yaml.SafeLoader): pass
|
|
31
|
+
def mapping(loader, node, deep=False):
|
|
32
|
+
result = {}
|
|
33
|
+
for k, v in node.value:
|
|
34
|
+
key = loader.construct_object(k, deep=deep)
|
|
35
|
+
require(isinstance(key, str) and key not in result, 'INVALID_CONFIG', '設定含重複鍵或非字串鍵。')
|
|
36
|
+
result[key] = loader.construct_object(v, deep=deep)
|
|
37
|
+
return result
|
|
38
|
+
Loader.add_constructor(yaml.resolver.BaseResolver.DEFAULT_MAPPING_TAG, mapping)
|
|
39
|
+
try: value = yaml.load(p.read_text(), Loader=Loader)
|
|
40
|
+
except FufuError: raise
|
|
41
|
+
except Exception: raise FufuError('INVALID_CONFIG', '無法解析 YAML/JSON;未輸出原始設定。') from None
|
|
42
|
+
require(isinstance(value, dict), 'INVALID_CONFIG', '部署描述檔必須是物件。')
|
|
43
|
+
return value
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def substitute(value, values):
|
|
47
|
+
if isinstance(value, str):
|
|
48
|
+
for key, replacement in values.items(): value = value.replace('${' + key + '}', replacement)
|
|
49
|
+
require('${' not in value, 'UNRESOLVED_VARIABLE', 'FUFU profile 只接受 stage、account、region、project 變數。')
|
|
50
|
+
return value
|
|
51
|
+
if isinstance(value, list): return [substitute(v, values) for v in value]
|
|
52
|
+
if isinstance(value, dict): return {k: substitute(v, values) for k, v in value.items()}
|
|
53
|
+
return value
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def package(root, include, handler):
|
|
57
|
+
require(isinstance(include, list) and 0 < len(include) <= 1000, 'INVALID_PACKAGE', '每個 Function 必須明確列出來源檔;不會自動打包 custom 設定與 credential。')
|
|
58
|
+
files = []; total = 0
|
|
59
|
+
for relative in include:
|
|
60
|
+
require(isinstance(relative, str) and not Path(relative).is_absolute() and '..' not in Path(relative).parts, 'INVALID_PACKAGE', '來源路徑必須位於專案內。')
|
|
61
|
+
p = root / relative
|
|
62
|
+
require(p.is_file() and not p.is_symlink() and p.resolve().is_relative_to(root) and p.suffix == '.py', 'INVALID_PACKAGE', '內建來源模式只打包明確列出的 Python;相依套件需另行提供 ZIP。')
|
|
63
|
+
total += p.stat().st_size
|
|
64
|
+
require(total <= 16 * 1024**2, 'SOURCE_LIMIT', '原始碼超過 16 MiB。')
|
|
65
|
+
files.append((p, relative))
|
|
66
|
+
module = handler.rsplit('.', 1)[0].replace('.', '/') + '.py'
|
|
67
|
+
require(module in include, 'INVALID_HANDLER', '來源列表缺少 handler 模組。')
|
|
68
|
+
# Reject unresolved local imports in the simple source package. Third-party
|
|
69
|
+
# imports are runtime dependencies; they must be provided in a supplied ZIP.
|
|
70
|
+
for p, _ in files:
|
|
71
|
+
for node in ast.walk(ast.parse(p.read_text())):
|
|
72
|
+
imports = [node.module] if isinstance(node, ast.ImportFrom) and node.module else [n.name for n in node.names] if isinstance(node, ast.Import) else []
|
|
73
|
+
for imported in imports:
|
|
74
|
+
if (root / imported.replace('.', '/')).is_dir() or (root / (imported.replace('.', '/') + '.py')).exists():
|
|
75
|
+
require(any(v.startswith(imported.replace('.', '/') + '/') or v == imported.replace('.', '/') + '.py' for v in include), 'MISSING_DEPENDENCY', '來源列表缺少本機相依模組;請提供完整來源清單或預先打包的 ZIP。')
|
|
76
|
+
data = io.BytesIO()
|
|
77
|
+
with zipfile.ZipFile(data, 'w', zipfile.ZIP_DEFLATED) as z:
|
|
78
|
+
for p, relative in files: z.writestr(relative, p.read_bytes())
|
|
79
|
+
require(data.tell() <= 6 * 1024**2, 'SOURCE_LIMIT', 'Lambda ZIP 上限為 6 MiB。')
|
|
80
|
+
return base64.b64encode(data.getvalue()).decode()
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def plan(config, profile_path, stage):
|
|
84
|
+
source = load_document(config); profile = load_document(profile_path); root = Path(config).resolve().parent
|
|
85
|
+
fields(profile, {'version', 'functions', 'roles', 'resources', 'acknowledgeSimplifications', 'apiName'}, 'FUFU profile')
|
|
86
|
+
require(profile.get('version') == 1 and set(profile.get('acknowledgeSimplifications', [])) == ACKNOWLEDGEMENTS,
|
|
87
|
+
'ACKNOWLEDGEMENT_REQUIRED', 'Profile 必須明示所有必要簡化;請核對 acknowledgeSimplifications。')
|
|
88
|
+
require(source.get('provider', {}).get('name') == 'aws', 'UNSUPPORTED_PROVIDER', 'Serverless adapter 目前支援 AWS。')
|
|
89
|
+
name(stage)
|
|
90
|
+
definitions = source.get('functions', {}); selected = profile.get('functions', {})
|
|
91
|
+
require(isinstance(definitions, dict) and isinstance(selected, dict) and 0 < len(selected) <= 8, 'RESOURCE_LIMIT', '明確選擇 1–8 個 Function;未選取者不會被部署。')
|
|
92
|
+
require(isinstance(profile.get('apiName', 'chatbot-fufu'), str) and re.fullmatch(r'[A-Za-z0-9][A-Za-z0-9_.+=,@-]{0,127}', profile.get('apiName', 'chatbot-fufu')), 'INVALID_NAME', 'API 名稱無效。')
|
|
93
|
+
require(isinstance(profile.get('roles'), dict), 'INVALID_ROLE', 'roles 必須是物件。')
|
|
94
|
+
prepared = []; routes = {}; source_hashes = {}; ignored_functions = {}
|
|
95
|
+
for key, options in selected.items():
|
|
96
|
+
require(key in definitions, 'FUNCTION_NOT_FOUND', 'Profile 選取了不存在的 Function。')
|
|
97
|
+
fields(options, {'name', 'include', 'artifact', 'environment', 'environmentFrom', 'role'}, 'function profile')
|
|
98
|
+
spec = definitions[key]
|
|
99
|
+
require(isinstance(spec, dict), 'UNSUPPORTED_FUNCTION', 'Function 定義必須是物件。')
|
|
100
|
+
ignored_functions[key] = sorted(set(spec) - {'handler', 'runtime', 'events'})
|
|
101
|
+
handler = spec.get('handler', '').replace('/', '.')
|
|
102
|
+
require(re.fullmatch(r'[A-Za-z_]\w*(?:\.[A-Za-z_]\w*)+', handler), 'UNSUPPORTED_FUNCTION', '目前只支援 Python handler;image Function 需使用其他部署入口。')
|
|
103
|
+
runtime = spec.get('runtime', source['provider'].get('runtime'))
|
|
104
|
+
require(runtime in {'python3.10', 'python3.12'}, 'UNSUPPORTED_RUNTIME', '目前 Lambda runtime 支援 Python 3.10/3.12。')
|
|
105
|
+
physical = name(options.get('name'))
|
|
106
|
+
require(physical not in [f['name'] for f in prepared], 'DUPLICATE_NAME', 'Function 的實體名稱不可重複。')
|
|
107
|
+
require(isinstance(options.get('role'), str) and options['role'] in profile.get('roles', {}), 'INVALID_ROLE', 'Function 必須引用 profile 中明確配置的 role。')
|
|
108
|
+
require(bool(options.get('include')) != bool(options.get('artifact')), 'INVALID_PACKAGE', 'include 與 artifact 必須擇一。')
|
|
109
|
+
if options.get('artifact'):
|
|
110
|
+
artifact = root / options['artifact']
|
|
111
|
+
require(artifact.resolve().is_relative_to(root) and artifact.is_file() and not artifact.is_symlink() and artifact.stat().st_size <= 6 * 1024**2,
|
|
112
|
+
'INVALID_PACKAGE', '預先打包的 ZIP 必須位於專案內且不超過 6 MiB。')
|
|
113
|
+
archive = base64.b64encode(artifact.read_bytes()).decode()
|
|
114
|
+
else: archive = package(root, options['include'], handler)
|
|
115
|
+
env = options.get('environment', {})
|
|
116
|
+
require(isinstance(env, dict), 'INVALID_ENV', 'environment 必須是物件。')
|
|
117
|
+
environment(substitute(env, {'stage': stage, 'account': '000000000000', 'region': 'ap-northeast-1', 'project': 'fufu-project'}))
|
|
118
|
+
from_env = options.get('environmentFrom', {})
|
|
119
|
+
require(isinstance(from_env, dict) and not (env.keys() & from_env.keys()), 'INVALID_ENV', 'environmentFrom 與 environment 不可重複。')
|
|
120
|
+
for target, entry in from_env.items():
|
|
121
|
+
fields(entry, {'env', 'ssm'}, 'environmentFrom')
|
|
122
|
+
require(len(entry) == 1 and isinstance(next(iter(entry.values())), str), 'INVALID_ENV', 'environmentFrom 使用 env 或 ssm 擇一。')
|
|
123
|
+
environment({target: 'validated-reference' for target in from_env})
|
|
124
|
+
http_routes = []
|
|
125
|
+
for event in spec.get('events', []):
|
|
126
|
+
require(isinstance(event, dict) and set(event) == {'http'}, 'UNSUPPORTED_EVENT', '選取的 Function 目前只匯入 HTTP events;SQS 請用 event-source-mapping 明確接線。')
|
|
127
|
+
http = event['http']
|
|
128
|
+
fields(http, {'path', 'method', 'cors', 'authorizer', 'private'}, 'HTTP event')
|
|
129
|
+
require(not http.get('private'), 'UNSUPPORTED_EVENT', 'API key/usage plan 尚未實作。')
|
|
130
|
+
require(isinstance(http.get('path'), str) and http.get('method', '').lower() in {'get','post','put','patch','delete','options','head'}, 'INVALID_EVENT', 'HTTP route 無效。')
|
|
131
|
+
route = '/' + http['path'].lstrip('/'); method = http['method'].lower()
|
|
132
|
+
require(method not in routes.setdefault(route, {}), 'DUPLICATE_ROUTE', 'HTTP route 重複。')
|
|
133
|
+
routes[route][method] = physical; http_routes.append({'path': route, 'method': method})
|
|
134
|
+
import hashlib
|
|
135
|
+
source_hashes[key] = hashlib.sha256(base64.b64decode(archive)).hexdigest()
|
|
136
|
+
prepared.append({'key': key, 'name': physical, 'handler': handler, 'runtime': runtime, 'archive': archive,
|
|
137
|
+
'environment': env, 'environmentFrom': from_env, 'role': options['role'], 'http': http_routes})
|
|
138
|
+
for role_name, role in profile.get('roles', {}).items():
|
|
139
|
+
require(re.fullmatch(r'[A-Za-z0-9][A-Za-z0-9_.-]{0,63}', role_name), 'INVALID_ROLE', 'Role 名稱無效。')
|
|
140
|
+
fields(role, {'AssumeRolePolicyDocument', 'PolicyDocument'}, 'role')
|
|
141
|
+
require(set(role) == {'AssumeRolePolicyDocument', 'PolicyDocument'}, 'INVALID_ROLE', 'Role 必須提供 trust 與 policy。')
|
|
142
|
+
resources = profile.get('resources', [])
|
|
143
|
+
require(isinstance(resources, list) and len(resources) <= 32, 'RESOURCE_LIMIT', '最多 32 個明確的資料資源。')
|
|
144
|
+
for item in resources:
|
|
145
|
+
fields(item, {'service', 'operation', 'params'}, 'resource')
|
|
146
|
+
require((item.get('service'), item.get('operation')) in {('dynamodb','CreateTable'),('s3api','CreateBucket'),('sqs','CreateQueue')}, 'UNSUPPORTED_RESOURCE', '自動部署只建立 DynamoDB、S3、SQS;其他設定請用原生命令。')
|
|
147
|
+
fields(item.get('params'), AWS_OPERATIONS[item['service']][item['operation']].split(), 'resource params')
|
|
148
|
+
public = {'mode': 'simplified', 'adapter': 'explicit-serverless-profile', 'stage': stage,
|
|
149
|
+
'selectedFunctions': [{'key': f['key'], 'name': f['name'], 'handler': f['handler'], 'runtime': f['runtime'], 'role': f['role'], 'environmentNames': sorted(set(f['environment']) | set(f['environmentFrom'])), 'http': f['http']} for f in prepared],
|
|
150
|
+
'notDeployed': sorted(set(definitions) - set(selected)), 'sourceSha256': source_hashes,
|
|
151
|
+
'ignoredSourceFields': {'provider': sorted(set(source['provider']) - {'name', 'runtime'}), 'functions': ignored_functions,
|
|
152
|
+
'root': sorted(set(source) - {'provider', 'functions'})},
|
|
153
|
+
'simplifications': sorted(ACKNOWLEDGEMENTS), 'resourceCount': len(resources), 'authorizer': 'MOCK: authenticated tenant owner claims'}
|
|
154
|
+
return public, prepared, profile, routes
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def deploy(config, profile_path, stage):
|
|
158
|
+
public, functions, profile, routes = plan(config, profile_path, stage)
|
|
159
|
+
backend = RemoteBackend('aws-lambda-sync')
|
|
160
|
+
with ApiClient('aws') as api:
|
|
161
|
+
who = api.whoami(); values = {'stage': stage, 'account': who['accountId'], 'region': who['region'], 'project': 'unused'}
|
|
162
|
+
call = lambda service, operation, params: api.request('POST', '/control/native', {'service': 's3' if service == 's3api' else service, 'operation': operation, 'params': params})
|
|
163
|
+
# Resolve every source environment and role locally before any mutation.
|
|
164
|
+
for f in functions:
|
|
165
|
+
env = substitute(f['environment'], values)
|
|
166
|
+
for key, ref in f['environmentFrom'].items():
|
|
167
|
+
if 'env' in ref:
|
|
168
|
+
require(ref['env'] in os.environ, 'MISSING_ENV', 'Profile 所需的受控環境變數未注入。')
|
|
169
|
+
env[key] = os.environ[ref['env']]
|
|
170
|
+
else: env[key] = call('ssm', 'GetParameter', {'Name': substitute(ref['ssm'], values)})['Parameter']['Value']
|
|
171
|
+
f['env'] = environment(env)
|
|
172
|
+
roles = substitute(profile['roles'], values)
|
|
173
|
+
resources = substitute(profile.get('resources', []), values)
|
|
174
|
+
capabilities = api.request('GET', '/compute/capabilities')
|
|
175
|
+
existing = api.request('GET', '/compute/services?kind=aws-lambda-sync')['services']
|
|
176
|
+
require(len({x['name'] for x in existing} | {x['name'] for x in functions}) <= capabilities['maxServices'], 'RESOURCE_LIMIT', '目前租戶沒有足夠的 Function 容量。')
|
|
177
|
+
role_names = {r['RoleName'] for r in call('iam', 'ListRoles', {})['Roles']}
|
|
178
|
+
for role_name, role in roles.items():
|
|
179
|
+
if role_name not in role_names: call('iam', 'CreateRole', {'RoleName': role_name, 'AssumeRolePolicyDocument': role['AssumeRolePolicyDocument']})
|
|
180
|
+
else:
|
|
181
|
+
previous = call('iam', 'GetRole', {'RoleName': role_name})['Role']['AssumeRolePolicyDocument']
|
|
182
|
+
require(previous == role['AssumeRolePolicyDocument'], 'ROLE_CONFLICT', '既有 role trust 與 profile 不同;未覆寫 trust。')
|
|
183
|
+
call('iam', 'PutRolePolicy', {'RoleName': role_name, 'PolicyName': 'fufu-deployment', 'PolicyDocument': role['PolicyDocument']})
|
|
184
|
+
for item in resources:
|
|
185
|
+
try: call(item['service'], item['operation'], item['params'])
|
|
186
|
+
except FufuError as e:
|
|
187
|
+
if e.code != 'RESOURCE_CONFLICT': raise
|
|
188
|
+
if item['service'] == 'dynamodb':
|
|
189
|
+
table = call('dynamodb', 'DescribeTable', {'TableName': item['params']['TableName']})['Table']
|
|
190
|
+
require(table['KeySchema'] == item['params']['KeySchema'] and sorted(table['AttributeDefinitions'], key=lambda x:x['AttributeName']) == sorted(item['params']['AttributeDefinitions'], key=lambda x:x['AttributeName']), 'RESOURCE_CONFLICT', '既有 DynamoDB schema 與 profile 不同。')
|
|
191
|
+
elif item['service'] != 's3api': raise
|
|
192
|
+
deployed = []
|
|
193
|
+
for f in functions:
|
|
194
|
+
body = {k: f[k] for k in ['name','handler','runtime','archive']}
|
|
195
|
+
body.update(kind='aws-lambda-sync', role=f"arn:aws:iam::{who['accountId']}:role/{f['role']}", env=f['env'], replace=True)
|
|
196
|
+
operation = api.request('POST', '/compute/deploy', body)
|
|
197
|
+
deployed.append(backend.wait(api, operation))
|
|
198
|
+
gateway = None
|
|
199
|
+
if routes:
|
|
200
|
+
paths = {p: {m: {'responses': {}, 'x-amazon-apigateway-integration': {'type':'aws_proxy','httpMethod':'POST','payloadFormatVersion':'1.0', 'uri':f"arn:aws:lambda:{who['region']}:{who['accountId']}:function:{fn}"}} for m,fn in methods.items()} for p,methods in routes.items()}
|
|
201
|
+
api_name = profile.get('apiName', 'chatbot-fufu'); prior = api.request('GET', '/control/apis')['Items']
|
|
202
|
+
require(sum(x['name'] == api_name for x in prior) <= 1, 'API_CONFLICT', 'API 名稱存在多個部署。')
|
|
203
|
+
previous_id = next((x['id'] for x in prior if x['name'] == api_name), None)
|
|
204
|
+
gateway = api.request('POST', '/control/apis', {'document': {'openapi':'3.0.1','info':{'title':api_name,'version':'1'},'paths':paths,'x-fufu-authorizer':'tenant-owner-claims-mock'}, **({'id':previous_id} if previous_id else {})})
|
|
205
|
+
return {'plan': public, 'deployed': deployed, 'gateway': gateway, 'status': 'SUCCEEDED', 'fullApplicationDeployed': False}
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def command(argv, Parser):
|
|
209
|
+
p = Parser(prog='aws-fufu serverless', description='明確 profile 的 Serverless 來源轉接;不呼叫正式雲端或執行 Serverless plugins。')
|
|
210
|
+
s = p.add_subparsers(dest='action', required=True)
|
|
211
|
+
for action in ['plan', 'deploy']:
|
|
212
|
+
q = s.add_parser(action); q.add_argument('--config', default='serverless.yml'); q.add_argument('--fufu-config', required=True); q.add_argument('--stage', default='fufu')
|
|
213
|
+
a = p.parse_args(argv)
|
|
214
|
+
return plan(a.config, a.fufu_config, a.stage)[0] if a.action == 'plan' else deploy(a.config, a.fufu_config, a.stage)
|