fufu-cloud-cli 0.5.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- fufu_cloud_cli/__init__.py +3 -0
- fufu_cloud_cli/__main__.py +9 -0
- fufu_cloud_cli/api_backend.py +302 -0
- fufu_cloud_cli/capabilities.json +570 -0
- fufu_cloud_cli/cli.py +421 -0
- fufu_cloud_cli/cloud_data.py +99 -0
- fufu_cloud_cli/control.py +185 -0
- fufu_cloud_cli/docker_backend.py +578 -0
- fufu_cloud_cli/errors.py +11 -0
- fufu_cloud_cli/lambda_adapter.py +30 -0
- fufu_cloud_cli/manifest.py +120 -0
- fufu_cloud_cli/remote_compute.py +93 -0
- fufu_cloud_cli/runtime/functions/Dockerfile +6 -0
- fufu_cloud_cli/runtime/ingress/Dockerfile +6 -0
- fufu_cloud_cli/runtime/ingress/proxy.py +49 -0
- fufu_cloud_cli/runtime_sdk.py +114 -0
- fufu_cloud_cli/serverless.py +214 -0
- fufu_cloud_cli-0.5.0.dist-info/METADATA +125 -0
- fufu_cloud_cli-0.5.0.dist-info/RECORD +23 -0
- fufu_cloud_cli-0.5.0.dist-info/WHEEL +5 -0
- fufu_cloud_cli-0.5.0.dist-info/entry_points.txt +5 -0
- fufu_cloud_cli-0.5.0.dist-info/licenses/LICENSE +6 -0
- fufu_cloud_cli-0.5.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
"""FUFU credential exchange and tenant-bound API transport (standard library only)."""
|
|
2
|
+
import ipaddress
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import re
|
|
6
|
+
import ssl
|
|
7
|
+
import signal
|
|
8
|
+
import threading
|
|
9
|
+
import time
|
|
10
|
+
from contextlib import contextmanager
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from urllib.error import HTTPError, URLError
|
|
13
|
+
from urllib.parse import quote, urlsplit, urlencode
|
|
14
|
+
from urllib.request import HTTPRedirectHandler, HTTPSHandler, ProxyHandler, Request, build_opener
|
|
15
|
+
|
|
16
|
+
from .errors import FufuError, require
|
|
17
|
+
|
|
18
|
+
PROVIDERS = {"aws", "gcp", "azure"}
|
|
19
|
+
MAX_RESPONSE = 2 * 1024 * 1024
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@contextmanager
|
|
23
|
+
def request_deadline(seconds=20):
|
|
24
|
+
# Linux CLI 的總期限包含 DNS、連線及持續傳送的小分段回應。
|
|
25
|
+
enabled = hasattr(signal, 'setitimer') and threading.current_thread() is threading.main_thread()
|
|
26
|
+
if not enabled:
|
|
27
|
+
yield
|
|
28
|
+
return
|
|
29
|
+
started = time.monotonic()
|
|
30
|
+
previous_handler = signal.getsignal(signal.SIGALRM)
|
|
31
|
+
previous_timer = signal.getitimer(signal.ITIMER_REAL)
|
|
32
|
+
def expired(*args):
|
|
33
|
+
raise FufuError("API_TIMEOUT", "FUFU API 請求超過總執行期限。")
|
|
34
|
+
signal.signal(signal.SIGALRM, expired)
|
|
35
|
+
signal.setitimer(signal.ITIMER_REAL, min(seconds, previous_timer[0]) if previous_timer[0] else seconds)
|
|
36
|
+
try:
|
|
37
|
+
yield
|
|
38
|
+
finally:
|
|
39
|
+
signal.setitimer(signal.ITIMER_REAL, 0)
|
|
40
|
+
signal.signal(signal.SIGALRM, previous_handler)
|
|
41
|
+
if previous_timer[0]:
|
|
42
|
+
signal.setitimer(signal.ITIMER_REAL, max(0.001, previous_timer[0] - (time.monotonic() - started)), previous_timer[1])
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class NoRedirect(HTTPRedirectHandler):
|
|
46
|
+
def redirect_request(self, req, fp, code, msg, headers, newurl):
|
|
47
|
+
return None
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def endpoint_url(value):
|
|
51
|
+
require(isinstance(value, str) and value == value.strip(), "INVALID_ENDPOINT", "請提供有效的 FUFU API endpoint。")
|
|
52
|
+
parsed = urlsplit(value)
|
|
53
|
+
loopback = parsed.hostname == "localhost"
|
|
54
|
+
try:
|
|
55
|
+
loopback = loopback or ipaddress.ip_address(parsed.hostname).is_loopback
|
|
56
|
+
except ValueError:
|
|
57
|
+
pass
|
|
58
|
+
require(parsed.hostname and not parsed.username and not parsed.password
|
|
59
|
+
and not parsed.query and not parsed.fragment and parsed.path in {"", "/"}
|
|
60
|
+
and not any(c.isspace() for c in value) and "\\" not in value
|
|
61
|
+
and (parsed.scheme == "https" or (parsed.scheme == "http" and loopback)),
|
|
62
|
+
"INVALID_ENDPOINT", "FUFU endpoint 必須使用 HTTPS;本機 loopback 可使用 HTTP。")
|
|
63
|
+
_ = parsed.port
|
|
64
|
+
return value.rstrip("/")
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def api_selected():
|
|
68
|
+
mode = os.environ.get("FUFU_BACKEND")
|
|
69
|
+
require(mode in {None, "api", "local-docker", "docker"}, "INVALID_BACKEND", "FUFU_BACKEND 必須是 api 或 local-docker。")
|
|
70
|
+
if mode in {"local-docker", "docker"}:
|
|
71
|
+
return False
|
|
72
|
+
return mode == "api" or any(os.environ.get(k) for k in (
|
|
73
|
+
"FUFU_ENDPOINT", "FUFU_CREDENTIAL_FILE", "FUFU_AWS_CREDENTIAL_FILE",
|
|
74
|
+
"FUFU_GCP_CREDENTIAL_FILE", "FUFU_AZURE_CREDENTIAL_FILE", 'FUFU_SESSION_FILE')) or os.environ.get("AWS_ACCESS_KEY_ID", "").startswith("FUFU")
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def load_credential(provider=None, filename=None):
|
|
78
|
+
require(provider is None or provider in PROVIDERS, "INVALID_PROVIDER", "未知的 provider。")
|
|
79
|
+
filename = filename or (os.environ.get(f"FUFU_{provider.upper()}_CREDENTIAL_FILE") if provider else None) or os.environ.get("FUFU_CREDENTIAL_FILE")
|
|
80
|
+
if not filename and provider == "gcp":
|
|
81
|
+
filename = os.environ.get("GOOGLE_APPLICATION_CREDENTIALS")
|
|
82
|
+
if filename:
|
|
83
|
+
p = Path(filename).expanduser()
|
|
84
|
+
require(p.is_file() and p.stat().st_size <= 16384, "INVALID_CREDENTIAL", "憑證檔案不存在或格式無效。")
|
|
85
|
+
data = json.loads(p.read_text(encoding="utf-8"))
|
|
86
|
+
elif provider == "aws" and os.environ.get("AWS_ACCESS_KEY_ID", "").startswith("FUFUAWS"):
|
|
87
|
+
data = {"fufu_version": 1, "fufu_provider": "aws", "fufu_endpoint": os.environ.get("FUFU_ENDPOINT"),
|
|
88
|
+
"fufu_tenant_id": os.environ.get("FUFU_TENANT_ID"), "aws_access_key_id": os.environ["AWS_ACCESS_KEY_ID"],
|
|
89
|
+
"aws_secret_access_key": os.environ.get("AWS_SECRET_ACCESS_KEY")}
|
|
90
|
+
else:
|
|
91
|
+
raise FufuError("CREDENTIAL_REQUIRED", "請透過 FUFU_CREDENTIAL_FILE 或各 provider 的 FUFU_*_CREDENTIAL_FILE 注入憑證檔案。")
|
|
92
|
+
require(isinstance(data, dict) and data.get("fufu_version") == 1
|
|
93
|
+
and data.get("fufu_provider") in PROVIDERS, "INVALID_CREDENTIAL", "需要 FUFU 專用憑證;不接受正式雲端金鑰。")
|
|
94
|
+
actual = data["fufu_provider"]
|
|
95
|
+
require(provider is None or actual == provider, "PROVIDER_MISMATCH", "憑證所屬 provider 不符。")
|
|
96
|
+
fields = {"aws": ("aws_access_key_id", "aws_secret_access_key"), "gcp": ("client_id", "client_secret"), "azure": ("clientId", "clientSecret")}
|
|
97
|
+
key, secret = (data.get(k) for k in fields[actual])
|
|
98
|
+
require(isinstance(key, str) and re.fullmatch(r"FUFU" + actual.upper() + r"[A-Za-z0-9_-]{12,100}", key)
|
|
99
|
+
and isinstance(secret, str) and 24 <= len(secret) <= 1024,
|
|
100
|
+
"INVALID_CREDENTIAL", "FUFU 憑證欄位無效。")
|
|
101
|
+
if actual == "gcp":
|
|
102
|
+
require(data.get("type") == "fufu_service_account", "INVALID_CREDENTIAL", "需要 FUFU service account JSON。")
|
|
103
|
+
tenant = data.get("fufu_tenant_id")
|
|
104
|
+
require(isinstance(tenant, str) and re.fullmatch(r"tenant-[a-z0-9]{8,32}", tenant), "INVALID_CREDENTIAL", "憑證缺少有效的 tenant。")
|
|
105
|
+
require(not os.environ.get("FUFU_TENANT_ID") or os.environ["FUFU_TENANT_ID"] == tenant,
|
|
106
|
+
"TENANT_MISMATCH", "環境指定的 tenant 與憑證不符。")
|
|
107
|
+
endpoint = endpoint_url(os.environ.get("FUFU_ENDPOINT") or data.get("fufu_endpoint"))
|
|
108
|
+
return {"provider": actual, "credential_id": key, "credential_secret": secret, "tenant_id": tenant}, endpoint
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
class ApiClient:
|
|
112
|
+
def __init__(self, provider=None, filename=None, read_only=False):
|
|
113
|
+
self.owns_token = True
|
|
114
|
+
self.opener = build_opener(ProxyHandler({}), NoRedirect(), HTTPSHandler(context=ssl.create_default_context()))
|
|
115
|
+
if os.environ.get('FUFU_SESSION_FILE') and not filename:
|
|
116
|
+
p = Path(os.environ['FUFU_SESSION_FILE'])
|
|
117
|
+
require(p.is_file() and p.stat().st_size <= 16384, 'INVALID_SESSION', 'Session 檔案無效。')
|
|
118
|
+
session = json.loads(p.read_text())
|
|
119
|
+
self.endpoint = endpoint_url(session.get('endpoint'))
|
|
120
|
+
self.provider, self.token = session.get('provider'), session.get('access_token')
|
|
121
|
+
require(self.provider in PROVIDERS and (provider is None or provider == self.provider)
|
|
122
|
+
and isinstance(self.token, str) and re.fullmatch(r'fufu_at_[A-Za-z0-9_-]{43}', self.token)
|
|
123
|
+
and session.get('expires_at', 0) > time.time(), 'INVALID_SESSION', 'Session 已過期或 provider 不符。')
|
|
124
|
+
self.owns_token = False
|
|
125
|
+
who = self.whoami()
|
|
126
|
+
require((who.get('role') or (session.get('session_kind') == 'terminal' and who.get('sessionKind') == 'terminal'))
|
|
127
|
+
and who.get('kind') == 'cli' and who.get('tenantId') == session.get('tenant_id') and who.get('provider') == self.provider,
|
|
128
|
+
'INVALID_SESSION', 'Session 的租戶與 role 無效。')
|
|
129
|
+
self.check_selectors(who)
|
|
130
|
+
return
|
|
131
|
+
credential, self.endpoint = load_credential(provider, filename)
|
|
132
|
+
self.provider = credential["provider"]
|
|
133
|
+
self.token = None
|
|
134
|
+
self.opener = build_opener(ProxyHandler({}), NoRedirect(), HTTPSHandler(context=ssl.create_default_context()))
|
|
135
|
+
scopes = [f"{self.provider}:read"] + ([] if read_only else [f"{self.provider}:write"])
|
|
136
|
+
issued = self._request("POST", "/api/auth/token", {**credential, "scopes": scopes,
|
|
137
|
+
**({'assume_role': os.environ['FUFU_ASSUME_ROLE']} if os.environ.get('FUFU_ASSUME_ROLE') else {})})
|
|
138
|
+
token = issued.get("access_token")
|
|
139
|
+
require(isinstance(token, str) and re.fullmatch(r"fufu_at_[A-Za-z0-9_-]{43}", token),
|
|
140
|
+
"INVALID_RESPONSE", "API 回傳的權杖格式無效。")
|
|
141
|
+
self.token = token
|
|
142
|
+
p = issued.get("principal", {})
|
|
143
|
+
if p.get("provider") != self.provider or p.get("tenantId") != credential["tenant_id"] or p.get("kind") != "cli":
|
|
144
|
+
self.close()
|
|
145
|
+
raise FufuError("TENANT_MISMATCH", "API 回覆的身分與憑證不符。")
|
|
146
|
+
if os.environ.get('FUFU_SELECTED_REGION') or os.environ.get('FUFU_SELECTED_PROJECT'):
|
|
147
|
+
try: self.check_selectors(self.whoami())
|
|
148
|
+
except Exception:
|
|
149
|
+
self.close(); raise
|
|
150
|
+
|
|
151
|
+
def check_selectors(self, who):
|
|
152
|
+
for env, field in [('FUFU_SELECTED_REGION', 'region'), ('FUFU_SELECTED_PROJECT', 'projectId')]:
|
|
153
|
+
require(not os.environ.get(env) or os.environ[env] == who.get(field), 'RESOURCE_SCOPE_MISMATCH', 'region/project 必須符合目前憑證的 FUFU scope。')
|
|
154
|
+
|
|
155
|
+
def _request(self, method, path, body=None, *, large=False):
|
|
156
|
+
require(path.startswith("/api/") and "\r" not in path and "\n" not in path,
|
|
157
|
+
"INVALID_REQUEST", "API 路徑無效。")
|
|
158
|
+
headers = {"Accept": "application/json", "User-Agent": "fufu-cloud-cli"}
|
|
159
|
+
if self.token:
|
|
160
|
+
headers["Authorization"] = f"Bearer {self.token}"
|
|
161
|
+
data = None if body is None else json.dumps(body).encode("utf-8")
|
|
162
|
+
if data is not None:
|
|
163
|
+
require(len(data) <= (12 * 1024**2 if large else 65536), "PAYLOAD_LIMIT", "API JSON payload 超過上限。")
|
|
164
|
+
headers["Content-Type"] = "application/json"
|
|
165
|
+
req = Request(self.endpoint + path, data=data, headers=headers, method=method)
|
|
166
|
+
try:
|
|
167
|
+
with request_deadline(), self.opener.open(req, timeout=15) as response:
|
|
168
|
+
limit = 9 * 1024**2 if large else MAX_RESPONSE
|
|
169
|
+
raw = response.read(limit + 1)
|
|
170
|
+
require(len(raw) <= limit, "RESPONSE_LIMIT", "API 回覆超過大小限制。")
|
|
171
|
+
return json.loads(raw)
|
|
172
|
+
except HTTPError as exc:
|
|
173
|
+
status = exc.code
|
|
174
|
+
exc.close()
|
|
175
|
+
codes = {401: "AUTHENTICATION_FAILED", 403: "ACCESS_DENIED", 404: "RESOURCE_NOT_FOUND", 409: "RESOURCE_CONFLICT", 429: "RATE_LIMITED"}
|
|
176
|
+
raise FufuError(codes.get(status, "API_REQUEST_FAILED"), f"FUFU API 拒絕請求(HTTP {status});未輸出憑證或原始回應。") from None
|
|
177
|
+
except (URLError, TimeoutError, ConnectionError) as exc:
|
|
178
|
+
raise FufuError("API_UNREACHABLE", "無法在時限內安全連線至 FUFU API。") from None
|
|
179
|
+
|
|
180
|
+
def request(self, method, path, body=None):
|
|
181
|
+
return self._request(method, f"/api/cloud/{self.provider}" + path, body, large=path.startswith(('/compute/', '/s3/', '/storage/', '/control/native')))
|
|
182
|
+
|
|
183
|
+
def whoami(self):
|
|
184
|
+
data = self._request("GET", "/api/auth/whoami")
|
|
185
|
+
return {k: v for k, v in data.items() if k != "credentialId"}
|
|
186
|
+
|
|
187
|
+
def close(self):
|
|
188
|
+
if self.token and self.owns_token:
|
|
189
|
+
try:
|
|
190
|
+
self._request("POST", "/api/auth/revoke", {})
|
|
191
|
+
except (FufuError, ValueError, OSError):
|
|
192
|
+
pass
|
|
193
|
+
finally:
|
|
194
|
+
self.token = None
|
|
195
|
+
|
|
196
|
+
def __enter__(self):
|
|
197
|
+
return self
|
|
198
|
+
|
|
199
|
+
def __exit__(self, *args):
|
|
200
|
+
self.close()
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def segment(value):
|
|
204
|
+
require(isinstance(value, str) and value not in {"", ".", ".."} and "/" not in value,
|
|
205
|
+
"INVALID_RESOURCE", "資源名稱無效。")
|
|
206
|
+
return quote(value, safe="")
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def storage_path(value):
|
|
210
|
+
parsed = urlsplit(value)
|
|
211
|
+
require(parsed.scheme == "gs" and parsed.netloc and not parsed.query and not parsed.fragment
|
|
212
|
+
and not parsed.username and not parsed.password, "INVALID_RESOURCE", "請使用 gs://bucket 或 gs://bucket/object。")
|
|
213
|
+
return segment(parsed.netloc), parsed.path.lstrip("/")
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def remote_command(provider, argv, Parser):
|
|
217
|
+
p = Parser(prog={"aws": "aws-fufu", "gcp": "gcloud-fufu", "azure": "az-fufu"}[provider],
|
|
218
|
+
description="FUFU API 憑證與租戶資源操作子集;無原生雲端 IAM/部署相容承諾。")
|
|
219
|
+
roots = p.add_subparsers(dest="service", required=True)
|
|
220
|
+
if provider == "aws":
|
|
221
|
+
roots.add_parser("sts").add_subparsers(dest="action", required=True).add_parser("get-caller-identity")
|
|
222
|
+
sub = roots.add_parser("dynamodb").add_subparsers(dest="action", required=True)
|
|
223
|
+
sub.add_parser("list-tables")
|
|
224
|
+
for action in ("create-table", "delete-table", "put-item", "scan"):
|
|
225
|
+
q = sub.add_parser(action)
|
|
226
|
+
q.add_argument("--table-name", required=True)
|
|
227
|
+
if action == "create-table":
|
|
228
|
+
q.add_argument("--partition-key", default="id", help="FUFU 字串 HASH key;預設 id")
|
|
229
|
+
if action == "put-item":
|
|
230
|
+
q.add_argument("--item", required=True, help="DynamoDB AttributeValue JSON,建議 file:// 路徑")
|
|
231
|
+
elif provider == "gcp":
|
|
232
|
+
roots.add_parser("auth").add_subparsers(dest="action", required=True).add_parser("list")
|
|
233
|
+
sub = roots.add_parser("storage").add_subparsers(dest="action", required=True)
|
|
234
|
+
buckets = sub.add_parser("buckets").add_subparsers(dest="bucket_action", required=True)
|
|
235
|
+
buckets.add_parser("list")
|
|
236
|
+
for action in ("create", "delete"):
|
|
237
|
+
buckets.add_parser(action).add_argument("url")
|
|
238
|
+
sub.add_parser("ls").add_argument("url")
|
|
239
|
+
sub.add_parser("cat").add_argument("url")
|
|
240
|
+
cp = sub.add_parser("cp")
|
|
241
|
+
cp.add_argument("source")
|
|
242
|
+
cp.add_argument("destination")
|
|
243
|
+
sub.add_parser("rm").add_argument("url")
|
|
244
|
+
else:
|
|
245
|
+
roots.add_parser("account").add_subparsers(dest="action", required=True).add_parser("show")
|
|
246
|
+
for group in ("group", "resource"):
|
|
247
|
+
sub = roots.add_parser(group).add_subparsers(dest="action", required=True)
|
|
248
|
+
sub.add_parser("list")
|
|
249
|
+
for action in ("show", "delete"):
|
|
250
|
+
sub.add_parser(action).add_argument("--name", required=True)
|
|
251
|
+
if group == "group":
|
|
252
|
+
q = sub.add_parser("create")
|
|
253
|
+
q.add_argument("--name", required=True)
|
|
254
|
+
q.add_argument("--location", default="eastasia")
|
|
255
|
+
a = p.parse_args(argv)
|
|
256
|
+
read_only = a.service in {"sts", "auth", "account"} or a.action in {"list", "list-tables", "scan", "show", "ls", "cat"} or getattr(a, "bucket_action", None) == "list" or (a.action == 'cp' and a.source.startswith('gs://'))
|
|
257
|
+
with ApiClient(provider, read_only=read_only) as api:
|
|
258
|
+
if a.service in {"sts", "auth", "account"}:
|
|
259
|
+
return api.whoami()
|
|
260
|
+
if provider == "aws":
|
|
261
|
+
if a.action == "list-tables":
|
|
262
|
+
return api.request("GET", "/dynamodb/tables")
|
|
263
|
+
path = "/dynamodb/tables/" + segment(a.table_name)
|
|
264
|
+
if a.action == "create-table":
|
|
265
|
+
return api.request("POST", "/dynamodb/tables", {"TableName": a.table_name, "PartitionKey": a.partition_key})
|
|
266
|
+
if a.action == "delete-table":
|
|
267
|
+
return api.request("DELETE", path)
|
|
268
|
+
if a.action == "scan":
|
|
269
|
+
return api.request("GET", path + "/items")
|
|
270
|
+
from .cli import payload
|
|
271
|
+
return api.request("POST", path + "/items", {"Item": json.loads(payload(a.item))})
|
|
272
|
+
if provider == "azure":
|
|
273
|
+
if a.action == "list":
|
|
274
|
+
result = api.request("GET", "/resources")
|
|
275
|
+
return {"value": [r for r in result["value"] if r.get("kind") == "resource-groups"]} if a.service == "group" else result
|
|
276
|
+
if a.action == "create":
|
|
277
|
+
return api.request("POST", "/resources", {"name": a.name, "location": a.location})
|
|
278
|
+
return api.request("GET" if a.action == "show" else "DELETE", "/resources/" + segment(a.name))
|
|
279
|
+
if a.action == "buckets" and a.bucket_action == "list":
|
|
280
|
+
return api.request("GET", "/storage/buckets")
|
|
281
|
+
if a.action == 'cp' and a.source.startswith('gs://'):
|
|
282
|
+
from .cloud_data import download_bytes
|
|
283
|
+
require(not a.destination.startswith('gs://'), 'UNSUPPORTED_ARGUMENT', 'cp 目前只支援本機與 Storage 之間傳輸。')
|
|
284
|
+
bucket, key = storage_path(a.source)
|
|
285
|
+
require(bool(key), 'INVALID_RESOURCE', '請指定 object 名稱。')
|
|
286
|
+
result = api.request('GET', '/storage/buckets/' + bucket + '/object?' + urlencode({'name': key, 'encoding': 'base64'}))
|
|
287
|
+
return download_bytes(a.destination, result['contentBase64'])
|
|
288
|
+
bucket, key = storage_path(a.destination if a.action == "cp" else a.url)
|
|
289
|
+
path = "/storage/buckets/" + bucket
|
|
290
|
+
if a.action == "buckets":
|
|
291
|
+
require(not key, "INVALID_RESOURCE", "Bucket 操作不接受 object 路徑。")
|
|
292
|
+
return api.request("POST", "/storage/buckets", {"name": bucket}) if a.bucket_action == "create" else api.request("DELETE", path)
|
|
293
|
+
if a.action == "ls":
|
|
294
|
+
require(not key, "UNSUPPORTED_ARGUMENT", "ls 目前只支援 bucket 根目錄。")
|
|
295
|
+
return api.request("GET", path + "/objects")
|
|
296
|
+
require(bool(key), "INVALID_RESOURCE", "請指定 object 名稱。")
|
|
297
|
+
if a.action == "cat":
|
|
298
|
+
return api.request("GET", path + "/object?" + urlencode({"name": key}))
|
|
299
|
+
if a.action == "rm":
|
|
300
|
+
return api.request("DELETE", path + "/object?" + urlencode({"name": key}))
|
|
301
|
+
from .cloud_data import upload_bytes
|
|
302
|
+
return api.request("POST", path + "/objects", {"name": key, "contentBase64": upload_bytes(a.source)})
|