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.
@@ -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,6 @@
1
+ FROM python:3.12-slim@sha256:57cd7c3a7a273101a6485ba99423ee568157882804b1124b4dd04266317710de
2
+ ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1
3
+ COPY --chown=10001:10001 --chmod=644 proxy.py /proxy.py
4
+ USER 10001:10001
5
+ EXPOSE 8080
6
+ CMD ["python", "/proxy.py"]
@@ -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)