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
fufu_cloud_cli/cli.py
ADDED
|
@@ -0,0 +1,421 @@
|
|
|
1
|
+
import argparse
|
|
2
|
+
import json
|
|
3
|
+
import os
|
|
4
|
+
import sys
|
|
5
|
+
from importlib.resources import files
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from . import __version__
|
|
9
|
+
from . import manifest
|
|
10
|
+
from .docker_backend import (Backend, FUNCTIONS_IMAGE, INGRESS_IMAGE, KINDS, LAMBDA_IMAGES,
|
|
11
|
+
MAX_PAYLOAD, build_function, build_lambda, docker,
|
|
12
|
+
inspect_image, prepare_runtime)
|
|
13
|
+
from .errors import FufuError, require
|
|
14
|
+
from .api_backend import ApiClient, api_selected, remote_command
|
|
15
|
+
from .remote_compute import RemoteBackend, source_archive, lambda_archive
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class Parser(argparse.ArgumentParser):
|
|
19
|
+
def __init__(self, *args, **kwargs):
|
|
20
|
+
kwargs.setdefault("allow_abbrev", False)
|
|
21
|
+
super().__init__(*args, **kwargs)
|
|
22
|
+
|
|
23
|
+
def error(self, message):
|
|
24
|
+
raise FufuError("UNSUPPORTED_ARGUMENT", "缺少必要參數,或使用尚未支援的命令/旗標;請查看 --help。", 2)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def emit(value):
|
|
28
|
+
sys.stdout.write(json.dumps(value, ensure_ascii=False, sort_keys=True) + "\n")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def capability_data():
|
|
32
|
+
return json.loads(files("fufu_cloud_cli").joinpath("capabilities.json").read_text())
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def env_from_names(names):
|
|
36
|
+
result = {}
|
|
37
|
+
for k in names or []:
|
|
38
|
+
require(k in os.environ, "MISSING_ENV", "必要的環境變數未注入;不會輸出值。")
|
|
39
|
+
result[k] = os.environ[k]
|
|
40
|
+
return manifest.environment(result)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def payload(value):
|
|
44
|
+
if value is None:
|
|
45
|
+
return b"{}"
|
|
46
|
+
if value.startswith("file://") or value.startswith("fileb://"):
|
|
47
|
+
p = Path(value.split("://", 1)[1])
|
|
48
|
+
require(p.is_file() and p.stat().st_size <= MAX_PAYLOAD, "INVALID_PAYLOAD", "Payload 檔案必須存在且不超過 6 MiB。")
|
|
49
|
+
body = p.read_bytes()
|
|
50
|
+
else:
|
|
51
|
+
body = value.encode()
|
|
52
|
+
require(len(body) <= MAX_PAYLOAD, "PAYLOAD_LIMIT", "Payload 超過 6 MiB。")
|
|
53
|
+
try:
|
|
54
|
+
json.loads(body)
|
|
55
|
+
except (ValueError, UnicodeDecodeError) as exc:
|
|
56
|
+
raise FufuError("INVALID_PAYLOAD", "這個試點的 invoke 接受 JSON payload。") from exc
|
|
57
|
+
return body
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def body_json(body):
|
|
61
|
+
try:
|
|
62
|
+
return json.loads(body)
|
|
63
|
+
except (ValueError, UnicodeDecodeError):
|
|
64
|
+
return body.decode("utf-8", errors="replace")
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def deployment_options(p):
|
|
68
|
+
p.add_argument("--env", action="append", default=[], metavar="VARIABLE_NAME", help="從受控環境讀值,不接受 NAME=value")
|
|
69
|
+
p.add_argument("--startup-timeout", type=int, default=30)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def compute_backend(kind):
|
|
73
|
+
return RemoteBackend(kind) if api_selected() else Backend()
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def generation_options(p):
|
|
77
|
+
g = p.add_mutually_exclusive_group()
|
|
78
|
+
g.add_argument('--gen2', dest='gen2', action='store_true')
|
|
79
|
+
g.add_argument('--no-gen2', dest='gen2', action='store_false')
|
|
80
|
+
p.set_defaults(gen2=True)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def fufu_parser():
|
|
84
|
+
p = Parser(prog="fufu", description="FUFU 租戶認證、遠端部署與本機 Docker;所有輸出均為 FUFU 子集契約。")
|
|
85
|
+
p.add_argument("--version", action="version", version=__version__)
|
|
86
|
+
sub = p.add_subparsers(dest="action", required=True)
|
|
87
|
+
auth = sub.add_parser("auth", help="驗證 CLI credential,顯示目前租戶與 scope").add_subparsers(dest="auth_action", required=True)
|
|
88
|
+
identity = auth.add_parser("whoami")
|
|
89
|
+
identity.add_argument("--provider", choices=["aws", "gcp", "azure"])
|
|
90
|
+
identity.add_argument("--credential-file")
|
|
91
|
+
assume = auth.add_parser('assume-role')
|
|
92
|
+
role = assume.add_mutually_exclusive_group(required=True)
|
|
93
|
+
role.add_argument('--role-arn')
|
|
94
|
+
role.add_argument('--service-account')
|
|
95
|
+
assume.add_argument('--output-file', required=True, help='受保護 session 檔;不輸出 token')
|
|
96
|
+
auth.add_parser('logout')
|
|
97
|
+
sub.add_parser("doctor").add_argument('--provider', choices=['aws', 'gcp'], default='gcp')
|
|
98
|
+
local = sub.add_parser("local").add_subparsers(dest="local_action", required=True)
|
|
99
|
+
prepare = local.add_parser("prepare")
|
|
100
|
+
prepare.add_argument("--lambda-runtime", choices=sorted(LAMBDA_IMAGES), nargs="+", default=["python3.12"])
|
|
101
|
+
c = sub.add_parser("capabilities")
|
|
102
|
+
c.add_argument("--require", nargs="+", default=[])
|
|
103
|
+
c.add_argument('--remote', action='store_true')
|
|
104
|
+
c.add_argument('--provider', choices=['aws', 'gcp'], default='gcp')
|
|
105
|
+
for action in ("plan", "deploy"):
|
|
106
|
+
q = sub.add_parser(action)
|
|
107
|
+
q.add_argument("--file", required=True)
|
|
108
|
+
if action == "deploy":
|
|
109
|
+
deployment_options(q)
|
|
110
|
+
sub.add_parser("list").add_argument('--kind', choices=sorted(KINDS))
|
|
111
|
+
sub.add_parser("down")
|
|
112
|
+
for action in ("describe", "delete", "invoke"):
|
|
113
|
+
q = sub.add_parser(action)
|
|
114
|
+
q.add_argument("name")
|
|
115
|
+
q.add_argument("--kind", choices=sorted(KINDS), default="cloud-run-http")
|
|
116
|
+
if action == "invoke":
|
|
117
|
+
q.add_argument("--data", default="{}")
|
|
118
|
+
q.add_argument("--path", default="/")
|
|
119
|
+
q.add_argument("--request-timeout", type=int, default=15)
|
|
120
|
+
return p
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def fufu_command(argv):
|
|
124
|
+
a = fufu_parser().parse_args(argv)
|
|
125
|
+
if a.action == "auth":
|
|
126
|
+
if a.auth_action == 'assume-role':
|
|
127
|
+
import time
|
|
128
|
+
require(not os.environ.get('FUFU_SESSION_FILE'), 'UNSUPPORTED_ARGUMENT', '請使用 tenant owner credential 取得 session。')
|
|
129
|
+
os.environ['FUFU_ASSUME_ROLE'] = a.role_arn or a.service_account
|
|
130
|
+
provider = 'aws' if a.role_arn else 'gcp'
|
|
131
|
+
with ApiClient(provider) as api:
|
|
132
|
+
who = api.whoami()
|
|
133
|
+
target = Path(a.output_file)
|
|
134
|
+
fd = os.open(target, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
|
|
135
|
+
with os.fdopen(fd, 'w') as f:
|
|
136
|
+
json.dump({'endpoint': api.endpoint, 'provider': provider, 'tenant_id': who['tenantId'], 'access_token': api.token, 'expires_at': time.time() + 3500}, f)
|
|
137
|
+
api.owns_token = False
|
|
138
|
+
return {'created': True, 'expiresIn': 3500, 'iamEnforced': True}
|
|
139
|
+
if a.auth_action == 'logout':
|
|
140
|
+
with ApiClient() as api:
|
|
141
|
+
api.owns_token = True
|
|
142
|
+
return {'revoked': True}
|
|
143
|
+
with ApiClient(a.provider, a.credential_file, read_only=True) as api:
|
|
144
|
+
return api.whoami()
|
|
145
|
+
if a.action == "local":
|
|
146
|
+
return prepare_runtime(a.lambda_runtime)
|
|
147
|
+
if a.action == "capabilities":
|
|
148
|
+
d = capability_data()
|
|
149
|
+
backend = 'api' if api_selected() or a.remote else 'local-docker'
|
|
150
|
+
require(all(d["services"].get(k, {}).get("status") == "implemented"
|
|
151
|
+
and (k not in KINDS or backend in d['services'][k].get('backends', [d['services'][k].get('backend', 'local-docker')])) for k in a.require),
|
|
152
|
+
"CAPABILITY_NOT_SUPPORTED", "必要能力尚未支援;部署應阻擋。")
|
|
153
|
+
if a.remote:
|
|
154
|
+
with ApiClient(a.provider, read_only=True) as api:
|
|
155
|
+
d['remote'] = api.request('GET', '/compute/capabilities')
|
|
156
|
+
require(all(k in d['remote']['kinds'] + d['remote'].get('features', []) for k in a.require), 'CAPABILITY_NOT_SUPPORTED', '目前 provider 的服務端不支援必要能力。')
|
|
157
|
+
d['selectedBackend'] = backend
|
|
158
|
+
return d
|
|
159
|
+
if a.action == "plan":
|
|
160
|
+
result = manifest.public_plan(manifest.load(a.file))
|
|
161
|
+
result['backend'] = 'api' if api_selected() else 'local-docker'
|
|
162
|
+
return result
|
|
163
|
+
if a.action == "doctor":
|
|
164
|
+
if api_selected():
|
|
165
|
+
with ApiClient(a.provider, read_only=True) as api:
|
|
166
|
+
return api.request('GET', '/compute/capabilities')
|
|
167
|
+
b = Backend()
|
|
168
|
+
docker("info", "--format", "{{.OSType}}")
|
|
169
|
+
available = {}
|
|
170
|
+
for label, image in {"functions-python": FUNCTIONS_IMAGE, "ingress": INGRESS_IMAGE, **LAMBDA_IMAGES}.items():
|
|
171
|
+
try:
|
|
172
|
+
inspect_image(image)
|
|
173
|
+
available[label] = True
|
|
174
|
+
except FufuError:
|
|
175
|
+
available[label] = False
|
|
176
|
+
return {"backend": "local-docker", "docker": "reachable", "workspace": b.workspace,
|
|
177
|
+
"images": available, "auth": "local-docker-permission", "remote_cloud": False}
|
|
178
|
+
if a.action == "deploy":
|
|
179
|
+
spec = manifest.load(a.file)
|
|
180
|
+
spec["env"].update(env_from_names(a.env))
|
|
181
|
+
return compute_backend('cloud-run-http').deploy(kind="cloud-run-http", service=spec["name"], image=spec["image"],
|
|
182
|
+
port=spec["port"], env=spec["env"], startup_timeout=a.startup_timeout, **({'role': spec['role']} if spec.get('role') else {}))
|
|
183
|
+
if a.action == "down":
|
|
184
|
+
require(not api_selected(), 'UNSUPPORTED_ARGUMENT', 'API 模式請逐一刪除指定服務;不提供整個租戶清空。')
|
|
185
|
+
return Backend().down()
|
|
186
|
+
if a.action == "list":
|
|
187
|
+
if not api_selected(): return {"services": Backend().listing(a.kind)}
|
|
188
|
+
kind = a.kind or 'cloud-run-http'
|
|
189
|
+
return {"services": RemoteBackend(kind).listing(kind)}
|
|
190
|
+
b = compute_backend(a.kind)
|
|
191
|
+
if a.action == "describe":
|
|
192
|
+
return b.describe(a.kind, a.name)
|
|
193
|
+
if a.action == "delete":
|
|
194
|
+
return b.delete(a.kind, a.name)
|
|
195
|
+
status, body, failure = b.invoke(a.kind, a.name, payload(a.data), path=a.path, timeout=a.request_timeout)
|
|
196
|
+
emit({"status": status, "body": body_json(body), "function_error": failure})
|
|
197
|
+
return 0 if status < 400 and not failure else 1
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def gcloud_command(argv):
|
|
201
|
+
p = Parser(prog="gcloud-fufu", description="支援 Cloud Run 單一容器與 Python HTTP function 子集。")
|
|
202
|
+
roots = p.add_subparsers(dest="service", required=True)
|
|
203
|
+
run_p = roots.add_parser("run")
|
|
204
|
+
run_sub = run_p.add_subparsers(dest="action", required=True)
|
|
205
|
+
d = run_sub.add_parser("deploy")
|
|
206
|
+
d.add_argument("name")
|
|
207
|
+
d.add_argument("--image", required=True)
|
|
208
|
+
d.add_argument("--port", type=int, default=8080)
|
|
209
|
+
d.add_argument('--service-account')
|
|
210
|
+
deployment_options(d)
|
|
211
|
+
services = run_sub.add_parser("services").add_subparsers(dest="service_action", required=True)
|
|
212
|
+
replace = services.add_parser("replace")
|
|
213
|
+
replace.add_argument("file")
|
|
214
|
+
deployment_options(replace)
|
|
215
|
+
for action in ("describe", "delete"):
|
|
216
|
+
services.add_parser(action).add_argument("name")
|
|
217
|
+
services.add_parser("list")
|
|
218
|
+
fn = roots.add_parser("functions").add_subparsers(dest="action", required=True)
|
|
219
|
+
d = fn.add_parser("deploy")
|
|
220
|
+
d.add_argument("name")
|
|
221
|
+
d.add_argument("--source", required=True)
|
|
222
|
+
d.add_argument("--runtime", choices=["python312"], required=True)
|
|
223
|
+
d.add_argument("--entry-point", required=True)
|
|
224
|
+
d.add_argument('--service-account')
|
|
225
|
+
generation_options(d)
|
|
226
|
+
d.add_argument("--trigger-http", required=True, action="store_true")
|
|
227
|
+
deployment_options(d)
|
|
228
|
+
for action in ("describe", "delete", "call"):
|
|
229
|
+
q = fn.add_parser(action)
|
|
230
|
+
q.add_argument("name")
|
|
231
|
+
generation_options(q)
|
|
232
|
+
if action == "call":
|
|
233
|
+
q.add_argument("--data", default="{}")
|
|
234
|
+
generation_options(fn.add_parser("list"))
|
|
235
|
+
a = p.parse_args(argv)
|
|
236
|
+
kind = "cloud-run-http" if a.service == "run" else 'cloud-run-functions-http' if a.gen2 else 'cloud-functions-gen1-http'
|
|
237
|
+
action = a.service_action if a.action == "services" else a.action
|
|
238
|
+
if action == "replace":
|
|
239
|
+
spec = manifest.load(a.file)
|
|
240
|
+
spec["env"].update(env_from_names(a.env))
|
|
241
|
+
return compute_backend(kind).deploy(kind=kind, service=spec["name"], image=spec["image"], port=spec["port"],
|
|
242
|
+
env=spec["env"], startup_timeout=a.startup_timeout, **({'role': spec['role']} if spec.get('role') else {}))
|
|
243
|
+
if action == "deploy":
|
|
244
|
+
manifest.name(a.name)
|
|
245
|
+
env = env_from_names(a.env)
|
|
246
|
+
if a.service == 'functions' and api_selected():
|
|
247
|
+
return RemoteBackend(kind).deploy(kind=kind, service=a.name, env=env,
|
|
248
|
+
archive=source_archive(a.source), runtime=a.runtime, handler=a.entry_point, startup_timeout=a.startup_timeout, role=a.service_account)
|
|
249
|
+
image = a.image if a.service == "run" else build_function(a.source, a.entry_point)
|
|
250
|
+
return compute_backend(kind).deploy(kind=kind, service=a.name, image=image, port=getattr(a, "port", 8080),
|
|
251
|
+
env=env, startup_timeout=a.startup_timeout, **({'role': a.service_account} if a.service_account else {}))
|
|
252
|
+
b = compute_backend(kind)
|
|
253
|
+
if action == "describe":
|
|
254
|
+
return b.describe(kind, a.name)
|
|
255
|
+
if action == "delete":
|
|
256
|
+
return b.delete(kind, a.name)
|
|
257
|
+
if action == "list":
|
|
258
|
+
return {"services": b.listing(kind)}
|
|
259
|
+
status, body, failure = b.invoke(kind, a.name, payload(a.data))
|
|
260
|
+
emit({"status": status, "result": body_json(body)})
|
|
261
|
+
return 0 if status < 400 and not failure else 1
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def aws_command(argv):
|
|
265
|
+
p = Parser(prog="aws-fufu", description="Lambda ZIP 與同步 invoke、IAM workload role 子集。")
|
|
266
|
+
root = p.add_subparsers(dest="service", required=True)
|
|
267
|
+
s = root.add_parser("lambda").add_subparsers(dest="action", required=True)
|
|
268
|
+
c = s.add_parser("create-function")
|
|
269
|
+
c.add_argument("--function-name", required=True)
|
|
270
|
+
c.add_argument("--runtime", choices=sorted(LAMBDA_IMAGES), required=True)
|
|
271
|
+
c.add_argument("--handler", required=True)
|
|
272
|
+
c.add_argument("--zip-file", required=True)
|
|
273
|
+
c.add_argument('--role')
|
|
274
|
+
deployment_options(c)
|
|
275
|
+
update = s.add_parser('update-function-code')
|
|
276
|
+
update.add_argument('--function-name', required=True)
|
|
277
|
+
update.add_argument('--zip-file', required=True)
|
|
278
|
+
deployment_options(update)
|
|
279
|
+
for action in ("get-function", "delete-function", "invoke"):
|
|
280
|
+
q = s.add_parser(action)
|
|
281
|
+
q.add_argument("--function-name", required=True)
|
|
282
|
+
if action == "invoke":
|
|
283
|
+
q.add_argument("--payload", default="{}")
|
|
284
|
+
q.add_argument("--invocation-type", choices=["RequestResponse"], default="RequestResponse")
|
|
285
|
+
q.add_argument("--cli-binary-format", choices=["raw-in-base64-out"], default="raw-in-base64-out")
|
|
286
|
+
q.add_argument("--request-timeout", type=int, default=15)
|
|
287
|
+
q.add_argument("outfile")
|
|
288
|
+
s.add_parser("list-functions")
|
|
289
|
+
a = p.parse_args(argv)
|
|
290
|
+
kind = "aws-lambda-sync"
|
|
291
|
+
if a.action in {"create-function", "update-function-code"}:
|
|
292
|
+
manifest.name(a.function_name)
|
|
293
|
+
env = env_from_names(a.env)
|
|
294
|
+
require(not getattr(a, 'role', None) or api_selected(), 'IAM_BACKEND_REQUIRED', 'Lambda IAM role 需要 API 模式。')
|
|
295
|
+
require(a.zip_file.startswith("fileb://"), "INVALID_ZIP", "ZIP 參數請使用 fileb:// 路徑。")
|
|
296
|
+
b = compute_backend(kind)
|
|
297
|
+
updating = a.action == 'update-function-code'
|
|
298
|
+
if updating:
|
|
299
|
+
previous = b.describe(kind, a.function_name)
|
|
300
|
+
require(previous.get('runtime') and previous.get('handler'), 'UNSUPPORTED_CONFIG', '原服務缺少 runtime metadata;請先完成一次明確來源部署。')
|
|
301
|
+
a.runtime, a.handler = previous['runtime'], previous['handler']
|
|
302
|
+
require(not a.env, 'UNSUPPORTED_ARGUMENT', 'update-function-code 不變更環境設定;請使用明確的重新部署流程。')
|
|
303
|
+
if api_selected():
|
|
304
|
+
return b.deploy(kind=kind, service=a.function_name, archive=lambda_archive(a.zip_file[8:]),
|
|
305
|
+
runtime=a.runtime, handler=a.handler, env=env, startup_timeout=a.startup_timeout,
|
|
306
|
+
replace=updating, require_existing=updating, preserve_environment=updating, role=getattr(a, 'role', None))
|
|
307
|
+
# 在耗費建置前檢查重名;真正提交時仍在鎖內再次驗證。
|
|
308
|
+
with b.locked():
|
|
309
|
+
require(updating or f"{kind}:{a.function_name}" not in b.read_state()["services"], "ALREADY_EXISTS", "同名 Lambda 已存在。")
|
|
310
|
+
image = build_lambda(a.zip_file[8:], a.runtime, a.handler)
|
|
311
|
+
return b.deploy(kind=kind, service=a.function_name, image=image, env=env,
|
|
312
|
+
startup_timeout=a.startup_timeout, replace=updating, require_existing=updating,
|
|
313
|
+
runtime=a.runtime, handler=a.handler, preserve_environment=updating)
|
|
314
|
+
if a.action == "list-functions":
|
|
315
|
+
return {"Functions": compute_backend(kind).listing(kind)}
|
|
316
|
+
if a.action == "get-function":
|
|
317
|
+
return compute_backend(kind).describe(kind, a.function_name)
|
|
318
|
+
if a.action == "delete-function":
|
|
319
|
+
return compute_backend(kind).delete(kind, a.function_name)
|
|
320
|
+
data = payload(a.payload)
|
|
321
|
+
destination = Path(a.outfile)
|
|
322
|
+
require(not destination.exists() and not destination.is_symlink() and destination.parent.is_dir(),
|
|
323
|
+
"OUTPUT_EXISTS", "輸出檔必須是新檔案且父目錄已存在,避免覆寫既有資料。")
|
|
324
|
+
# 先保留輸出位置再 invoke,避免呼叫已發生卻沒有可保存結果的位置。
|
|
325
|
+
fd = os.open(destination, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
|
|
326
|
+
try:
|
|
327
|
+
with os.fdopen(fd, "wb") as out:
|
|
328
|
+
status, body, failure = compute_backend(kind).invoke(kind, a.function_name, data, timeout=a.request_timeout)
|
|
329
|
+
out.write(body)
|
|
330
|
+
result = {"StatusCode": status}
|
|
331
|
+
if failure:
|
|
332
|
+
result["FunctionError"] = failure
|
|
333
|
+
emit(result)
|
|
334
|
+
# AWS 同步 invoke 的 handler error 透過 FunctionError 與 payload 回報。
|
|
335
|
+
return 0 if status < 400 else 1
|
|
336
|
+
except Exception:
|
|
337
|
+
destination.unlink(missing_ok=True)
|
|
338
|
+
raise
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
def entry(provider, argv=None):
|
|
342
|
+
try:
|
|
343
|
+
argv = sys.argv[1:] if argv is None else argv
|
|
344
|
+
argv = list(argv)
|
|
345
|
+
require(provider in {"fufu", "aws-fufu", "gcloud-fufu", "az-fufu"}, "UNSUPPORTED_CLI", "未知 CLI 入口。")
|
|
346
|
+
# Native global selectors are checked against the authenticated tenant scope.
|
|
347
|
+
if provider in {'aws-fufu', 'gcloud-fufu'}:
|
|
348
|
+
rest = []; i = 0
|
|
349
|
+
while i < len(argv):
|
|
350
|
+
flag, _, inline = argv[i].partition('=')
|
|
351
|
+
if flag in {'--region', '--project', '--output', '--format', '--impersonate-service-account'}:
|
|
352
|
+
value = inline if '=' in argv[i] else (argv[i + 1] if i + 1 < len(argv) else '')
|
|
353
|
+
i += 1 if '=' in argv[i] else 2
|
|
354
|
+
require(bool(value) and not value.startswith('--'), 'UNSUPPORTED_ARGUMENT', '全域旗標缺少值。')
|
|
355
|
+
if flag == '--impersonate-service-account':
|
|
356
|
+
require(provider == 'gcloud-fufu', 'UNSUPPORTED_ARGUMENT', '此旗標只適用於 gcloud-fufu。')
|
|
357
|
+
os.environ['FUFU_ASSUME_ROLE'] = value
|
|
358
|
+
elif flag in {'--output', '--format'}: require(value == 'json', 'UNSUPPORTED_ARGUMENT', '目前輸出格式支援 json。')
|
|
359
|
+
else: os.environ['FUFU_SELECTED_REGION' if flag == '--region' else 'FUFU_SELECTED_PROJECT'] = value
|
|
360
|
+
elif flag in {'--quiet', '--no-cli-pager'}:
|
|
361
|
+
require('=' not in argv[i], 'UNSUPPORTED_ARGUMENT', '布林旗標不接受額外值。'); i += 1
|
|
362
|
+
else: rest.append(argv[i]); i += 1
|
|
363
|
+
argv = rest
|
|
364
|
+
if provider == 'aws-fufu' and argv and argv[0] == 'serverless':
|
|
365
|
+
from .serverless import command
|
|
366
|
+
emit(command(argv[1:], Parser)); return
|
|
367
|
+
if provider == 'fufu' and argv and argv[0] in {'gateway', 'events', 'mock'}:
|
|
368
|
+
from .control import fufu_control
|
|
369
|
+
emit(fufu_control(argv, Parser)); return
|
|
370
|
+
if provider == 'aws-fufu' and argv and argv[0] in {'iam', 'dynamodb', 's3api', 'sqs', 'ssm'}:
|
|
371
|
+
from .control import command
|
|
372
|
+
emit(command(argv, Parser)); return
|
|
373
|
+
if provider == 'gcloud-fufu' and argv and argv[0] in {'iam', 'projects'}:
|
|
374
|
+
from .control import gcp_command
|
|
375
|
+
emit(gcp_command(argv, Parser)); return
|
|
376
|
+
if provider == 'aws-fufu' and argv and argv[0] == 'apigatewayv2':
|
|
377
|
+
from .control import gateway_command
|
|
378
|
+
emit(gateway_command(argv[1:], Parser)); return
|
|
379
|
+
if provider == 'aws-fufu' and len(argv) > 1 and argv[0] == 'lambda' and argv[1] in {'create-event-source-mapping', 'list-event-source-mappings', 'delete-event-source-mapping'}:
|
|
380
|
+
from .control import mapping_command
|
|
381
|
+
emit(mapping_command(argv[1:], Parser)); return
|
|
382
|
+
if provider == 'az-fufu' and argv and argv[0] == 'functionapp':
|
|
383
|
+
raise FufuError('CAPABILITY_NOT_SUPPORTED', 'Azure Functions runtime 尚未實作。', 2)
|
|
384
|
+
remote = {"aws-fufu": "aws", "gcloud-fufu": "gcp", "az-fufu": "azure"}
|
|
385
|
+
if provider == 'aws-fufu' and argv and argv[0] in {'s3', 'sqs', 'ssm'}:
|
|
386
|
+
from .cloud_data import aws_data_command
|
|
387
|
+
emit(aws_data_command(argv, Parser))
|
|
388
|
+
return
|
|
389
|
+
api_services = {"aws-fufu": {"sts", "dynamodb"}, "gcloud-fufu": {"auth", "storage"}, "az-fufu": {"account", "group", "resource"}}
|
|
390
|
+
compute = (provider == 'aws-fufu' and argv and argv[0] == 'lambda') or (provider == 'gcloud-fufu' and argv and argv[0] in {'run', 'functions'})
|
|
391
|
+
if provider in remote and not compute and (provider == "az-fufu" or api_selected() or (argv and argv[0] in api_services[provider])):
|
|
392
|
+
result = remote_command(remote[provider], argv, Parser)
|
|
393
|
+
else:
|
|
394
|
+
result = {"fufu": fufu_command, "aws-fufu": aws_command, "gcloud-fufu": gcloud_command}[provider](argv)
|
|
395
|
+
if type(result) is int:
|
|
396
|
+
raise SystemExit(result)
|
|
397
|
+
if provider == 'az-fufu' and argv and argv[0] != 'account' and isinstance(result, dict):
|
|
398
|
+
result['Fufu'] = {'mode': 'mock-only', 'executable': False}
|
|
399
|
+
emit(result)
|
|
400
|
+
except FufuError as exc:
|
|
401
|
+
sys.stderr.write(json.dumps({"error": {"code": exc.code, "message": exc.message}}, ensure_ascii=False) + "\n")
|
|
402
|
+
raise SystemExit(exc.exit_code)
|
|
403
|
+
except (OSError, ValueError, KeyError, TypeError) as exc:
|
|
404
|
+
sys.stderr.write(json.dumps({"error": {"code": "LOCAL_OPERATION_FAILED", "message": "本機檔案或輸入操作失敗;未輸出原始設定。"}}, ensure_ascii=False) + "\n")
|
|
405
|
+
raise SystemExit(1) from exc
|
|
406
|
+
|
|
407
|
+
|
|
408
|
+
def main():
|
|
409
|
+
entry("fufu")
|
|
410
|
+
|
|
411
|
+
|
|
412
|
+
def aws_main():
|
|
413
|
+
entry("aws-fufu")
|
|
414
|
+
|
|
415
|
+
|
|
416
|
+
def gcloud_main():
|
|
417
|
+
entry("gcloud-fufu")
|
|
418
|
+
|
|
419
|
+
|
|
420
|
+
def az_main():
|
|
421
|
+
entry("az-fufu")
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import base64
|
|
2
|
+
import json
|
|
3
|
+
import os
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from urllib.parse import urlencode, urlsplit
|
|
6
|
+
|
|
7
|
+
from .api_backend import ApiClient, segment
|
|
8
|
+
from .errors import require
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def upload_bytes(path):
|
|
12
|
+
p = Path(path)
|
|
13
|
+
require(p.is_file() and p.stat().st_size <= 6 * 1024**2, 'PAYLOAD_LIMIT', '物件上傳檔案上限為 6 MiB。')
|
|
14
|
+
return base64.b64encode(p.read_bytes()).decode()
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def download_bytes(path, encoded):
|
|
18
|
+
p = Path(path)
|
|
19
|
+
require(not p.exists() and not p.is_symlink() and p.parent.is_dir(), 'OUTPUT_EXISTS', '下載目的地必須是新檔案。')
|
|
20
|
+
data = base64.b64decode(encoded, validate=True)
|
|
21
|
+
fd = os.open(p, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
|
|
22
|
+
with os.fdopen(fd, 'wb') as f:
|
|
23
|
+
f.write(data)
|
|
24
|
+
return {'downloaded': True, 'bytes': len(data)}
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def s3_path(value):
|
|
28
|
+
p = urlsplit(value)
|
|
29
|
+
require(p.scheme == 's3' and p.netloc and not p.query and not p.fragment and not p.username and not p.password,
|
|
30
|
+
'INVALID_RESOURCE', '請使用 s3://bucket 或 s3://bucket/key。')
|
|
31
|
+
return '/s3/buckets/' + segment(p.netloc), p.path.lstrip('/')
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def aws_data_command(argv, Parser):
|
|
35
|
+
p = Parser(prog='aws-fufu')
|
|
36
|
+
root = p.add_subparsers(dest='service', required=True)
|
|
37
|
+
s3 = root.add_parser('s3').add_subparsers(dest='action', required=True)
|
|
38
|
+
s3.add_parser('ls').add_argument('url', nargs='?')
|
|
39
|
+
for action in ('mb', 'rb', 'rm'):
|
|
40
|
+
s3.add_parser(action).add_argument('url')
|
|
41
|
+
cp = s3.add_parser('cp'); cp.add_argument('source'); cp.add_argument('destination')
|
|
42
|
+
sqs = root.add_parser('sqs').add_subparsers(dest='action', required=True)
|
|
43
|
+
sqs.add_parser('list-queues')
|
|
44
|
+
sqs.add_parser('create-queue').add_argument('--queue-name', required=True)
|
|
45
|
+
for action in ('delete-queue', 'send-message', 'receive-message', 'delete-message'):
|
|
46
|
+
q = sqs.add_parser(action); q.add_argument('--queue-url', required=True)
|
|
47
|
+
if action == 'send-message': q.add_argument('--message-body', required=True)
|
|
48
|
+
if action == 'delete-message': q.add_argument('--receipt-handle', required=True)
|
|
49
|
+
ssm = root.add_parser('ssm').add_subparsers(dest='action', required=True)
|
|
50
|
+
for action in ('get-parameter', 'put-parameter', 'delete-parameter'):
|
|
51
|
+
q = ssm.add_parser(action); q.add_argument('--name', required=True)
|
|
52
|
+
if action == 'put-parameter':
|
|
53
|
+
values = q.add_mutually_exclusive_group(required=True)
|
|
54
|
+
values.add_argument('--value'); values.add_argument('--value-env')
|
|
55
|
+
q.add_argument('--type', choices=['String'], default='String')
|
|
56
|
+
q.add_argument('--overwrite', action='store_true')
|
|
57
|
+
a = p.parse_args(argv)
|
|
58
|
+
read_only = a.action in {'ls', 'list-queues', 'get-parameter'} or (a.action == 'cp' and a.source.startswith('s3://'))
|
|
59
|
+
with ApiClient('aws', read_only=read_only) as api:
|
|
60
|
+
if a.service == 'ssm':
|
|
61
|
+
path = '/ssm/parameter'
|
|
62
|
+
if a.action == 'put-parameter':
|
|
63
|
+
if a.value_env:
|
|
64
|
+
require(a.value_env in os.environ, 'MISSING_ENV', '必要的設定值尚未注入。')
|
|
65
|
+
value = os.environ[a.value_env]
|
|
66
|
+
else: value = a.value
|
|
67
|
+
return api.request('PUT', path, {'Name': a.name, 'Value': value, 'Type': a.type, 'Overwrite': a.overwrite})
|
|
68
|
+
return api.request('GET' if a.action == 'get-parameter' else 'DELETE', path + '?' + urlencode({'name': a.name}))
|
|
69
|
+
if a.service == 'sqs':
|
|
70
|
+
if a.action == 'list-queues': return api.request('GET', '/sqs/queues')
|
|
71
|
+
if a.action == 'create-queue': return api.request('POST', '/sqs/queues', {'QueueName': a.queue_name})
|
|
72
|
+
url = urlsplit(a.queue_url)
|
|
73
|
+
require(url.scheme == 'fufu' and url.netloc == 'sqs' and not url.query and not url.fragment,
|
|
74
|
+
'INVALID_RESOURCE', 'QueueUrl 必須是 FUFU create-queue 回傳的租戶資源識別。')
|
|
75
|
+
path = '/sqs/queues/' + segment(url.path.lstrip('/'))
|
|
76
|
+
if a.action == 'delete-queue': return api.request('DELETE', path)
|
|
77
|
+
if a.action == 'receive-message': return api.request('POST', path + '/receive', {})
|
|
78
|
+
if a.action == 'delete-message': return api.request('POST', path + '/delete-message', {'ReceiptHandle': a.receipt_handle})
|
|
79
|
+
return api.request('POST', path + '/messages', {'MessageBody': a.message_body})
|
|
80
|
+
def native(operation, params):
|
|
81
|
+
return api.request('POST', '/control/native', {'service': 's3', 'operation': operation, 'params': params})
|
|
82
|
+
if a.action == 'ls' and not a.url: return native('ListBuckets', {})
|
|
83
|
+
if a.action == 'cp':
|
|
84
|
+
downloading = a.source.startswith('s3://')
|
|
85
|
+
require(downloading != a.destination.startswith('s3://'), 'UNSUPPORTED_ARGUMENT', 'cp 支援本機檔案與 S3 之間傳輸。')
|
|
86
|
+
path, key = s3_path(a.source if downloading else a.destination)
|
|
87
|
+
bucket = urlsplit(a.source if downloading else a.destination).netloc
|
|
88
|
+
require(bool(key), 'INVALID_RESOURCE', '請指定 object key。')
|
|
89
|
+
if downloading:
|
|
90
|
+
result = native('GetObject', {'Bucket': bucket, 'Key': key})
|
|
91
|
+
return download_bytes(a.destination, result['Body']['__fufu_bytes'])
|
|
92
|
+
return native('PutObject', {'Bucket': bucket, 'Key': key, 'Body': {'__fufu_bytes': upload_bytes(a.source)}})
|
|
93
|
+
path, key = s3_path(a.url)
|
|
94
|
+
bucket = urlsplit(a.url).netloc
|
|
95
|
+
if a.action == 'rm':
|
|
96
|
+
require(bool(key), 'INVALID_RESOURCE', '請指定 object key。')
|
|
97
|
+
return native('DeleteObject', {'Bucket': bucket, 'Key': key})
|
|
98
|
+
require(not key, 'UNSUPPORTED_ARGUMENT', 'Bucket 操作不接受 object key 或 prefix。')
|
|
99
|
+
return native({'mb': 'CreateBucket', 'rb': 'DeleteBucket', 'ls': 'ListObjectsV2'}[a.action], {'Bucket': bucket})
|