fufu-cloud-cli 0.9.3__tar.gz → 0.9.4__tar.gz
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-0.9.3/src/fufu_cloud_cli.egg-info → fufu_cloud_cli-0.9.4}/PKG-INFO +7 -4
- {fufu_cloud_cli-0.9.3 → fufu_cloud_cli-0.9.4}/README.md +6 -3
- {fufu_cloud_cli-0.9.3 → fufu_cloud_cli-0.9.4}/pyproject.toml +1 -1
- {fufu_cloud_cli-0.9.3 → fufu_cloud_cli-0.9.4}/src/fufu_cloud_cli/__init__.py +1 -1
- {fufu_cloud_cli-0.9.3 → fufu_cloud_cli-0.9.4}/src/fufu_cloud_cli/api_backend.py +6 -2
- {fufu_cloud_cli-0.9.3 → fufu_cloud_cli-0.9.4}/src/fufu_cloud_cli/capabilities.json +11 -3
- {fufu_cloud_cli-0.9.3 → fufu_cloud_cli-0.9.4}/src/fufu_cloud_cli/cli.py +1 -1
- fufu_cloud_cli-0.9.4/src/fufu_cloud_cli/gcs.py +137 -0
- {fufu_cloud_cli-0.9.3 → fufu_cloud_cli-0.9.4}/src/fufu_cloud_cli/object_transfer.py +4 -3
- {fufu_cloud_cli-0.9.3 → fufu_cloud_cli-0.9.4/src/fufu_cloud_cli.egg-info}/PKG-INFO +7 -4
- {fufu_cloud_cli-0.9.3 → fufu_cloud_cli-0.9.4}/src/fufu_cloud_cli.egg-info/SOURCES.txt +1 -0
- {fufu_cloud_cli-0.9.3 → fufu_cloud_cli-0.9.4}/LICENSE +0 -0
- {fufu_cloud_cli-0.9.3 → fufu_cloud_cli-0.9.4}/MANIFEST.in +0 -0
- {fufu_cloud_cli-0.9.3 → fufu_cloud_cli-0.9.4}/setup.cfg +0 -0
- {fufu_cloud_cli-0.9.3 → fufu_cloud_cli-0.9.4}/src/fufu_cloud_cli/__main__.py +0 -0
- {fufu_cloud_cli-0.9.3 → fufu_cloud_cli-0.9.4}/src/fufu_cloud_cli/capacity.py +0 -0
- {fufu_cloud_cli-0.9.3 → fufu_cloud_cli-0.9.4}/src/fufu_cloud_cli/cloud_data.py +0 -0
- {fufu_cloud_cli-0.9.3 → fufu_cloud_cli-0.9.4}/src/fufu_cloud_cli/cloudflare.py +0 -0
- {fufu_cloud_cli-0.9.3 → fufu_cloud_cli-0.9.4}/src/fufu_cloud_cli/control.py +0 -0
- {fufu_cloud_cli-0.9.3 → fufu_cloud_cli-0.9.4}/src/fufu_cloud_cli/docker_backend.py +0 -0
- {fufu_cloud_cli-0.9.3 → fufu_cloud_cli-0.9.4}/src/fufu_cloud_cli/errors.py +0 -0
- {fufu_cloud_cli-0.9.3 → fufu_cloud_cli-0.9.4}/src/fufu_cloud_cli/gcloud_environment.py +0 -0
- {fufu_cloud_cli-0.9.3 → fufu_cloud_cli-0.9.4}/src/fufu_cloud_cli/gcloud_output.py +0 -0
- {fufu_cloud_cli-0.9.3 → fufu_cloud_cli-0.9.4}/src/fufu_cloud_cli/http_runtime.py +0 -0
- {fufu_cloud_cli-0.9.3 → fufu_cloud_cli-0.9.4}/src/fufu_cloud_cli/iac_state.py +0 -0
- {fufu_cloud_cli-0.9.3 → fufu_cloud_cli-0.9.4}/src/fufu_cloud_cli/lambda_adapter.py +0 -0
- {fufu_cloud_cli-0.9.3 → fufu_cloud_cli-0.9.4}/src/fufu_cloud_cli/limits.py +0 -0
- {fufu_cloud_cli-0.9.3 → fufu_cloud_cli-0.9.4}/src/fufu_cloud_cli/manifest.py +0 -0
- {fufu_cloud_cli-0.9.3 → fufu_cloud_cli-0.9.4}/src/fufu_cloud_cli/public_access.py +0 -0
- {fufu_cloud_cli-0.9.3 → fufu_cloud_cli-0.9.4}/src/fufu_cloud_cli/pubsub.py +0 -0
- {fufu_cloud_cli-0.9.3 → fufu_cloud_cli-0.9.4}/src/fufu_cloud_cli/pulumi-endpoints.json +0 -0
- {fufu_cloud_cli-0.9.3 → fufu_cloud_cli-0.9.4}/src/fufu_cloud_cli/pulumi_bridge.py +0 -0
- {fufu_cloud_cli-0.9.3 → fufu_cloud_cli-0.9.4}/src/fufu_cloud_cli/remote_compute.py +0 -0
- {fufu_cloud_cli-0.9.3 → fufu_cloud_cli-0.9.4}/src/fufu_cloud_cli/runtime/functions/Dockerfile +0 -0
- {fufu_cloud_cli-0.9.3 → fufu_cloud_cli-0.9.4}/src/fufu_cloud_cli/runtime/ingress/Dockerfile +0 -0
- {fufu_cloud_cli-0.9.3 → fufu_cloud_cli-0.9.4}/src/fufu_cloud_cli/runtime/ingress/proxy.py +0 -0
- {fufu_cloud_cli-0.9.3 → fufu_cloud_cli-0.9.4}/src/fufu_cloud_cli/runtime_sdk.py +0 -0
- {fufu_cloud_cli-0.9.3 → fufu_cloud_cli-0.9.4}/src/fufu_cloud_cli/secrets.py +0 -0
- {fufu_cloud_cli-0.9.3 → fufu_cloud_cli-0.9.4}/src/fufu_cloud_cli/serverless.py +0 -0
- {fufu_cloud_cli-0.9.3 → fufu_cloud_cli-0.9.4}/src/fufu_cloud_cli/terraform_bridge/__init__.py +0 -0
- {fufu_cloud_cli-0.9.3 → fufu_cloud_cli-0.9.4}/src/fufu_cloud_cli/terraform_bridge/aws.py +0 -0
- {fufu_cloud_cli-0.9.3 → fufu_cloud_cli-0.9.4}/src/fufu_cloud_cli/terraform_bridge/aws_data.py +0 -0
- {fufu_cloud_cli-0.9.3 → fufu_cloud_cli-0.9.4}/src/fufu_cloud_cli/terraform_bridge/common.py +0 -0
- {fufu_cloud_cli-0.9.3 → fufu_cloud_cli-0.9.4}/src/fufu_cloud_cli/terraform_bridge/google.py +0 -0
- {fufu_cloud_cli-0.9.3 → fufu_cloud_cli-0.9.4}/src/fufu_cloud_cli/terraform_bridge/google_pubsub.py +0 -0
- {fufu_cloud_cli-0.9.3 → fufu_cloud_cli-0.9.4}/src/fufu_cloud_cli/terraform_bridge/remote_state.py +0 -0
- {fufu_cloud_cli-0.9.3 → fufu_cloud_cli-0.9.4}/src/fufu_cloud_cli/terraform_bridge/runner.py +0 -0
- {fufu_cloud_cli-0.9.3 → fufu_cloud_cli-0.9.4}/src/fufu_cloud_cli.egg-info/dependency_links.txt +0 -0
- {fufu_cloud_cli-0.9.3 → fufu_cloud_cli-0.9.4}/src/fufu_cloud_cli.egg-info/entry_points.txt +0 -0
- {fufu_cloud_cli-0.9.3 → fufu_cloud_cli-0.9.4}/src/fufu_cloud_cli.egg-info/requires.txt +0 -0
- {fufu_cloud_cli-0.9.3 → fufu_cloud_cli-0.9.4}/src/fufu_cloud_cli.egg-info/top_level.txt +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: fufu-cloud-cli
|
|
3
|
-
Version: 0.9.
|
|
3
|
+
Version: 0.9.4
|
|
4
4
|
Summary: FUFU Cloud CLI for tenant-scoped mock cloud development and deployment
|
|
5
5
|
License-Expression: LicenseRef-Proprietary
|
|
6
6
|
Requires-Python: >=3.10
|
|
@@ -22,7 +22,9 @@ Dynamic: license-file
|
|
|
22
22
|
`gcloud-fufu`、`az-fufu`、`wrangler-fufu`。這是 AWS、Google Cloud、Azure 與 Cloudflare 的**明確子集**,
|
|
23
23
|
只供 FUFU mock 環境使用;不會把部署轉送到正式雲端。
|
|
24
24
|
|
|
25
|
-
0.9.
|
|
25
|
+
0.9.4 新增 GCS generation/metageneration 條件、`storage objects describe/update/list`、8 KiB 自訂 metadata 與二進位 `cat`。競爭寫入、過期條件、CLI metadata 和官方 gcloud 輸出欄位已對照驗收;不相符條件回傳 `PRECONDITION_FAILED`/HTTP 412。新功能需要同步更新 FUFU Console、GCS 儲存映像和 Cloud Shell。儲存升級前須停寫並備份完整 volume;新 metadata 格式可讀舊資料,回滾須還原升級前備份。
|
|
26
|
+
|
|
27
|
+
0.9.3 的常用輸出投影、清單篩選/排序/limit、Cloud Run 設定更新、Service Account 啟停及 Pub/Sub attributes/ACK deadline 持續保留。僅明列子集經過官方 gcloud、小用量真實 GCP 與隔離 Docker 驗收;不表示完整 Google IAM、SDK/ADC 或自動擴縮相容。
|
|
26
28
|
|
|
27
29
|
0.9.1 統一所有 CLI API 請求的 `User-Agent: fufu-cloud-cli`,涵蓋物件傳輸、
|
|
28
30
|
Cloudflare 靜態檔案部署、IaC 遠端 state 與 Cloud Shell session,避免沿用
|
|
@@ -34,7 +36,7 @@ Python 預設識別而遇到 Cloudflare HTTP 403/1010。識別標頭不取代
|
|
|
34
36
|
## Cloudflare 前端部署與 R2
|
|
35
37
|
|
|
36
38
|
```bash
|
|
37
|
-
pip install 'fufu-cloud-cli==0.9.
|
|
39
|
+
pip install 'fufu-cloud-cli==0.9.4'
|
|
38
40
|
export FUFU_CLOUDFLARE_CREDENTIAL_FILE=/run/secrets/fufu-cloudflare.json
|
|
39
41
|
wrangler-fufu capabilities
|
|
40
42
|
wrangler-fufu pages project create frontend --production-branch main
|
|
@@ -205,7 +207,8 @@ CLI 無法判斷任意原始碼或 ZIP 內的資料是否可以分享。
|
|
|
205
207
|
| Cloud Run | 已核准 HTTP 容器或 API 自動建置 Python ASGI 來源;支援配置後的 HTTPS 入口、Invoker policy;無 autoscaling、traffic splitting、Jobs |
|
|
206
208
|
| Cloud Run functions / Functions Gen 1 | Python 3.12 HTTP 函式;Gen 1 為相同 HTTP runtime 的模擬子集,無背景事件 |
|
|
207
209
|
| AWS Lambda | Python 3.10 / 3.12 ZIP、同步 invoke、程式碼更新、BUFFERED Function URL;ZIP 50 MiB/解壓 250 MiB,同步 payload 6 MiB |
|
|
208
|
-
| GCS
|
|
210
|
+
| GCS | bucket/object 子集、分段上傳/串流下載/binary cat、generation/metageneration 條件、metadata describe/update、前綴分頁;8 KiB custom metadata;無 versioning、signed URL、bucket IAM、一般 SDK 或 GCS IaC 資源 |
|
|
211
|
+
| S3 | bucket 與 object 子集;分段上傳/串流下載,受主機磁碟限制;已驗證超過 1 GiB,無 versioning / signed URL |
|
|
209
212
|
| DynamoDB / SQS / SSM | 明列資料操作;無完整交易、Streams、FIFO 或 KMS / SecureString |
|
|
210
213
|
| AWS IAM / GCP IAM | 租戶內 role / service account / policy 子集;default deny、限定 session;無原生雲端全量 IAM 語義 |
|
|
211
214
|
| API Gateway / Serverless profile | 簡化 HTTP Lambda proxy 和明確來源清單;不執行 plugins / CloudFormation |
|
|
@@ -4,7 +4,9 @@
|
|
|
4
4
|
`gcloud-fufu`、`az-fufu`、`wrangler-fufu`。這是 AWS、Google Cloud、Azure 與 Cloudflare 的**明確子集**,
|
|
5
5
|
只供 FUFU mock 環境使用;不會把部署轉送到正式雲端。
|
|
6
6
|
|
|
7
|
-
0.9.
|
|
7
|
+
0.9.4 新增 GCS generation/metageneration 條件、`storage objects describe/update/list`、8 KiB 自訂 metadata 與二進位 `cat`。競爭寫入、過期條件、CLI metadata 和官方 gcloud 輸出欄位已對照驗收;不相符條件回傳 `PRECONDITION_FAILED`/HTTP 412。新功能需要同步更新 FUFU Console、GCS 儲存映像和 Cloud Shell。儲存升級前須停寫並備份完整 volume;新 metadata 格式可讀舊資料,回滾須還原升級前備份。
|
|
8
|
+
|
|
9
|
+
0.9.3 的常用輸出投影、清單篩選/排序/limit、Cloud Run 設定更新、Service Account 啟停及 Pub/Sub attributes/ACK deadline 持續保留。僅明列子集經過官方 gcloud、小用量真實 GCP 與隔離 Docker 驗收;不表示完整 Google IAM、SDK/ADC 或自動擴縮相容。
|
|
8
10
|
|
|
9
11
|
0.9.1 統一所有 CLI API 請求的 `User-Agent: fufu-cloud-cli`,涵蓋物件傳輸、
|
|
10
12
|
Cloudflare 靜態檔案部署、IaC 遠端 state 與 Cloud Shell session,避免沿用
|
|
@@ -16,7 +18,7 @@ Python 預設識別而遇到 Cloudflare HTTP 403/1010。識別標頭不取代
|
|
|
16
18
|
## Cloudflare 前端部署與 R2
|
|
17
19
|
|
|
18
20
|
```bash
|
|
19
|
-
pip install 'fufu-cloud-cli==0.9.
|
|
21
|
+
pip install 'fufu-cloud-cli==0.9.4'
|
|
20
22
|
export FUFU_CLOUDFLARE_CREDENTIAL_FILE=/run/secrets/fufu-cloudflare.json
|
|
21
23
|
wrangler-fufu capabilities
|
|
22
24
|
wrangler-fufu pages project create frontend --production-branch main
|
|
@@ -187,7 +189,8 @@ CLI 無法判斷任意原始碼或 ZIP 內的資料是否可以分享。
|
|
|
187
189
|
| Cloud Run | 已核准 HTTP 容器或 API 自動建置 Python ASGI 來源;支援配置後的 HTTPS 入口、Invoker policy;無 autoscaling、traffic splitting、Jobs |
|
|
188
190
|
| Cloud Run functions / Functions Gen 1 | Python 3.12 HTTP 函式;Gen 1 為相同 HTTP runtime 的模擬子集,無背景事件 |
|
|
189
191
|
| AWS Lambda | Python 3.10 / 3.12 ZIP、同步 invoke、程式碼更新、BUFFERED Function URL;ZIP 50 MiB/解壓 250 MiB,同步 payload 6 MiB |
|
|
190
|
-
| GCS
|
|
192
|
+
| GCS | bucket/object 子集、分段上傳/串流下載/binary cat、generation/metageneration 條件、metadata describe/update、前綴分頁;8 KiB custom metadata;無 versioning、signed URL、bucket IAM、一般 SDK 或 GCS IaC 資源 |
|
|
193
|
+
| S3 | bucket 與 object 子集;分段上傳/串流下載,受主機磁碟限制;已驗證超過 1 GiB,無 versioning / signed URL |
|
|
191
194
|
| DynamoDB / SQS / SSM | 明列資料操作;無完整交易、Streams、FIFO 或 KMS / SecureString |
|
|
192
195
|
| AWS IAM / GCP IAM | 租戶內 role / service account / policy 子集;default deny、限定 session;無原生雲端全量 IAM 語義 |
|
|
193
196
|
| API Gateway / Serverless profile | 簡化 HTTP Lambda proxy 和明確來源清單;不執行 plugins / CloudFormation |
|
|
@@ -185,7 +185,7 @@ class ApiClient:
|
|
|
185
185
|
except HTTPError as exc:
|
|
186
186
|
status = exc.code
|
|
187
187
|
exc.close()
|
|
188
|
-
codes = {401: "AUTHENTICATION_FAILED", 403: "ACCESS_DENIED", 404: "RESOURCE_NOT_FOUND", 409: "RESOURCE_CONFLICT", 410: "UPLOAD_EXPIRED", 413: "PAYLOAD_LIMIT", 429: "RATE_LIMITED", 507: "STORAGE_CAPACITY"}
|
|
188
|
+
codes = {401: "AUTHENTICATION_FAILED", 403: "ACCESS_DENIED", 404: "RESOURCE_NOT_FOUND", 409: "RESOURCE_CONFLICT", 410: "UPLOAD_EXPIRED", 412: "PRECONDITION_FAILED", 413: "PAYLOAD_LIMIT", 429: "RATE_LIMITED", 507: "STORAGE_CAPACITY"}
|
|
189
189
|
if status == 507: raise FufuError('STORAGE_CAPACITY', 'FUFU 主機磁碟容量不足;這是目前可用空間的限制,並非雲端物件大小上限。') from None
|
|
190
190
|
raise FufuError(codes.get(status, "API_REQUEST_FAILED"), f"FUFU API 拒絕請求(HTTP {status});未輸出憑證或原始回應。") from None
|
|
191
191
|
except (URLError, TimeoutError, ConnectionError) as exc:
|
|
@@ -211,7 +211,8 @@ class ApiClient:
|
|
|
211
211
|
return json.loads(response.read(65537))
|
|
212
212
|
except HTTPError as error:
|
|
213
213
|
status = error.code; error.close()
|
|
214
|
-
|
|
214
|
+
code = 'PRECONDITION_FAILED' if self.provider == 'gcp' and status == 412 else 'OBJECT_TRANSFER_FAILED'
|
|
215
|
+
raise FufuError(code, f'物件傳輸未完成(HTTP {status});未覆寫下載目的地。') from None
|
|
215
216
|
|
|
216
217
|
def close(self):
|
|
217
218
|
if self.token and self.owns_token:
|
|
@@ -243,6 +244,9 @@ def storage_path(value):
|
|
|
243
244
|
|
|
244
245
|
|
|
245
246
|
def remote_command(provider, argv, Parser):
|
|
247
|
+
if provider == 'gcp' and argv[:1] == ['storage']:
|
|
248
|
+
from .gcs import command
|
|
249
|
+
return command(argv[1:], Parser)
|
|
246
250
|
p = Parser(prog={"aws": "aws-fufu", "gcp": "gcloud-fufu", "azure": "az-fufu"}[provider],
|
|
247
251
|
description="FUFU API 憑證與租戶資源操作子集;無原生雲端 IAM/部署相容承諾。")
|
|
248
252
|
roots = p.add_subparsers(dest="service", required=True)
|
|
@@ -188,16 +188,24 @@
|
|
|
188
188
|
"operations": [
|
|
189
189
|
"bucket-create-list-delete",
|
|
190
190
|
"binary-object-upload-download-list-delete",
|
|
191
|
+
"generation-match-preconditions",
|
|
192
|
+
"metageneration-match-preconditions",
|
|
193
|
+
"metadata-describe-update-merge-delete",
|
|
194
|
+
"paginated-object-list",
|
|
195
|
+
"binary-cat",
|
|
191
196
|
"UTF8-preview"
|
|
192
197
|
],
|
|
193
198
|
"limitations": [
|
|
194
199
|
"object-5TiB-policy-subject-to-host-disk-capacity",
|
|
195
200
|
"chunked-upload-streaming-download",
|
|
201
|
+
"custom-metadata-8KiB",
|
|
196
202
|
"filesystem-object-name-subset",
|
|
197
|
-
"preview-32KiB",
|
|
198
|
-
"
|
|
203
|
+
"UI-preview-32KiB-CLI-cat-streams",
|
|
204
|
+
"requires-generation-metadata-storage-backend",
|
|
205
|
+
"single-writer-per-volume",
|
|
199
206
|
"no-signed-URLs",
|
|
200
|
-
"no-versioning"
|
|
207
|
+
"no-versioning-retention-ACL-KMS",
|
|
208
|
+
"no-GCS-Terraform-or-Pulumi-resource-adapter"
|
|
201
209
|
]
|
|
202
210
|
},
|
|
203
211
|
"azure-catalog-api": {
|
|
@@ -477,7 +477,7 @@ def entry(provider, argv=None):
|
|
|
477
477
|
if output_filter or output_sort or output_limit is not None:
|
|
478
478
|
list_commands = [('run', 'services', 'list'), ('functions', 'list'), ('iam', 'service-accounts', 'list'),
|
|
479
479
|
('pubsub', 'topics', 'list'), ('pubsub', 'subscriptions', 'list'), ('secrets', 'list'),
|
|
480
|
-
('secrets', 'versions', 'list'), ('storage', 'buckets', 'list')]
|
|
480
|
+
('secrets', 'versions', 'list'), ('storage', 'buckets', 'list'), ('storage', 'objects', 'list')]
|
|
481
481
|
require(any(tuple(argv[:len(command)]) == command for command in list_commands), 'UNSUPPORTED_ARGUMENT', '清單選取旗標只適用於資源 list;未執行操作。')
|
|
482
482
|
if output_format and argv[:2] == ['functions', 'call']:
|
|
483
483
|
require(output_format == 'json', 'UNSUPPORTED_FORMAT', 'Functions call 目前只支援 JSON 回應。')
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
"""GCS CLI subset with atomic generation and metadata preconditions."""
|
|
2
|
+
import mimetypes
|
|
3
|
+
import re
|
|
4
|
+
import sys
|
|
5
|
+
import time
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from urllib.parse import quote, urlencode, urlsplit
|
|
8
|
+
|
|
9
|
+
from .api_backend import ApiClient
|
|
10
|
+
from .errors import require
|
|
11
|
+
from .gcloud_environment import dictionary
|
|
12
|
+
from .object_transfer import upload_file, download_file
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def location(value):
|
|
16
|
+
parsed = urlsplit(value)
|
|
17
|
+
require(parsed.scheme == 'gs' and parsed.netloc and not parsed.query and not parsed.username and not parsed.password,
|
|
18
|
+
'INVALID_RESOURCE', '請指定 gs://bucket/object。')
|
|
19
|
+
generation = parsed.fragment or None
|
|
20
|
+
if generation:
|
|
21
|
+
require(re.fullmatch(r'[1-9][0-9]{0,18}', generation) and int(generation) <= 2**63-1,
|
|
22
|
+
'INVALID_RESOURCE', '物件版本必須是正整數 generation。')
|
|
23
|
+
return parsed.netloc, parsed.path.removeprefix('/'), generation
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def conditions(args):
|
|
27
|
+
result = {}
|
|
28
|
+
for flag, field in [('if_generation_match', 'ifGenerationMatch'), ('if_metageneration_match', 'ifMetagenerationMatch')]:
|
|
29
|
+
raw = getattr(args, flag, None)
|
|
30
|
+
if raw is not None:
|
|
31
|
+
require(re.fullmatch(r'0|[1-9][0-9]{0,18}', raw) and int(raw) <= 2**63-1,
|
|
32
|
+
'INVALID_INPUT', 'Generation 條件必須是非負的 64-bit 整數。')
|
|
33
|
+
result[field] = raw
|
|
34
|
+
return result
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def display(obj):
|
|
38
|
+
mapping = {'bucket': 'bucket', 'name': 'name', 'generation': 'generation', 'metageneration': 'metageneration', 'size': 'size',
|
|
39
|
+
'contentType': 'content_type', 'cacheControl': 'cache_control', 'contentDisposition': 'content_disposition',
|
|
40
|
+
'contentLanguage': 'content_language', 'contentEncoding': 'content_encoding', 'storageClass': 'storage_class',
|
|
41
|
+
'timeCreated': 'creation_time', 'timeStorageClassUpdated': 'storage_class_update_time', 'updated': 'update_time',
|
|
42
|
+
'crc32c': 'crc32c_hash', 'md5Hash': 'md5_hash', 'etag': 'etag', 'metadata': 'custom_fields'}
|
|
43
|
+
result = {target: obj[source] for source, target in mapping.items() if source in obj}
|
|
44
|
+
for field in ['size', 'metageneration']:
|
|
45
|
+
if field in result: result[field] = int(result[field])
|
|
46
|
+
result['storage_url'] = 'gs://' + obj['bucket'] + '/' + obj['name'] + '#' + obj['generation']
|
|
47
|
+
return result
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def pages(api, path, params=None):
|
|
51
|
+
params = dict(params or {}); rows, seen, deadline = [], set(), time.monotonic()+300
|
|
52
|
+
while True:
|
|
53
|
+
require(time.monotonic() < deadline, 'LIST_TIMEOUT', '清單超過五分鐘;請縮小物件前綴範圍。')
|
|
54
|
+
page = api.request('GET', path + '?' + urlencode(params))
|
|
55
|
+
rows.extend(page.get('items', [])); token = page.get('nextPageToken')
|
|
56
|
+
if not token: return rows
|
|
57
|
+
require(isinstance(token, str) and token not in seen, 'INVALID_PAGINATION', 'Storage 回傳重複分頁;未提供不完整清單。')
|
|
58
|
+
seen.add(token); params['pageToken'] = token
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def command(argv, Parser):
|
|
62
|
+
parser = Parser(prog='gcloud-fufu storage', description='GCS 物件與 generation 條件子集;不支援歷史版本保留、ACL、保留政策或 KMS。')
|
|
63
|
+
root = parser.add_subparsers(dest='action', required=True)
|
|
64
|
+
buckets = root.add_parser('buckets').add_subparsers(dest='verb', required=True)
|
|
65
|
+
buckets.add_parser('list')
|
|
66
|
+
for verb in ['create', 'delete']: buckets.add_parser(verb).add_argument('url')
|
|
67
|
+
objects = root.add_parser('objects').add_subparsers(dest='verb', required=True)
|
|
68
|
+
for verb in ['list', 'describe', 'update']:
|
|
69
|
+
p = objects.add_parser(verb); p.add_argument('url')
|
|
70
|
+
if verb in ['list', 'describe']: p.add_argument('--raw', action='store_true')
|
|
71
|
+
else:
|
|
72
|
+
for flag in ['content-type', 'cache-control', 'content-disposition', 'content-language', 'content-encoding',
|
|
73
|
+
'update-custom-metadata', 'remove-custom-metadata', 'if-generation-match', 'if-metageneration-match']:
|
|
74
|
+
p.add_argument('--'+flag)
|
|
75
|
+
p.add_argument('--clear-custom-metadata', action='store_true')
|
|
76
|
+
root.add_parser('ls').add_argument('url')
|
|
77
|
+
for action in ['cp', 'rm', 'cat']:
|
|
78
|
+
p = root.add_parser(action)
|
|
79
|
+
if action == 'cp':
|
|
80
|
+
p.add_argument('source'); p.add_argument('destination'); p.add_argument('--content-type'); p.add_argument('--custom-metadata')
|
|
81
|
+
else: p.add_argument('url')
|
|
82
|
+
for flag in ['if-generation-match', 'if-metageneration-match']: p.add_argument('--'+flag)
|
|
83
|
+
args = parser.parse_args(argv); preconditions = conditions(args)
|
|
84
|
+
downloading = args.action == 'cp' and args.source.startswith('gs://')
|
|
85
|
+
readonly = args.action in ['ls', 'cat'] or downloading or getattr(args, 'verb', None) in ['list', 'describe']
|
|
86
|
+
with ApiClient('gcp', read_only=readonly) as api:
|
|
87
|
+
if args.action == 'buckets' and args.verb == 'list': return pages(api, '/storage/buckets')
|
|
88
|
+
url = args.source if downloading else args.destination if args.action == 'cp' else args.url
|
|
89
|
+
bucket, key, generation = location(url); path = '/storage/buckets/' + quote(bucket, safe='')
|
|
90
|
+
if generation:
|
|
91
|
+
require(args.action != 'cp' or downloading, 'INVALID_RESOURCE', '上傳目的地不能指定歷史 generation。')
|
|
92
|
+
preconditions['generation'] = generation
|
|
93
|
+
if args.action == 'buckets':
|
|
94
|
+
require(not key and not generation, 'INVALID_RESOURCE', 'Bucket 操作不接受物件路徑或版本。')
|
|
95
|
+
return api.request('POST', '/storage/buckets', {'name': bucket}) if args.verb == 'create' else api.request('DELETE', path)
|
|
96
|
+
if args.action == 'ls' or args.action == 'objects' and args.verb == 'list':
|
|
97
|
+
require(not generation, 'UNSUPPORTED_ARGUMENT', '清單不接受單一 generation。')
|
|
98
|
+
prefix = key[:-2] if key.endswith('**') else key
|
|
99
|
+
require(not any(c in prefix for c in '*?[]'), 'UNSUPPORTED_ARGUMENT', '目前清單支援 bucket、名稱前綴及結尾 **。')
|
|
100
|
+
rows = pages(api, path+'/objects', {'prefix': prefix})
|
|
101
|
+
return rows if getattr(args, 'raw', False) else [display(row) for row in rows]
|
|
102
|
+
if args.action == 'cp' and not downloading and (not key or key.endswith('/')): key += Path(args.source).name
|
|
103
|
+
require(bool(key), 'INVALID_RESOURCE', '請指定 object 名稱。')
|
|
104
|
+
target = path+'/metadata?'+urlencode({'name': key, **preconditions})
|
|
105
|
+
if args.action == 'objects':
|
|
106
|
+
if args.verb == 'describe':
|
|
107
|
+
obj = api.request('GET', target)
|
|
108
|
+
return obj if args.raw else display(obj)
|
|
109
|
+
body = {}
|
|
110
|
+
for flag, field in [('content_type', 'contentType'), ('cache_control', 'cacheControl'), ('content_disposition', 'contentDisposition'),
|
|
111
|
+
('content_language', 'contentLanguage'), ('content_encoding', 'contentEncoding')]:
|
|
112
|
+
if getattr(args, flag) is not None: body[field] = getattr(args, flag) or None
|
|
113
|
+
require(not args.clear_custom_metadata or not (args.update_custom_metadata or args.remove_custom_metadata),
|
|
114
|
+
'CONFLICTING_ARGUMENTS', '清空自訂 metadata 不可與更新/移除並用。')
|
|
115
|
+
if args.clear_custom_metadata: body['metadata'] = None
|
|
116
|
+
elif args.update_custom_metadata is not None or args.remove_custom_metadata is not None:
|
|
117
|
+
values = {name: None for name in (args.remove_custom_metadata or '').split(',') if name}
|
|
118
|
+
values.update(dictionary(args.update_custom_metadata or '')); body['metadata'] = values
|
|
119
|
+
require(bool(body), 'INVALID_INPUT', '請指定至少一項 metadata 更新。')
|
|
120
|
+
return display(api.request('PATCH', target, body))
|
|
121
|
+
if args.action == 'rm': return api.request('DELETE', path+'/object?'+urlencode({'name': key, **preconditions}))
|
|
122
|
+
if args.action == 'cat':
|
|
123
|
+
deadline = time.monotonic()+3600
|
|
124
|
+
with api.binary('GET', '/objects/download?'+urlencode({'bucket': bucket, 'key': key, **preconditions})) as response:
|
|
125
|
+
while True:
|
|
126
|
+
require(time.monotonic() < deadline, 'TRANSFER_TIMEOUT', 'Storage cat 超過一小時。')
|
|
127
|
+
data = response.read(65536)
|
|
128
|
+
if not data: break
|
|
129
|
+
sys.stdout.buffer.write(data)
|
|
130
|
+
return 0
|
|
131
|
+
if downloading:
|
|
132
|
+
require(not args.destination.startswith('gs://') and args.content_type is None and args.custom_metadata is None,
|
|
133
|
+
'UNSUPPORTED_ARGUMENT', '下載不接受上傳 metadata;目前支援本機與 GCS 之間傳輸。')
|
|
134
|
+
return download_file(api, args.destination, bucket, key, conditions=preconditions)
|
|
135
|
+
return upload_file(api, args.source, bucket, key, conditions=preconditions,
|
|
136
|
+
content_type=args.content_type or mimetypes.guess_type(args.source)[0] or 'application/octet-stream',
|
|
137
|
+
metadata=dictionary(args.custom_metadata) if args.custom_metadata is not None else None)
|
|
@@ -19,8 +19,9 @@ class FileRange:
|
|
|
19
19
|
return data
|
|
20
20
|
|
|
21
21
|
|
|
22
|
-
def upload(api, stream, size, bucket, key, *, content_type=None, metadata=None):
|
|
22
|
+
def upload(api, stream, size, bucket, key, *, content_type=None, metadata=None, conditions=None):
|
|
23
23
|
body = {'bucket': bucket, 'key': key, 'size': size}
|
|
24
|
+
if conditions: body.update(conditions)
|
|
24
25
|
if content_type is not None: body['contentType'] = content_type
|
|
25
26
|
if metadata is not None: body['metadata'] = metadata
|
|
26
27
|
transfer = api.request('POST', '/objects/uploads', body)
|
|
@@ -52,13 +53,13 @@ def upload_file(api, filename, bucket, key, **options):
|
|
|
52
53
|
return upload(api, stream, os.fstat(stream.fileno()).st_size, bucket, key, **options)
|
|
53
54
|
|
|
54
55
|
|
|
55
|
-
def download_file(api, filename, bucket, key):
|
|
56
|
+
def download_file(api, filename, bucket, key, *, conditions=None):
|
|
56
57
|
path = Path(filename)
|
|
57
58
|
if path.exists() or path.is_symlink() or not path.parent.is_dir():
|
|
58
59
|
raise ValueError('Object download requires a new destination file')
|
|
59
60
|
descriptor, temporary = tempfile.mkstemp(prefix='.fufu-download-', dir=path.parent)
|
|
60
61
|
try:
|
|
61
|
-
with os.fdopen(descriptor, 'wb') as output, api.binary('GET', '/objects/download?'+urlencode({'bucket': bucket, 'key': key})) as response:
|
|
62
|
+
with os.fdopen(descriptor, 'wb') as output, api.binary('GET', '/objects/download?'+urlencode({'bucket': bucket, 'key': key, **(conditions or {})})) as response:
|
|
62
63
|
expected, received, digest = int(response.headers['Content-Length']), 0, hashlib.sha256()
|
|
63
64
|
deadline = time.monotonic()+3600
|
|
64
65
|
while True:
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: fufu-cloud-cli
|
|
3
|
-
Version: 0.9.
|
|
3
|
+
Version: 0.9.4
|
|
4
4
|
Summary: FUFU Cloud CLI for tenant-scoped mock cloud development and deployment
|
|
5
5
|
License-Expression: LicenseRef-Proprietary
|
|
6
6
|
Requires-Python: >=3.10
|
|
@@ -22,7 +22,9 @@ Dynamic: license-file
|
|
|
22
22
|
`gcloud-fufu`、`az-fufu`、`wrangler-fufu`。這是 AWS、Google Cloud、Azure 與 Cloudflare 的**明確子集**,
|
|
23
23
|
只供 FUFU mock 環境使用;不會把部署轉送到正式雲端。
|
|
24
24
|
|
|
25
|
-
0.9.
|
|
25
|
+
0.9.4 新增 GCS generation/metageneration 條件、`storage objects describe/update/list`、8 KiB 自訂 metadata 與二進位 `cat`。競爭寫入、過期條件、CLI metadata 和官方 gcloud 輸出欄位已對照驗收;不相符條件回傳 `PRECONDITION_FAILED`/HTTP 412。新功能需要同步更新 FUFU Console、GCS 儲存映像和 Cloud Shell。儲存升級前須停寫並備份完整 volume;新 metadata 格式可讀舊資料,回滾須還原升級前備份。
|
|
26
|
+
|
|
27
|
+
0.9.3 的常用輸出投影、清單篩選/排序/limit、Cloud Run 設定更新、Service Account 啟停及 Pub/Sub attributes/ACK deadline 持續保留。僅明列子集經過官方 gcloud、小用量真實 GCP 與隔離 Docker 驗收;不表示完整 Google IAM、SDK/ADC 或自動擴縮相容。
|
|
26
28
|
|
|
27
29
|
0.9.1 統一所有 CLI API 請求的 `User-Agent: fufu-cloud-cli`,涵蓋物件傳輸、
|
|
28
30
|
Cloudflare 靜態檔案部署、IaC 遠端 state 與 Cloud Shell session,避免沿用
|
|
@@ -34,7 +36,7 @@ Python 預設識別而遇到 Cloudflare HTTP 403/1010。識別標頭不取代
|
|
|
34
36
|
## Cloudflare 前端部署與 R2
|
|
35
37
|
|
|
36
38
|
```bash
|
|
37
|
-
pip install 'fufu-cloud-cli==0.9.
|
|
39
|
+
pip install 'fufu-cloud-cli==0.9.4'
|
|
38
40
|
export FUFU_CLOUDFLARE_CREDENTIAL_FILE=/run/secrets/fufu-cloudflare.json
|
|
39
41
|
wrangler-fufu capabilities
|
|
40
42
|
wrangler-fufu pages project create frontend --production-branch main
|
|
@@ -205,7 +207,8 @@ CLI 無法判斷任意原始碼或 ZIP 內的資料是否可以分享。
|
|
|
205
207
|
| Cloud Run | 已核准 HTTP 容器或 API 自動建置 Python ASGI 來源;支援配置後的 HTTPS 入口、Invoker policy;無 autoscaling、traffic splitting、Jobs |
|
|
206
208
|
| Cloud Run functions / Functions Gen 1 | Python 3.12 HTTP 函式;Gen 1 為相同 HTTP runtime 的模擬子集,無背景事件 |
|
|
207
209
|
| AWS Lambda | Python 3.10 / 3.12 ZIP、同步 invoke、程式碼更新、BUFFERED Function URL;ZIP 50 MiB/解壓 250 MiB,同步 payload 6 MiB |
|
|
208
|
-
| GCS
|
|
210
|
+
| GCS | bucket/object 子集、分段上傳/串流下載/binary cat、generation/metageneration 條件、metadata describe/update、前綴分頁;8 KiB custom metadata;無 versioning、signed URL、bucket IAM、一般 SDK 或 GCS IaC 資源 |
|
|
211
|
+
| S3 | bucket 與 object 子集;分段上傳/串流下載,受主機磁碟限制;已驗證超過 1 GiB,無 versioning / signed URL |
|
|
209
212
|
| DynamoDB / SQS / SSM | 明列資料操作;無完整交易、Streams、FIFO 或 KMS / SecureString |
|
|
210
213
|
| AWS IAM / GCP IAM | 租戶內 role / service account / policy 子集;default deny、限定 session;無原生雲端全量 IAM 語義 |
|
|
211
214
|
| API Gateway / Serverless profile | 簡化 HTTP Lambda proxy 和明確來源清單;不執行 plugins / CloudFormation |
|
|
@@ -15,6 +15,7 @@ src/fufu_cloud_cli/docker_backend.py
|
|
|
15
15
|
src/fufu_cloud_cli/errors.py
|
|
16
16
|
src/fufu_cloud_cli/gcloud_environment.py
|
|
17
17
|
src/fufu_cloud_cli/gcloud_output.py
|
|
18
|
+
src/fufu_cloud_cli/gcs.py
|
|
18
19
|
src/fufu_cloud_cli/http_runtime.py
|
|
19
20
|
src/fufu_cloud_cli/iac_state.py
|
|
20
21
|
src/fufu_cloud_cli/lambda_adapter.py
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{fufu_cloud_cli-0.9.3 → fufu_cloud_cli-0.9.4}/src/fufu_cloud_cli/runtime/functions/Dockerfile
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{fufu_cloud_cli-0.9.3 → fufu_cloud_cli-0.9.4}/src/fufu_cloud_cli/terraform_bridge/__init__.py
RENAMED
|
File without changes
|
|
File without changes
|
{fufu_cloud_cli-0.9.3 → fufu_cloud_cli-0.9.4}/src/fufu_cloud_cli/terraform_bridge/aws_data.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{fufu_cloud_cli-0.9.3 → fufu_cloud_cli-0.9.4}/src/fufu_cloud_cli/terraform_bridge/google_pubsub.py
RENAMED
|
File without changes
|
{fufu_cloud_cli-0.9.3 → fufu_cloud_cli-0.9.4}/src/fufu_cloud_cli/terraform_bridge/remote_state.py
RENAMED
|
File without changes
|
|
File without changes
|
{fufu_cloud_cli-0.9.3 → fufu_cloud_cli-0.9.4}/src/fufu_cloud_cli.egg-info/dependency_links.txt
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|