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,578 @@
1
+ """只操作本機 Docker daemon;不呼叫任何雲端控制 API。"""
2
+
3
+ import contextlib
4
+ try:
5
+ import fcntl
6
+ except ImportError:
7
+ fcntl = None
8
+ import hashlib
9
+ import ipaddress
10
+ import json
11
+ import os
12
+ import platform
13
+ import re
14
+ import shutil
15
+ import signal
16
+ import socket
17
+ import stat
18
+ import subprocess
19
+ import tempfile
20
+ import time
21
+ import uuid
22
+ import zipfile
23
+ from pathlib import Path, PurePosixPath
24
+ from urllib import error, request
25
+
26
+ from .errors import FufuError, require
27
+ from .manifest import environment, name
28
+ from .lambda_adapter import PROTOCOL
29
+
30
+ PYTHON_IMAGE = "python:3.12-slim@sha256:57cd7c3a7a273101a6485ba99423ee568157882804b1124b4dd04266317710de"
31
+ LAMBDA_IMAGES = {
32
+ "python3.12": "public.ecr.aws/lambda/python:3.12@sha256:c3692e82744acb2ceab8feb1fe6e9a4ee60b28beb4b3f7ea4e23b4d15a8ab710",
33
+ "python3.10": "public.ecr.aws/lambda/python:3.10@sha256:e74c50d8626878f0ca02bda970f671bd87dfdb530733b3f99fde7238d156390c",
34
+ }
35
+ FUNCTIONS_IMAGE = "fufu-cloud-cli-functions:local"
36
+ INGRESS_IMAGE = "fufu-cloud-cli-ingress:local"
37
+ LABEL = "io.fufu.cloud-cli"
38
+ KINDS = {"cloud-run-http", "cloud-run-functions-http", "cloud-functions-gen1-http", "aws-lambda-sync"}
39
+ MAX_PAYLOAD = 6 * 1024 * 1024
40
+
41
+
42
+ def run(args, *, timeout=30, env=None, cwd=None, input_data=None, max_output=2 * 1024 * 1024):
43
+ """不經 shell;限制輸出量並停止逾時程序群組。錯誤不回顯設定與日誌。"""
44
+ child_env = {k: os.environ[k] for k in ("PATH", "LANG", "LC_ALL", "TMPDIR") if k in os.environ}
45
+ child_env.update(env or {})
46
+ with tempfile.TemporaryFile() as out, tempfile.TemporaryFile() as err:
47
+ try:
48
+ with subprocess.Popen(args, cwd=cwd, env=child_env, stdin=subprocess.PIPE,
49
+ stdout=out, stderr=err, start_new_session=True) as p:
50
+ try:
51
+ p.communicate(input_data, timeout=timeout)
52
+ except subprocess.TimeoutExpired:
53
+ os.killpg(p.pid, signal.SIGTERM)
54
+ try:
55
+ p.wait(timeout=3)
56
+ except subprocess.TimeoutExpired:
57
+ os.killpg(p.pid, signal.SIGKILL)
58
+ p.wait(timeout=3)
59
+ raise FufuError("OPERATION_TIMEOUT", "操作超過期限;未輸出原始日誌。")
60
+ require(p.returncode == 0, "DOCKER_OPERATION_FAILED", "Docker 操作失敗;請核對本機映像、來源及執行狀態。")
61
+ require(out.tell() <= max_output, "OUTPUT_LIMIT", "操作輸出超過限制。")
62
+ out.seek(0)
63
+ return out.read().decode("utf-8").strip()
64
+ except FileNotFoundError as exc:
65
+ raise FufuError("DEPENDENCY_MISSING", "需要本機 Docker CLI 與可用的 Docker daemon。") from exc
66
+
67
+
68
+ def docker(*args, **kw):
69
+ # 固定本機 Unix socket,不繼承 Docker context、雲端 registry 認證或遠端主機設定。
70
+ with tempfile.TemporaryDirectory(prefix='fufu-docker-client-') as config:
71
+ return run(["docker", "--config", config, "--host", "unix:///var/run/docker.sock", *args], **kw)
72
+
73
+
74
+ def object_json(text):
75
+ try:
76
+ return json.loads(text)
77
+ except (TypeError, ValueError) as exc:
78
+ raise FufuError("INVALID_DOCKER_RESPONSE", "Docker 回應無法解析。") from exc
79
+
80
+
81
+ def inspect_image(image):
82
+ require(isinstance(image, str) and image and len(image) <= 256 and not image.startswith("-"),
83
+ "INVALID_IMAGE", "必須指定本機映像。")
84
+ # 只保留相容性檢查必要欄位,避免讀出映像內環境變數。
85
+ data = object_json(docker("image", "inspect", image, "--format",
86
+ '{"id":{{json .Id}},"arch":{{json .Architecture}},"os":{{json .Os}}}'))
87
+ expected = {"x86_64": "amd64", "aarch64": "arm64"}.get(platform.machine())
88
+ require(data["os"] == "linux" and data["arch"] == expected,
89
+ "ARCHITECTURE_MISMATCH", "本機 daemon 與映像必須使用相同的 Linux CPU 架構。")
90
+ return data["id"]
91
+
92
+
93
+ def read_container(container_id):
94
+ require(isinstance(container_id, str) and re.fullmatch(r"[a-f0-9]{64}", container_id),
95
+ "INVALID_STATE", "本機狀態中的容器識別格式不符。")
96
+ return object_json(docker("container", "inspect", container_id, "--format",
97
+ '{"labels":{{json .Config.Labels}},"running":{{json .State.Running}},'
98
+ '"ports":{{json .NetworkSettings.Ports}},"networks":{{json .NetworkSettings.Networks}}}'))
99
+
100
+
101
+ def available_subnet(seed):
102
+ """避開既有 Docker 與主機路由,不改共用 daemon 的 address pool。"""
103
+ require(shutil.which("ip") is not None, "DEPENDENCY_MISSING", "需要 Linux iproute2 以檢查本機網路衝突。")
104
+ occupied = []
105
+ ids = docker("network", "ls", "--quiet").splitlines()
106
+ require(len(ids) <= 512, "NETWORK_LIMIT", "主機網路數量超過本機預檢限制。")
107
+ if ids:
108
+ configs = docker("network", "inspect", *ids, "--format", "{{json .IPAM.Config}}")
109
+ for line in configs.splitlines():
110
+ for entry in object_json(line) or []:
111
+ if entry.get("Subnet"):
112
+ occupied.append(ipaddress.ip_network(entry["Subnet"]))
113
+ routes = object_json(run(["ip", "-j", "-4", "route", "show", "table", "all"], timeout=15))
114
+ for entry in routes:
115
+ dst = entry.get("dst")
116
+ if dst and dst != "default":
117
+ network = ipaddress.ip_network(dst, strict=False)
118
+ if network.prefixlen:
119
+ occupied.append(network)
120
+ pool = list(ipaddress.ip_network("10.240.0.0/12").subnets(new_prefix=24))
121
+ start = int(hashlib.sha256(seed.encode()).hexdigest()[:8], 16) % len(pool)
122
+ for offset in range(len(pool)):
123
+ candidate = pool[(start + offset) % len(pool)]
124
+ if not any(candidate.overlaps(n) for n in occupied if n.version == 4):
125
+ return str(candidate)
126
+ raise FufuError("NETWORK_POOL_UNAVAILABLE", "沒有可避開既有網路與路由的 FUFU 子網路;未變更 daemon 設定。")
127
+
128
+
129
+ class Backend:
130
+ def __init__(self, *, workspace=None, state_dir=None):
131
+ require(fcntl is not None and platform.system() == "Linux", "UNSUPPORTED_PLATFORM",
132
+ "本機 Docker backend 需要 Linux;其他平台請使用 FUFU API backend。")
133
+ require(os.environ.get("FUFU_BACKEND", "local-docker") in {"docker", "local-docker"}, "UNSUPPORTED_BACKEND",
134
+ "此部署核心需要 local-docker 模式;API 模式不會轉為本機部署。")
135
+ self.workspace = name(workspace or os.environ.get("FUFU_WORKSPACE", "fufu"))
136
+ self.root = Path(state_dir or os.environ.get("FUFU_STATE_DIR", str(Path.home() / ".local/state/fufu-cloud-cli"))).expanduser().resolve()
137
+ self.root.mkdir(parents=True, exist_ok=True, mode=0o700)
138
+ self.path = self.root / self.workspace
139
+ require(not self.path.is_symlink(), "INVALID_STATE", "Workspace 不接受符號連結。")
140
+ self.path.mkdir(mode=0o700, exist_ok=True)
141
+ self.scope = hashlib.sha256(f"{self.root}:{os.getuid()}:{self.workspace}".encode()).hexdigest()[:16]
142
+ self.network = f"fufu-cli-{self.scope}"
143
+ self.ingress_network = f"{self.network}-ingress"
144
+ self.state_file = self.path / "state.json"
145
+
146
+ @contextlib.contextmanager
147
+ def locked(self):
148
+ fd = os.open(self.path / "state.lock", os.O_RDWR | os.O_CREAT | os.O_NOFOLLOW, 0o600)
149
+ with os.fdopen(fd, "a") as lock:
150
+ deadline = time.monotonic() + 5
151
+ while True:
152
+ try:
153
+ fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
154
+ break
155
+ except BlockingIOError:
156
+ require(time.monotonic() < deadline, "WORKSPACE_BUSY", "同 workspace 正在操作;鎖等待已到期。")
157
+ time.sleep(0.05)
158
+ try:
159
+ yield
160
+ finally:
161
+ fcntl.flock(lock, fcntl.LOCK_UN)
162
+
163
+ def read_state(self):
164
+ if not self.state_file.exists():
165
+ return {"schema": 1, "services": {}}
166
+ try:
167
+ require(not self.state_file.is_symlink() and self.state_file.stat().st_size <= 1048576,
168
+ "INVALID_STATE", "本機狀態檔格式不符。")
169
+ data = json.loads(self.state_file.read_text())
170
+ require(data.get("schema") == 1 and isinstance(data.get("services"), dict),
171
+ "INVALID_STATE", "本機狀態 schema 不符;不會覆写原檔。")
172
+ for key, r in data["services"].items():
173
+ require(isinstance(r, dict) and r.get("kind") in KINDS and key == f"{r['kind']}:{name(r.get('name'))}",
174
+ "INVALID_STATE", "本機服務狀態不符。")
175
+ require(re.fullmatch(r"[a-f0-9]{64}", r.get("container_id", "")), "INVALID_STATE", "容器狀態不符。")
176
+ require(re.fullmatch(r"[a-f0-9]{64}", r.get("ingress_id", "")), "INVALID_STATE", "Ingress 狀態不符。")
177
+ return data
178
+ except FufuError:
179
+ raise
180
+ except Exception as exc:
181
+ raise FufuError("INVALID_STATE", "本機狀態損毀;不會覆寫或重新建立既有狀態。") from exc
182
+
183
+ def save_state(self, data):
184
+ if self.state_file.exists():
185
+ shutil.copy2(self.state_file, self.path / f"state.{time.time_ns()}.bak")
186
+ fd, p = tempfile.mkstemp(dir=self.path, prefix="state-", suffix=".tmp")
187
+ try:
188
+ with os.fdopen(fd, "w") as f:
189
+ json.dump(data, f, sort_keys=True)
190
+ f.flush()
191
+ os.fsync(f.fileno())
192
+ os.replace(p, self.state_file)
193
+ finally:
194
+ if os.path.exists(p):
195
+ os.unlink(p)
196
+
197
+ def owned(self, container_id):
198
+ d = read_container(container_id)
199
+ require(d["labels"].get(LABEL) == "local" and d["labels"].get(f"{LABEL}.scope") == self.scope,
200
+ "OWNERSHIP_MISMATCH", "資源不屬於這個 workspace;拒絕操作。")
201
+ return d
202
+
203
+ def ensure_network(self, runtime_network=None):
204
+ for network, internal in ((runtime_network or self.network, True), (self.ingress_network, False)):
205
+ ids = docker("network", "ls", "--filter", f"name=^{network}$", "--format", "{{.ID}}").splitlines()
206
+ if ids:
207
+ d = object_json(docker("network", "inspect", network, "--format",
208
+ '{"internal":{{json .Internal}},"labels":{{json .Labels}}}'))
209
+ require(d["internal"] == internal and (d["labels"] or {}).get(f"{LABEL}.scope") == self.scope,
210
+ "NETWORK_MISMATCH", "同名網路不屬於此 workspace 或隔離設定不符。")
211
+ else:
212
+ flags = ["--internal"] if internal else []
213
+ subnet = available_subnet(self.scope + network)
214
+ docker("network", "create", *flags, "--subnet", subnet, "--label", f"{LABEL}=local",
215
+ "--label", f"{LABEL}.scope={self.scope}", network)
216
+
217
+ def url(self, record):
218
+ network = record.get('network', self.network)
219
+ require(network == self.network or re.fullmatch(re.escape(self.network) + r'-[a-f0-9]{8}', network), 'NETWORK_MISMATCH', 'Workload network 不屬於此 workspace。')
220
+ d = self.owned(record["container_id"])
221
+ require(d["running"], "SERVICE_NOT_RUNNING", "容器未執行。")
222
+ require(set(d["networks"]) == {network}, "NETWORK_MISMATCH", "容器網路與隔離契約不符。")
223
+ require(not any(d["ports"].values()), "PORT_MISMATCH", "應用容器不能直接發布 port。")
224
+ ingress = self.owned(record["ingress_id"])
225
+ require(ingress["running"] and set(ingress["networks"]) == {network, self.ingress_network},
226
+ "NETWORK_MISMATCH", "Ingress 執行狀態或網路不符。")
227
+ ports = ingress["ports"].get("8080/tcp") or []
228
+ require(len(ports) == 1 and ports[0]["HostIp"] == "127.0.0.1", "PORT_MISMATCH", "服務必須只綁定 loopback。")
229
+ return f"http://127.0.0.1:{int(ports[0]['HostPort'])}"
230
+
231
+ def public(self, r):
232
+ return {"name": r["name"], "kind": r["kind"], "workspace": self.workspace,
233
+ "revision": r["revision"], "url": self.url(r), "readiness": "tcp",
234
+ **{k: r[k] for k in ("runtime", "handler", "role", "sdkTransport", "iamEnforced") if k in r},
235
+ "status": "running", "backend": "local-docker"}
236
+
237
+ def deploy(self, *, kind, service, image, port=8080, env=None, command=None,
238
+ startup_timeout=30, replace=True, require_existing=False, runtime=None, handler=None, preserve_environment=False,
239
+ runtime_env=None, role=None, gateway_container=None):
240
+ name(service)
241
+ require(kind in KINDS, "UNSUPPORTED_SERVICE", "未支援此服務。")
242
+ require(type(port) is int and 1024 <= port <= 65535, "INVALID_PORT", "無效的容器 port。")
243
+ require(1 <= startup_timeout <= 120, "INVALID_TIMEOUT", "啟動期限須介於 1–120 秒。")
244
+ env = environment(dict(env or {}))
245
+ require(not role or (runtime_env and gateway_container), 'IAM_BACKEND_REQUIRED', 'IAM workload role 需要已配置的遠端 gateway;本機 Docker 模式不假裝執行 IAM。')
246
+ image_id = inspect_image(image)
247
+ ingress_image_id = inspect_image(INGRESS_IMAGE)
248
+ key = f"{kind}:{service}"
249
+ with self.locked():
250
+ state = self.read_state()
251
+ old = state["services"].get(key)
252
+ require(replace or old is None, "ALREADY_EXISTS", "同名服務已存在;create-function 不會覆寫。")
253
+ require(not require_existing or old is not None, "NOT_FOUND", "更新目標不存在;未建立新服務。")
254
+ if old:
255
+ self.owned(old["container_id"])
256
+ self.owned(old["ingress_id"])
257
+ if preserve_environment:
258
+ require(old is not None and 'environment_names' in old, 'UNSUPPORTED_CONFIG', '原部署缺少環境欄位資訊;拒絕可能改變設定的程式碼更新。')
259
+ original = object_json(docker('container', 'inspect', old['container_id'], '--format', '{{json .Config.Env}}'))
260
+ original_env = dict(item.split('=', 1) for item in original if '=' in item)
261
+ env = environment({k: original_env[k] for k in old['environment_names']})
262
+ network = self.network + '-' + uuid.uuid4().hex[:8] if runtime_env else self.network
263
+ self.ensure_network(network)
264
+ gateway = None
265
+ if runtime_env:
266
+ require(gateway_container is not None, 'GATEWAY_UNAVAILABLE', 'Workload gateway 尚未配置。')
267
+ gateway = object_json(docker('container', 'inspect', gateway_container, '--format',
268
+ '{"id":{{json .Id}},"labels":{{json .Config.Labels}},"networks":{{json .NetworkSettings.Networks}},"running":{{json .State.Running}}}'))
269
+ require(gateway['running'] and gateway['labels'].get('com.docker.compose.service') == 'local-cloud-console',
270
+ 'GATEWAY_UNAVAILABLE', '指定 gateway 必須是正在執行的 FUFU Console。')
271
+ if network not in gateway['networks']:
272
+ docker('network', 'connect', '--alias', 'fufu-api', network, gateway['id'])
273
+ revision = f"{service}-{uuid.uuid4().hex[:8]}"
274
+ cname = f"fufu-cli-{self.scope}-{uuid.uuid4().hex[:12]}"
275
+ supplied = dict(env)
276
+ supplied.update(runtime_env or {})
277
+ supplied["AWS_EC2_METADATA_DISABLED"] = "true"
278
+ if kind == "aws-lambda-sync":
279
+ supplied.update(AWS_LAMBDA_FUNCTION_NAME=service, AWS_REGION=(runtime_env or {}).get('AWS_REGION', 'us-east-1'),
280
+ AWS_DEFAULT_REGION=(runtime_env or {}).get('AWS_REGION', 'us-east-1'))
281
+ else:
282
+ supplied.update(PORT=str(port), K_SERVICE=service, K_REVISION=revision, K_CONFIGURATION=service)
283
+ args = ["run", "--detach", "--pull=never", "--name", cname,
284
+ "--label", f"{LABEL}=local", "--label", f"{LABEL}.scope={self.scope}",
285
+ "--network", network,
286
+ "--user", "10001:10001", "--read-only", "--tmpfs", "/tmp:rw,nosuid,nodev,size=64m",
287
+ "--cap-drop", "ALL", "--security-opt", "no-new-privileges:true",
288
+ "--pids-limit", "128", "--memory", "512m", "--cpus", "1",
289
+ "--log-opt", "max-size=5m", "--log-opt", "max-file=1"]
290
+ for k in sorted(supplied):
291
+ args.extend(["--env", k])
292
+ args.append(image_id)
293
+ args.extend(command or [])
294
+ candidate = None
295
+ ingress_candidate = None
296
+ committed = False
297
+ try:
298
+ # 環境值經子程序環境注入,不出現在命令列、狀態或部署輸出。
299
+ candidate = docker(*args, env=supplied)
300
+ record = {"name": service, "kind": kind, "container_id": candidate,
301
+ "port": port, "revision": revision,
302
+ **({'network': network, 'gateway_id': gateway['id']} if gateway else {}),
303
+ **({'role': role, 'sdkTransport': 'fufu-workload-token', 'iamEnforced': True} if role else {}),
304
+ **({"environment_names": sorted(env)} if kind != 'cloud-run-http' else {}),
305
+ **({"runtime": runtime, "handler": handler} if runtime is not None else {})}
306
+ deadline = time.monotonic() + startup_timeout
307
+ while True:
308
+ detail = self.owned(candidate)
309
+ require(detail["running"], "SERVICE_NOT_RUNNING", "容器未執行;原部署保留。")
310
+ require(set(detail["networks"]) == {network}, "NETWORK_MISMATCH", "應用容器網路不符。")
311
+ address = detail["networks"][network]["IPAddress"]
312
+ require(ipaddress.ip_address(address).is_private, "NETWORK_MISMATCH", "容器必須位於本機 private network。")
313
+ try:
314
+ # Docker published port 的 proxy 可能先開始監聽;直接查容器 port。
315
+ with socket.create_connection((address, port), timeout=0.5):
316
+ break
317
+ except OSError:
318
+ require(time.monotonic() < deadline, "STARTUP_TIMEOUT", "容器未在期限內監聽;原部署保留。")
319
+ time.sleep(0.1)
320
+ # 新版 Docker 不替 internal-only 網路發布 port;固定 upstream 的 ingress
321
+ # 橋接 loopback 與 internal network,應用本身始終沒有外部路由。
322
+ ingress_candidate = docker("create", "--pull=never", "--name", cname + "-ingress",
323
+ "--label", f"{LABEL}=local", "--label", f"{LABEL}.scope={self.scope}",
324
+ "--network", self.ingress_network, "--publish", "127.0.0.1::8080",
325
+ "--user", "10001:10001", "--read-only", "--cap-drop", "ALL",
326
+ "--security-opt", "no-new-privileges:true", "--pids-limit", "64", "--memory", "64m", "--cpus", "0.25",
327
+ "--log-opt", "max-size=1m", "--log-opt", "max-file=1",
328
+ "--env", "FUFU_UPSTREAM_HOST", "--env", "FUFU_UPSTREAM_PORT", ingress_image_id,
329
+ env={"FUFU_UPSTREAM_HOST": cname, "FUFU_UPSTREAM_PORT": str(port)})
330
+ docker("network", "connect", network, ingress_candidate)
331
+ docker("start", ingress_candidate)
332
+ record["ingress_id"] = ingress_candidate
333
+ deadline = time.monotonic() + startup_timeout
334
+ while True:
335
+ ingress = self.owned(ingress_candidate)
336
+ require(ingress["running"], "SERVICE_NOT_RUNNING", "Ingress 未執行。")
337
+ address = ingress["networks"][self.ingress_network]["IPAddress"]
338
+ try:
339
+ with socket.create_connection((address, 8080), timeout=0.5):
340
+ break
341
+ except OSError:
342
+ require(time.monotonic() < deadline, "STARTUP_TIMEOUT", "Ingress 未在期限內就緒。")
343
+ time.sleep(0.1)
344
+ result = self.public(record)
345
+ state["services"][key] = record
346
+ self.save_state(state)
347
+ committed = True
348
+ if old:
349
+ try:
350
+ self.owned(old["container_id"])
351
+ self.owned(old["ingress_id"])
352
+ docker("container", "rm", "--force", old["ingress_id"], old["container_id"])
353
+ self.cleanup_workload_network(old)
354
+ except FufuError:
355
+ result["warning"] = "新部署已就緒;舊容器待 fufu down 回收。"
356
+ return result
357
+ finally:
358
+ if candidate is None and not committed:
359
+ candidates = docker("container", "ls", "--all", "--quiet", "--no-trunc",
360
+ "--filter", f"name=^{cname}$", "--filter", f"label={LABEL}.scope={self.scope}").splitlines()
361
+ if len(candidates) == 1:
362
+ candidate = candidates[0]
363
+ if candidate and not committed:
364
+ if ingress_candidate:
365
+ self.owned(ingress_candidate)
366
+ docker("container", "rm", "--force", ingress_candidate)
367
+ self.owned(candidate)
368
+ docker("container", "rm", "--force", candidate)
369
+ if gateway: self.cleanup_workload_network({'network': network, 'gateway_id': gateway['id']})
370
+
371
+ def cleanup_workload_network(self, record):
372
+ network = record.get('network')
373
+ if not network: return
374
+ require(re.fullmatch(re.escape(self.network) + r'-[a-f0-9]{8}', network), 'NETWORK_MISMATCH', 'Workload network 不符。')
375
+ info = object_json(docker('network', 'inspect', network, '--format', '{"labels":{{json .Labels}},"containers":{{json .Containers}}}'))
376
+ require(info['labels'].get(f'{LABEL}.scope') == self.scope, 'OWNERSHIP_MISMATCH', 'Workload network 不符。')
377
+ require(set(info['containers']).issubset({record['gateway_id']}), 'NETWORK_BUSY', 'Workload network 仍有其他資源,未清除。')
378
+ if record['gateway_id'] in info['containers']:
379
+ docker('network', 'disconnect', network, record['gateway_id'])
380
+ docker('network', 'rm', network)
381
+
382
+ def describe(self, kind, service):
383
+ name(service)
384
+ with self.locked():
385
+ r = self.read_state()["services"].get(f"{kind}:{service}")
386
+ require(r is not None, "NOT_FOUND", "此 workspace 沒有該服務。")
387
+ return self.public(r)
388
+
389
+ def listing(self, kind=None):
390
+ with self.locked():
391
+ return [self.public(r) for r in self.read_state()["services"].values() if kind is None or r["kind"] == kind]
392
+
393
+ def delete(self, kind, service):
394
+ name(service)
395
+ with self.locked():
396
+ state = self.read_state()
397
+ key = f"{kind}:{service}"
398
+ r = state["services"].get(key)
399
+ require(r is not None, "NOT_FOUND", "此 workspace 沒有該服務。")
400
+ self.owned(r["container_id"])
401
+ self.owned(r["ingress_id"])
402
+ docker("container", "rm", "--force", r["ingress_id"], r["container_id"])
403
+ del state["services"][key]
404
+ self.save_state(state)
405
+ self.cleanup_workload_network(r)
406
+ return {"deleted": service, "kind": kind}
407
+
408
+ def down(self):
409
+ with self.locked():
410
+ self.read_state() # 損毀狀態不得觸發自動清除。
411
+ ids = docker("container", "ls", "--all", "--quiet", "--no-trunc", "--filter",
412
+ f"label={LABEL}=local", "--filter", f"label={LABEL}.scope={self.scope}").splitlines()
413
+ for cid in ids:
414
+ self.owned(cid)
415
+ docker("container", "rm", "--force", cid)
416
+ self.save_state({"schema": 1, "services": {}})
417
+ for network in (self.ingress_network, self.network):
418
+ networks = docker("network", "ls", "--filter", f"name=^{network}$", "--format", "{{.ID}}").splitlines()
419
+ if networks:
420
+ d = object_json(docker("network", "inspect", network, "--format", "{{json .Labels}}"))
421
+ require(d.get(f"{LABEL}.scope") == self.scope, "OWNERSHIP_MISMATCH", "網路不屬於此 workspace。")
422
+ docker("network", "rm", network)
423
+ return {"workspace": self.workspace, "removed_containers": len(ids)}
424
+
425
+ def invoke(self, kind, service, payload, *, path="/", timeout=15):
426
+ require(1 <= timeout <= 120, "INVALID_TIMEOUT", "請求期限須介於 1–120 秒。")
427
+ require(len(payload) <= MAX_PAYLOAD, "PAYLOAD_LIMIT", "Payload 超過 6 MiB。")
428
+ require(isinstance(path, str) and path.startswith("/") and not path.startswith("//")
429
+ and "\r" not in path and "\n" not in path, "INVALID_PATH", "只能使用本機服務相對路徑。")
430
+ data = self.describe(kind, service)
431
+ route = "/2015-03-31/functions/function/invocations" if kind == "aws-lambda-sync" else path
432
+
433
+ class NoRedirect(request.HTTPRedirectHandler):
434
+ def redirect_request(self, *args, **kw):
435
+ return None
436
+
437
+ opener = request.build_opener(request.ProxyHandler({}), NoRedirect())
438
+ req = request.Request(data["url"] + route, data=payload, headers={"Content-Type": "application/json"})
439
+ try:
440
+ try:
441
+ response = opener.open(req, timeout=timeout)
442
+ except error.HTTPError as e:
443
+ response = e
444
+ with response:
445
+ body = response.read(MAX_PAYLOAD + 1)
446
+ require(len(body) <= MAX_PAYLOAD, "RESPONSE_LIMIT", "服務回應超過 6 MiB。")
447
+ function_error = response.headers.get("X-Amz-Function-Error") or response.headers.get("Lambda-Runtime-Function-Error-Type")
448
+ status = response.status
449
+ except (TimeoutError, OSError, error.URLError) as exc:
450
+ # 客戶端逾時不宣稱已終止 handler,RIE 與正式 Lambda 的執行管理不同。
451
+ raise FufuError("INVOKE_TRANSPORT_FAILED", "本機請求失敗或逾時;handler 可能仍在執行。") from exc
452
+ if kind == "aws-lambda-sync" and status == 200:
453
+ try:
454
+ envelope = json.loads(body)
455
+ require(isinstance(envelope, dict) and envelope.get("protocol") == PROTOCOL
456
+ and set(envelope) == {"protocol", "result", "error"},
457
+ "LAMBDA_RUNTIME_FAILED", "Lambda runtime 未完成可驗證的 handler 執行;不會當作成功。")
458
+ failed = envelope["error"] is not None
459
+ if failed:
460
+ require(isinstance(envelope["error"], dict) and "errorType" in envelope["error"],
461
+ "LAMBDA_RUNTIME_FAILED", "Lambda runtime 錯誤格式不符。")
462
+ body = json.dumps(envelope["error"] if failed else envelope["result"], ensure_ascii=False).encode()
463
+ function_error = "Unhandled" if failed else None
464
+ except (ValueError, UnicodeDecodeError) as exc:
465
+ raise FufuError("LAMBDA_RUNTIME_FAILED", "Lambda runtime 回應無法解析。") from exc
466
+ return status, body, function_error
467
+
468
+
469
+ def build_image(context, tag, *, network="none"):
470
+ require(shutil.disk_usage(context).free >= 2 * 1024**3, "DISK_SPACE", "建置前至少需要 2 GiB 可用空間。")
471
+ docker("build", "--quiet", "--pull=false", "--network", network, "--tag", tag, str(context), timeout=300)
472
+ return inspect_image(tag)
473
+
474
+
475
+ def prepare_runtime(runtimes=("python3.12",)):
476
+ require(all(r in LAMBDA_IMAGES for r in runtimes), "UNSUPPORTED_RUNTIME", "不支援的 Lambda runtime。")
477
+ for image in (PYTHON_IMAGE, *(LAMBDA_IMAGES[r] for r in runtimes)):
478
+ try:
479
+ inspect_image(image)
480
+ except FufuError as exc:
481
+ if exc.code != "DOCKER_OPERATION_FAILED":
482
+ raise
483
+ docker("pull", image, timeout=240)
484
+ inspect_image(image)
485
+ runtime_dir = Path(__file__).with_name("runtime")
486
+ build_image(runtime_dir / "functions", FUNCTIONS_IMAGE, network="default")
487
+ build_image(runtime_dir / "ingress", INGRESS_IMAGE)
488
+ return {"prepared": ["functions-python312", "ingress", *runtimes], "backend": "local-docker"}
489
+
490
+
491
+ def build_function(source, target, *, network="default"):
492
+ require(re.fullmatch(r"[A-Za-z_][A-Za-z0-9_]*", target or ""), "INVALID_HANDLER", "入口必須是有效 Python 函式名稱。")
493
+ src = Path(source).resolve()
494
+ require(src.is_dir() and (src / "main.py").is_file(), "INVALID_SOURCE", "Python function 來源需包含 main.py。")
495
+ inspect_image(FUNCTIONS_IMAGE)
496
+ base = FUNCTIONS_IMAGE
497
+ with tempfile.TemporaryDirectory(prefix="fufu-function-") as tmp:
498
+ dst = Path(tmp)
499
+ files = [p for p in src.rglob("*") if p.is_file() and (p.suffix == ".py" or p.name == "requirements.txt")
500
+ and not any(part.startswith(".") or part in {"__pycache__", "node_modules"} for part in p.relative_to(src).parts)]
501
+ require(len(files) <= 1000 and sum(p.stat().st_size for p in files) <= 32 * 1024**2,
502
+ "SOURCE_LIMIT", "Python 來源超過檔案數或大小限制。")
503
+ for p in files:
504
+ require(not p.is_symlink() and p.resolve().is_relative_to(src), "INVALID_SOURCE", "來源不接受符號連結。")
505
+ q = dst / "app" / p.relative_to(src)
506
+ q.parent.mkdir(parents=True, exist_ok=True)
507
+ shutil.copyfile(p, q)
508
+ q.chmod(0o644)
509
+ sdk = dst / 'app/_fufu_runtime_sdk.py'
510
+ require(not sdk.exists(), 'RESERVED_FILE', '來源與 FUFU SDK 的保留檔名衝突。')
511
+ shutil.copyfile(Path(__file__).with_name('runtime_sdk.py'), sdk)
512
+ sdk.chmod(0o644)
513
+ requirements = dst / "app/requirements.txt"
514
+ if not requirements.exists():
515
+ requirements.write_text("")
516
+ # 原始 requirements 可安裝應用相依;build 有網路,runtime 不開放 egress。
517
+ dockerfile = (f"FROM {base}\nUSER 0\nCOPY app/requirements.txt /tmp/requirements.txt\n"
518
+ "RUN python -m pip install --index-url https://pypi.org/simple --disable-pip-version-check --no-cache-dir --retries 1 --timeout 20 -r /tmp/requirements.txt\n"
519
+ "COPY --chown=10001:10001 app/ /app/\nWORKDIR /app\nUSER 10001:10001\n"
520
+ + "CMD " + json.dumps(["functions-framework", "--target", target, "--signature-type", "http",
521
+ "--host", "0.0.0.0", "--port", "8080"]) + "\n")
522
+ (dst / "Dockerfile").write_text(dockerfile)
523
+ return build_image(dst, f"fufu-cli-function:{uuid.uuid4().hex}", network=network)
524
+
525
+
526
+ def extract_zip(archive, destination):
527
+ require(archive.is_file() and archive.stat().st_size <= 50 * 1024**2, "INVALID_ZIP", "ZIP 必須存在且不超過 50 MiB。")
528
+ try:
529
+ with zipfile.ZipFile(archive) as z:
530
+ info = z.infolist()
531
+ require(len(info) <= 10000 and sum(i.file_size for i in info) <= 250 * 1024**2,
532
+ "ZIP_LIMIT", "ZIP 解壓縮超過檔案數或大小上限。")
533
+ seen = set()
534
+ for i in info:
535
+ p = PurePosixPath(i.filename)
536
+ mode = i.external_attr >> 16
537
+ require(i.filename and "\\" not in i.filename and not p.is_absolute() and ".." not in p.parts
538
+ and not stat.S_ISLNK(mode) and not i.flag_bits & 1 and str(p) not in seen,
539
+ "INVALID_ZIP", "ZIP 含路徑穿越、符號連結、加密或重複檔案。")
540
+ seen.add(str(p))
541
+ for i in info:
542
+ dst = destination / i.filename
543
+ require(dst.resolve().is_relative_to(destination.resolve()), "INVALID_ZIP", "ZIP 路徑超出目的地。")
544
+ if i.is_dir():
545
+ dst.mkdir(parents=True, exist_ok=True)
546
+ else:
547
+ dst.parent.mkdir(parents=True, exist_ok=True)
548
+ with z.open(i) as src, dst.open("wb") as out:
549
+ shutil.copyfileobj(src, out)
550
+ dst.chmod(0o755 if (i.external_attr >> 16) & 0o111 else 0o644)
551
+ except FufuError:
552
+ raise
553
+ except Exception as exc:
554
+ raise FufuError("INVALID_ZIP", "ZIP 無法安全解壓縮。") from exc
555
+
556
+
557
+ def build_lambda(archive, runtime, handler):
558
+ require(runtime in LAMBDA_IMAGES, "UNSUPPORTED_RUNTIME", "目前只支援 Python 3.10 與 3.12 Lambda ZIP。")
559
+ require(re.fullmatch(r"[A-Za-z_]\w*(?:\.[A-Za-z_]\w*)+", handler or ""), "INVALID_HANDLER", "Lambda handler 格式應為 module.function。")
560
+ base = LAMBDA_IMAGES[runtime]
561
+ inspect_image(base)
562
+ with tempfile.TemporaryDirectory(prefix="fufu-lambda-") as tmp:
563
+ dst = Path(tmp)
564
+ app = dst / "app"
565
+ app.mkdir()
566
+ extract_zip(Path(archive), app)
567
+ module = app / (handler.rsplit(".", 1)[0].replace(".", "/") + ".py")
568
+ require(module.is_file(), "INVALID_HANDLER", "ZIP 缺少指定 handler 模組。")
569
+ adapter = app / "_fufu_runtime_adapter.py"
570
+ require(not adapter.exists(), "RESERVED_FILE", "ZIP 與 FUFU runtime adapter 的保留檔名衝突。")
571
+ shutil.copyfile(Path(__file__).with_name("lambda_adapter.py"), adapter)
572
+ adapter.chmod(0o644)
573
+ sdk = app / '_fufu_runtime_sdk.py'
574
+ require(not sdk.exists(), 'RESERVED_FILE', 'ZIP 與 FUFU SDK 的保留檔名衝突。')
575
+ shutil.copyfile(Path(__file__).with_name('runtime_sdk.py'), sdk)
576
+ sdk.chmod(0o644)
577
+ (dst / "Dockerfile").write_text(f"FROM {base}\nENV FUFU_HANDLER={handler}\nCOPY --chown=10001:10001 app/ ${{LAMBDA_TASK_ROOT}}/\nCMD " + json.dumps(["_fufu_runtime_adapter.handler"]) + "\n")
578
+ return build_image(dst, f"fufu-cli-lambda:{uuid.uuid4().hex}")
@@ -0,0 +1,11 @@
1
+ class FufuError(Exception):
2
+ def __init__(self, code: str, message: str, exit_code: int = 1):
3
+ self.code = code
4
+ self.message = message
5
+ self.exit_code = exit_code
6
+ super().__init__(message)
7
+
8
+
9
+ def require(condition, code, message):
10
+ if not condition:
11
+ raise FufuError(code, message)
@@ -0,0 +1,30 @@
1
+ """將原 handler 的成功與例外明確編碼;不以業務 payload 欄位猜測失敗。"""
2
+
3
+ import importlib
4
+ import json
5
+ import os
6
+ import traceback
7
+
8
+ PROTOCOL = "fufu.lambda-envelope.v1"
9
+
10
+
11
+ def handler(event, context):
12
+ error_type = None
13
+ try:
14
+ if os.environ.get('FUFU_RUNTIME_ENDPOINT'):
15
+ from _fufu_runtime_sdk import install
16
+ install()
17
+ module, function = os.environ["FUFU_HANDLER"].rsplit(".", 1)
18
+ original = getattr(importlib.import_module(module), function)
19
+ value = original(event, context)
20
+ try:
21
+ json.dumps(value, allow_nan=False)
22
+ except (ValueError, TypeError):
23
+ error_type = "Runtime.MarshalError"
24
+ raise
25
+ return {"protocol": PROTOCOL, "result": value, "error": None}
26
+ except Exception as exc:
27
+ failure = {"errorMessage": str(exc), "errorType": error_type or type(exc).__name__,
28
+ "requestId": context.aws_request_id,
29
+ "stackTrace": traceback.format_list(traceback.extract_tb(exc.__traceback__))}
30
+ return {"protocol": PROTOCOL, "result": None, "error": failure}