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.
Files changed (51) hide show
  1. {fufu_cloud_cli-0.9.3/src/fufu_cloud_cli.egg-info → fufu_cloud_cli-0.9.4}/PKG-INFO +7 -4
  2. {fufu_cloud_cli-0.9.3 → fufu_cloud_cli-0.9.4}/README.md +6 -3
  3. {fufu_cloud_cli-0.9.3 → fufu_cloud_cli-0.9.4}/pyproject.toml +1 -1
  4. {fufu_cloud_cli-0.9.3 → fufu_cloud_cli-0.9.4}/src/fufu_cloud_cli/__init__.py +1 -1
  5. {fufu_cloud_cli-0.9.3 → fufu_cloud_cli-0.9.4}/src/fufu_cloud_cli/api_backend.py +6 -2
  6. {fufu_cloud_cli-0.9.3 → fufu_cloud_cli-0.9.4}/src/fufu_cloud_cli/capabilities.json +11 -3
  7. {fufu_cloud_cli-0.9.3 → fufu_cloud_cli-0.9.4}/src/fufu_cloud_cli/cli.py +1 -1
  8. fufu_cloud_cli-0.9.4/src/fufu_cloud_cli/gcs.py +137 -0
  9. {fufu_cloud_cli-0.9.3 → fufu_cloud_cli-0.9.4}/src/fufu_cloud_cli/object_transfer.py +4 -3
  10. {fufu_cloud_cli-0.9.3 → fufu_cloud_cli-0.9.4/src/fufu_cloud_cli.egg-info}/PKG-INFO +7 -4
  11. {fufu_cloud_cli-0.9.3 → fufu_cloud_cli-0.9.4}/src/fufu_cloud_cli.egg-info/SOURCES.txt +1 -0
  12. {fufu_cloud_cli-0.9.3 → fufu_cloud_cli-0.9.4}/LICENSE +0 -0
  13. {fufu_cloud_cli-0.9.3 → fufu_cloud_cli-0.9.4}/MANIFEST.in +0 -0
  14. {fufu_cloud_cli-0.9.3 → fufu_cloud_cli-0.9.4}/setup.cfg +0 -0
  15. {fufu_cloud_cli-0.9.3 → fufu_cloud_cli-0.9.4}/src/fufu_cloud_cli/__main__.py +0 -0
  16. {fufu_cloud_cli-0.9.3 → fufu_cloud_cli-0.9.4}/src/fufu_cloud_cli/capacity.py +0 -0
  17. {fufu_cloud_cli-0.9.3 → fufu_cloud_cli-0.9.4}/src/fufu_cloud_cli/cloud_data.py +0 -0
  18. {fufu_cloud_cli-0.9.3 → fufu_cloud_cli-0.9.4}/src/fufu_cloud_cli/cloudflare.py +0 -0
  19. {fufu_cloud_cli-0.9.3 → fufu_cloud_cli-0.9.4}/src/fufu_cloud_cli/control.py +0 -0
  20. {fufu_cloud_cli-0.9.3 → fufu_cloud_cli-0.9.4}/src/fufu_cloud_cli/docker_backend.py +0 -0
  21. {fufu_cloud_cli-0.9.3 → fufu_cloud_cli-0.9.4}/src/fufu_cloud_cli/errors.py +0 -0
  22. {fufu_cloud_cli-0.9.3 → fufu_cloud_cli-0.9.4}/src/fufu_cloud_cli/gcloud_environment.py +0 -0
  23. {fufu_cloud_cli-0.9.3 → fufu_cloud_cli-0.9.4}/src/fufu_cloud_cli/gcloud_output.py +0 -0
  24. {fufu_cloud_cli-0.9.3 → fufu_cloud_cli-0.9.4}/src/fufu_cloud_cli/http_runtime.py +0 -0
  25. {fufu_cloud_cli-0.9.3 → fufu_cloud_cli-0.9.4}/src/fufu_cloud_cli/iac_state.py +0 -0
  26. {fufu_cloud_cli-0.9.3 → fufu_cloud_cli-0.9.4}/src/fufu_cloud_cli/lambda_adapter.py +0 -0
  27. {fufu_cloud_cli-0.9.3 → fufu_cloud_cli-0.9.4}/src/fufu_cloud_cli/limits.py +0 -0
  28. {fufu_cloud_cli-0.9.3 → fufu_cloud_cli-0.9.4}/src/fufu_cloud_cli/manifest.py +0 -0
  29. {fufu_cloud_cli-0.9.3 → fufu_cloud_cli-0.9.4}/src/fufu_cloud_cli/public_access.py +0 -0
  30. {fufu_cloud_cli-0.9.3 → fufu_cloud_cli-0.9.4}/src/fufu_cloud_cli/pubsub.py +0 -0
  31. {fufu_cloud_cli-0.9.3 → fufu_cloud_cli-0.9.4}/src/fufu_cloud_cli/pulumi-endpoints.json +0 -0
  32. {fufu_cloud_cli-0.9.3 → fufu_cloud_cli-0.9.4}/src/fufu_cloud_cli/pulumi_bridge.py +0 -0
  33. {fufu_cloud_cli-0.9.3 → fufu_cloud_cli-0.9.4}/src/fufu_cloud_cli/remote_compute.py +0 -0
  34. {fufu_cloud_cli-0.9.3 → fufu_cloud_cli-0.9.4}/src/fufu_cloud_cli/runtime/functions/Dockerfile +0 -0
  35. {fufu_cloud_cli-0.9.3 → fufu_cloud_cli-0.9.4}/src/fufu_cloud_cli/runtime/ingress/Dockerfile +0 -0
  36. {fufu_cloud_cli-0.9.3 → fufu_cloud_cli-0.9.4}/src/fufu_cloud_cli/runtime/ingress/proxy.py +0 -0
  37. {fufu_cloud_cli-0.9.3 → fufu_cloud_cli-0.9.4}/src/fufu_cloud_cli/runtime_sdk.py +0 -0
  38. {fufu_cloud_cli-0.9.3 → fufu_cloud_cli-0.9.4}/src/fufu_cloud_cli/secrets.py +0 -0
  39. {fufu_cloud_cli-0.9.3 → fufu_cloud_cli-0.9.4}/src/fufu_cloud_cli/serverless.py +0 -0
  40. {fufu_cloud_cli-0.9.3 → fufu_cloud_cli-0.9.4}/src/fufu_cloud_cli/terraform_bridge/__init__.py +0 -0
  41. {fufu_cloud_cli-0.9.3 → fufu_cloud_cli-0.9.4}/src/fufu_cloud_cli/terraform_bridge/aws.py +0 -0
  42. {fufu_cloud_cli-0.9.3 → fufu_cloud_cli-0.9.4}/src/fufu_cloud_cli/terraform_bridge/aws_data.py +0 -0
  43. {fufu_cloud_cli-0.9.3 → fufu_cloud_cli-0.9.4}/src/fufu_cloud_cli/terraform_bridge/common.py +0 -0
  44. {fufu_cloud_cli-0.9.3 → fufu_cloud_cli-0.9.4}/src/fufu_cloud_cli/terraform_bridge/google.py +0 -0
  45. {fufu_cloud_cli-0.9.3 → fufu_cloud_cli-0.9.4}/src/fufu_cloud_cli/terraform_bridge/google_pubsub.py +0 -0
  46. {fufu_cloud_cli-0.9.3 → fufu_cloud_cli-0.9.4}/src/fufu_cloud_cli/terraform_bridge/remote_state.py +0 -0
  47. {fufu_cloud_cli-0.9.3 → fufu_cloud_cli-0.9.4}/src/fufu_cloud_cli/terraform_bridge/runner.py +0 -0
  48. {fufu_cloud_cli-0.9.3 → fufu_cloud_cli-0.9.4}/src/fufu_cloud_cli.egg-info/dependency_links.txt +0 -0
  49. {fufu_cloud_cli-0.9.3 → fufu_cloud_cli-0.9.4}/src/fufu_cloud_cli.egg-info/entry_points.txt +0 -0
  50. {fufu_cloud_cli-0.9.3 → fufu_cloud_cli-0.9.4}/src/fufu_cloud_cli.egg-info/requires.txt +0 -0
  51. {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
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.3 新增 gcloud 常用輸出投影、資源清單篩選/排序/limit、Cloud Run 設定與環境變數更新、Service Account 查詢/停用/啟用,以及 Pub/Sub attributes、分頁和 message ACK deadline。這些行為已用官方 gcloud、小用量真實 GCP 與隔離 Docker 驗收;不表示完整 Google IAM、SDK/ADC 或自動擴縮相容。新增後端能力需要同步更新 FUFU Console 和 compute worker。
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.3'
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 / S3 | bucket 與 object 子集;分段上傳/串流下載,受主機磁碟限制;已驗證超過 1 GiB,無 versioning / signed URL |
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.3 新增 gcloud 常用輸出投影、資源清單篩選/排序/limit、Cloud Run 設定與環境變數更新、Service Account 查詢/停用/啟用,以及 Pub/Sub attributes、分頁和 message ACK deadline。這些行為已用官方 gcloud、小用量真實 GCP 與隔離 Docker 驗收;不表示完整 Google IAM、SDK/ADC 或自動擴縮相容。新增後端能力需要同步更新 FUFU Console 和 compute worker。
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.3'
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 / S3 | bucket 與 object 子集;分段上傳/串流下載,受主機磁碟限制;已驗證超過 1 GiB,無 versioning / signed URL |
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 |
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "fufu-cloud-cli"
7
- version = "0.9.3"
7
+ version = "0.9.4"
8
8
  description = "FUFU Cloud CLI for tenant-scoped mock cloud development and deployment"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -1,3 +1,3 @@
1
1
  """FUFU CLI 與本機部署工具;相容範圍見 capabilities.json。"""
2
2
 
3
- __version__ = "0.9.3"
3
+ __version__ = "0.9.4"
@@ -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
- raise FufuError('OBJECT_TRANSFER_FAILED', f'物件傳輸未完成(HTTP {status});未覆寫下載目的地。') from None
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
- "first-100-objects",
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
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.3 新增 gcloud 常用輸出投影、資源清單篩選/排序/limit、Cloud Run 設定與環境變數更新、Service Account 查詢/停用/啟用,以及 Pub/Sub attributes、分頁和 message ACK deadline。這些行為已用官方 gcloud、小用量真實 GCP 與隔離 Docker 驗收;不表示完整 Google IAM、SDK/ADC 或自動擴縮相容。新增後端能力需要同步更新 FUFU Console 和 compute worker。
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.3'
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 / S3 | bucket 與 object 子集;分段上傳/串流下載,受主機磁碟限制;已驗證超過 1 GiB,無 versioning / signed URL |
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