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/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})