fufu-cloud-cli 0.9.2__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.2/src/fufu_cloud_cli.egg-info → fufu_cloud_cli-0.9.4}/PKG-INFO +7 -4
  2. {fufu_cloud_cli-0.9.2 → fufu_cloud_cli-0.9.4}/README.md +6 -3
  3. {fufu_cloud_cli-0.9.2 → fufu_cloud_cli-0.9.4}/pyproject.toml +1 -1
  4. {fufu_cloud_cli-0.9.2 → fufu_cloud_cli-0.9.4}/src/fufu_cloud_cli/__init__.py +1 -1
  5. {fufu_cloud_cli-0.9.2 → fufu_cloud_cli-0.9.4}/src/fufu_cloud_cli/api_backend.py +6 -2
  6. {fufu_cloud_cli-0.9.2 → fufu_cloud_cli-0.9.4}/src/fufu_cloud_cli/capabilities.json +11 -3
  7. {fufu_cloud_cli-0.9.2 → fufu_cloud_cli-0.9.4}/src/fufu_cloud_cli/cli.py +61 -3
  8. {fufu_cloud_cli-0.9.2 → fufu_cloud_cli-0.9.4}/src/fufu_cloud_cli/control.py +3 -2
  9. fufu_cloud_cli-0.9.4/src/fufu_cloud_cli/gcloud_environment.py +53 -0
  10. fufu_cloud_cli-0.9.4/src/fufu_cloud_cli/gcloud_output.py +196 -0
  11. fufu_cloud_cli-0.9.4/src/fufu_cloud_cli/gcs.py +137 -0
  12. {fufu_cloud_cli-0.9.2 → fufu_cloud_cli-0.9.4}/src/fufu_cloud_cli/object_transfer.py +4 -3
  13. {fufu_cloud_cli-0.9.2 → fufu_cloud_cli-0.9.4}/src/fufu_cloud_cli/public_access.py +22 -2
  14. {fufu_cloud_cli-0.9.2 → fufu_cloud_cli-0.9.4}/src/fufu_cloud_cli/pubsub.py +30 -5
  15. {fufu_cloud_cli-0.9.2 → fufu_cloud_cli-0.9.4}/src/fufu_cloud_cli/remote_compute.py +6 -1
  16. {fufu_cloud_cli-0.9.2 → fufu_cloud_cli-0.9.4}/src/fufu_cloud_cli/terraform_bridge/google.py +5 -1
  17. {fufu_cloud_cli-0.9.2 → fufu_cloud_cli-0.9.4/src/fufu_cloud_cli.egg-info}/PKG-INFO +7 -4
  18. {fufu_cloud_cli-0.9.2 → fufu_cloud_cli-0.9.4}/src/fufu_cloud_cli.egg-info/SOURCES.txt +3 -0
  19. {fufu_cloud_cli-0.9.2 → fufu_cloud_cli-0.9.4}/LICENSE +0 -0
  20. {fufu_cloud_cli-0.9.2 → fufu_cloud_cli-0.9.4}/MANIFEST.in +0 -0
  21. {fufu_cloud_cli-0.9.2 → fufu_cloud_cli-0.9.4}/setup.cfg +0 -0
  22. {fufu_cloud_cli-0.9.2 → fufu_cloud_cli-0.9.4}/src/fufu_cloud_cli/__main__.py +0 -0
  23. {fufu_cloud_cli-0.9.2 → fufu_cloud_cli-0.9.4}/src/fufu_cloud_cli/capacity.py +0 -0
  24. {fufu_cloud_cli-0.9.2 → fufu_cloud_cli-0.9.4}/src/fufu_cloud_cli/cloud_data.py +0 -0
  25. {fufu_cloud_cli-0.9.2 → fufu_cloud_cli-0.9.4}/src/fufu_cloud_cli/cloudflare.py +0 -0
  26. {fufu_cloud_cli-0.9.2 → fufu_cloud_cli-0.9.4}/src/fufu_cloud_cli/docker_backend.py +0 -0
  27. {fufu_cloud_cli-0.9.2 → fufu_cloud_cli-0.9.4}/src/fufu_cloud_cli/errors.py +0 -0
  28. {fufu_cloud_cli-0.9.2 → fufu_cloud_cli-0.9.4}/src/fufu_cloud_cli/http_runtime.py +0 -0
  29. {fufu_cloud_cli-0.9.2 → fufu_cloud_cli-0.9.4}/src/fufu_cloud_cli/iac_state.py +0 -0
  30. {fufu_cloud_cli-0.9.2 → fufu_cloud_cli-0.9.4}/src/fufu_cloud_cli/lambda_adapter.py +0 -0
  31. {fufu_cloud_cli-0.9.2 → fufu_cloud_cli-0.9.4}/src/fufu_cloud_cli/limits.py +0 -0
  32. {fufu_cloud_cli-0.9.2 → fufu_cloud_cli-0.9.4}/src/fufu_cloud_cli/manifest.py +0 -0
  33. {fufu_cloud_cli-0.9.2 → fufu_cloud_cli-0.9.4}/src/fufu_cloud_cli/pulumi-endpoints.json +0 -0
  34. {fufu_cloud_cli-0.9.2 → fufu_cloud_cli-0.9.4}/src/fufu_cloud_cli/pulumi_bridge.py +0 -0
  35. {fufu_cloud_cli-0.9.2 → fufu_cloud_cli-0.9.4}/src/fufu_cloud_cli/runtime/functions/Dockerfile +0 -0
  36. {fufu_cloud_cli-0.9.2 → fufu_cloud_cli-0.9.4}/src/fufu_cloud_cli/runtime/ingress/Dockerfile +0 -0
  37. {fufu_cloud_cli-0.9.2 → fufu_cloud_cli-0.9.4}/src/fufu_cloud_cli/runtime/ingress/proxy.py +0 -0
  38. {fufu_cloud_cli-0.9.2 → fufu_cloud_cli-0.9.4}/src/fufu_cloud_cli/runtime_sdk.py +0 -0
  39. {fufu_cloud_cli-0.9.2 → fufu_cloud_cli-0.9.4}/src/fufu_cloud_cli/secrets.py +0 -0
  40. {fufu_cloud_cli-0.9.2 → fufu_cloud_cli-0.9.4}/src/fufu_cloud_cli/serverless.py +0 -0
  41. {fufu_cloud_cli-0.9.2 → fufu_cloud_cli-0.9.4}/src/fufu_cloud_cli/terraform_bridge/__init__.py +0 -0
  42. {fufu_cloud_cli-0.9.2 → fufu_cloud_cli-0.9.4}/src/fufu_cloud_cli/terraform_bridge/aws.py +0 -0
  43. {fufu_cloud_cli-0.9.2 → fufu_cloud_cli-0.9.4}/src/fufu_cloud_cli/terraform_bridge/aws_data.py +0 -0
  44. {fufu_cloud_cli-0.9.2 → fufu_cloud_cli-0.9.4}/src/fufu_cloud_cli/terraform_bridge/common.py +0 -0
  45. {fufu_cloud_cli-0.9.2 → fufu_cloud_cli-0.9.4}/src/fufu_cloud_cli/terraform_bridge/google_pubsub.py +0 -0
  46. {fufu_cloud_cli-0.9.2 → fufu_cloud_cli-0.9.4}/src/fufu_cloud_cli/terraform_bridge/remote_state.py +0 -0
  47. {fufu_cloud_cli-0.9.2 → fufu_cloud_cli-0.9.4}/src/fufu_cloud_cli/terraform_bridge/runner.py +0 -0
  48. {fufu_cloud_cli-0.9.2 → fufu_cloud_cli-0.9.4}/src/fufu_cloud_cli.egg-info/dependency_links.txt +0 -0
  49. {fufu_cloud_cli-0.9.2 → fufu_cloud_cli-0.9.4}/src/fufu_cloud_cli.egg-info/entry_points.txt +0 -0
  50. {fufu_cloud_cli-0.9.2 → fufu_cloud_cli-0.9.4}/src/fufu_cloud_cli.egg-info/requires.txt +0 -0
  51. {fufu_cloud_cli-0.9.2 → 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.2
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.2 新增 `gcloud-fufu secrets versions describe`,以 metadata 權限查看版本狀態而不讀取秘密;GCP 秘密內容不可為空。配套後端依真實 GCP 驗證修正 Editor/Viewer 等角色的明列權限子集、Gen 1 HTTP invoker Pub/Sub 0 秒預設 ack deadline。這些新能力需要同步更新 FUFU 服務端;沒有完整 Google IAM、SDK/ADC 或自動擴縮相容保證。
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.2'
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.2 新增 `gcloud-fufu secrets versions describe`,以 metadata 權限查看版本狀態而不讀取秘密;GCP 秘密內容不可為空。配套後端依真實 GCP 驗證修正 Editor/Viewer 等角色的明列權限子集、Gen 1 HTTP invoker Pub/Sub 0 秒預設 ack deadline。這些新能力需要同步更新 FUFU 服務端;沒有完整 Google IAM、SDK/ADC 或自動擴縮相容保證。
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.2'
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.2"
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.2"
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": {
@@ -224,6 +224,7 @@ def fufu_command(argv):
224
224
 
225
225
 
226
226
  def gcloud_command(argv):
227
+ from .gcloud_environment import flags as environment_flags, change as environment_change
227
228
  p = Parser(prog="gcloud-fufu", description="支援 Cloud Run 單一容器與 Python HTTP function 子集。")
228
229
  roots = p.add_subparsers(dest="service", required=True)
229
230
  run_p = roots.add_parser("run")
@@ -239,6 +240,7 @@ def gcloud_command(argv):
239
240
  secrets = d.add_mutually_exclusive_group()
240
241
  secrets.add_argument('--set-secrets'); secrets.add_argument('--update-secrets'); secrets.add_argument('--clear-secrets', action='store_true')
241
242
  deployment_options(d)
243
+ environment_flags(d)
242
244
  deployment_flags(d); run_iam_flags(d)
243
245
  services = run_sub.add_parser("services").add_subparsers(dest="service_action", required=True)
244
246
  replace = services.add_parser("replace")
@@ -259,6 +261,7 @@ def gcloud_command(argv):
259
261
  generation_options(d)
260
262
  d.add_argument("--trigger-http", required=True, action="store_true")
261
263
  deployment_options(d)
264
+ environment_flags(d)
262
265
  deployment_flags(d)
263
266
  for action in ("describe", "delete", "call"):
264
267
  q = fn.add_parser(action)
@@ -284,6 +287,12 @@ def gcloud_command(argv):
284
287
  env = env_from_names(a.env)
285
288
  from .secrets import references
286
289
  secret_options = {}
290
+ mutation = environment_change(a)
291
+ if mutation:
292
+ require(api_selected(), 'API_BACKEND_REQUIRED', '原生環境更新旗標目前需要 FUFU API 模式。')
293
+ secret_options['environment_patch'] = mutation
294
+ elif not a.env and api_selected():
295
+ secret_options['environment_patch'] = {'replace': False, 'values': {}, 'remove': []}
287
296
  if a.set_secrets is not None or a.clear_secrets: secret_options['secret_references'] = references(a.set_secrets)
288
297
  if a.update_secrets is not None: secret_options['secret_updates'] = references(a.update_secrets)
289
298
  require(not secret_options or api_selected(), 'API_BACKEND_REQUIRED', 'Secret references 需要 FUFU API 模式。')
@@ -391,10 +400,34 @@ def aws_command(argv):
391
400
 
392
401
 
393
402
  def entry(provider, argv=None):
403
+ selected_keys = ['FUFU_SELECTED_REGION', 'FUFU_SELECTED_PROJECT', 'FUFU_ASSUME_ROLE']
404
+ original_environment = {key: os.environ.get(key) for key in selected_keys}
405
+ output_format = None; output_filter = None; output_sort = None; output_limit = None
406
+ def emit(value):
407
+ if provider == 'gcloud-fufu' and (output_format or output_filter or output_sort or output_limit is not None):
408
+ from .gcloud_output import native, render, select
409
+ shaped = native(value, argv)
410
+ if output_filter or output_sort or output_limit is not None:
411
+ shaped = select(shaped, output_filter, output_sort, output_limit)
412
+ sys.stdout.write(render(shaped, output_format or 'json'))
413
+ else: sys.stdout.write(json.dumps(value, ensure_ascii=False, sort_keys=True) + '\n')
394
414
  try:
395
415
  argv = sys.argv[1:] if argv is None else argv
396
416
  argv = list(argv)
397
417
  require(provider in {"fufu", "aws-fufu", "gcloud-fufu", "az-fufu", "wrangler-fufu"}, "UNSUPPORTED_CLI", "未知 CLI 入口。")
418
+ if argv == ['--version']:
419
+ sys.stdout.write(('fufu-cloud-cli ' if provider == 'gcloud-fufu' else '') + __version__ + '\n'); return
420
+ if provider == 'gcloud-fufu' and argv in [['--help'], ['-h']]:
421
+ p = Parser(prog='gcloud-fufu', description='GCP 開發環境的可執行子集;使用 FUFU credential,功能範圍請見 fufu capabilities。')
422
+ for flag, description in [('--format', 'json/yaml/value/table/csv;支援 dotted path 投影'),
423
+ ('--filter', 'list 的欄位比較與 AND/OR/NOT'), ('--sort-by', 'list 排序;~ 表示降序'),
424
+ ('--limit', '篩選排序後的 list 筆數'), ('--project', '必須符合 credential 的 project'),
425
+ ('--region', '必須符合 credential 的 region')]: p.add_argument(flag, help=description)
426
+ p.add_argument('--quiet', '-q', action='store_true', help='非互動執行')
427
+ p.add_argument('--version', action='store_true', help='CLI 版本')
428
+ groups = p.add_subparsers(title='服務')
429
+ for group in ['run', 'functions', 'iam', 'projects', 'pubsub', 'secrets', 'storage', 'auth']: groups.add_parser(group)
430
+ p.print_help(); return
398
431
  if provider == 'wrangler-fufu':
399
432
  from .cloudflare import command
400
433
  result = command(argv, Parser)
@@ -414,19 +447,40 @@ def entry(provider, argv=None):
414
447
  rest = []; i = 0
415
448
  while i < len(argv):
416
449
  flag, _, inline = argv[i].partition('=')
417
- if flag in {'--region', '--project', '--output', '--format', '--impersonate-service-account'}:
450
+ if flag in {'--region', '--project', '--output', '--format', '--impersonate-service-account', '--filter', '--sort-by'} or flag == '--limit' and provider == 'gcloud-fufu' and 'list' in argv:
418
451
  value = inline if '=' in argv[i] else (argv[i + 1] if i + 1 < len(argv) else '')
419
452
  i += 1 if '=' in argv[i] else 2
420
453
  require(bool(value) and not value.startswith('--'), 'UNSUPPORTED_ARGUMENT', '全域旗標缺少值。')
421
454
  if flag == '--impersonate-service-account':
422
455
  require(provider == 'gcloud-fufu', 'UNSUPPORTED_ARGUMENT', '此旗標只適用於 gcloud-fufu。')
423
456
  os.environ['FUFU_ASSUME_ROLE'] = value
424
- elif flag in {'--output', '--format'}: require(value == 'json', 'UNSUPPORTED_ARGUMENT', '目前輸出格式支援 json。')
457
+ elif flag == '--format' and provider == 'gcloud-fufu':
458
+ from .gcloud_output import format_spec
459
+ format_spec(value); output_format = value
460
+ elif flag in {'--filter', '--sort-by', '--limit'}:
461
+ require(provider == 'gcloud-fufu' and 'list' in argv, 'UNSUPPORTED_ARGUMENT', '清單選取旗標只適用於 gcloud-fufu list。')
462
+ from .gcloud_output import Filter, field
463
+ if flag == '--filter': Filter(value); output_filter = value
464
+ if flag == '--sort-by':
465
+ for column in value.split(','):
466
+ require(not field(column.lstrip('~'))[1], 'UNSUPPORTED_ARGUMENT', '排序轉換尚未支援。')
467
+ output_sort = value
468
+ if flag == '--limit':
469
+ require(value.isdigit(), 'UNSUPPORTED_ARGUMENT', 'limit 必須為非負整數。'); output_limit = int(value)
470
+ elif flag in {'--output', '--format'}: require(value == 'json', 'UNSUPPORTED_ARGUMENT', '此入口目前輸出格式支援 json。')
425
471
  else: os.environ['FUFU_SELECTED_REGION' if flag == '--region' else 'FUFU_SELECTED_PROJECT'] = value
426
- elif flag in {'--quiet', '--no-cli-pager'}:
472
+ elif flag in {'--quiet', '-q', '--no-cli-pager'}:
427
473
  require('=' not in argv[i], 'UNSUPPORTED_ARGUMENT', '布林旗標不接受額外值。'); i += 1
428
474
  else: rest.append(argv[i]); i += 1
429
475
  argv = rest
476
+ if provider == 'gcloud-fufu':
477
+ if output_filter or output_sort or output_limit is not None:
478
+ list_commands = [('run', 'services', 'list'), ('functions', 'list'), ('iam', 'service-accounts', 'list'),
479
+ ('pubsub', 'topics', 'list'), ('pubsub', 'subscriptions', 'list'), ('secrets', 'list'),
480
+ ('secrets', 'versions', 'list'), ('storage', 'buckets', 'list'), ('storage', 'objects', 'list')]
481
+ require(any(tuple(argv[:len(command)]) == command for command in list_commands), 'UNSUPPORTED_ARGUMENT', '清單選取旗標只適用於資源 list;未執行操作。')
482
+ if output_format and argv[:2] == ['functions', 'call']:
483
+ require(output_format == 'json', 'UNSUPPORTED_FORMAT', 'Functions call 目前只支援 JSON 回應。')
430
484
  from . import public_access
431
485
  if provider == 'aws-fufu' and argv[:1] == ['secretsmanager'] or provider == 'gcloud-fufu' and argv[:1] == ['secrets']:
432
486
  from .secrets import command
@@ -487,6 +541,10 @@ def entry(provider, argv=None):
487
541
  except (OSError, ValueError, KeyError, TypeError) as exc:
488
542
  sys.stderr.write(json.dumps({"error": {"code": "LOCAL_OPERATION_FAILED", "message": "本機檔案或輸入操作失敗;未輸出原始設定。"}}, ensure_ascii=False) + "\n")
489
543
  raise SystemExit(1) from exc
544
+ finally:
545
+ for key, value in original_environment.items():
546
+ if value is None: os.environ.pop(key, None)
547
+ else: os.environ[key] = value
490
548
 
491
549
 
492
550
  def main():
@@ -134,16 +134,17 @@ def gcp_command(argv, Parser):
134
134
  c = sub.add_parser('create'); c.add_argument('name'); c.add_argument('--display-name'); c.add_argument('--description')
135
135
  c = sub.add_parser('update'); c.add_argument('email'); c.add_argument('--display-name'); c.add_argument('--description')
136
136
  sub.add_parser('delete').add_argument('email')
137
+ for action in ['describe', 'enable', 'disable']: sub.add_parser(action).add_argument('email')
137
138
  sub = roots.add_parser('projects').add_subparsers(dest='action', required=True)
138
139
  for act in ['get-iam-policy', 'add-iam-policy-binding', 'remove-iam-policy-binding']:
139
140
  q = sub.add_parser(act); q.add_argument('project')
140
141
  if act != 'get-iam-policy': q.add_argument('--member', required=True); q.add_argument('--role', required=True)
141
142
  a = p.parse_args(argv)
142
- op = {'get-iam-policy': 'list', 'add-iam-policy-binding': 'add-binding', 'remove-iam-policy-binding': 'remove-binding'}.get(a.action, a.action)
143
+ op = {'describe': 'get', 'get-iam-policy': 'get-policy', 'add-iam-policy-binding': 'add-binding', 'remove-iam-policy-binding': 'remove-binding'}.get(a.action, a.action)
143
144
  params = {k: getattr(a, k) for k in ['name', 'email', 'project', 'member', 'role'] if hasattr(a, k)}
144
145
  if getattr(a, 'display_name', None) is not None: params['displayName'] = a.display_name
145
146
  if getattr(a, 'description', None) is not None: params['description'] = a.description
146
- with ApiClient('gcp') as api: return api.request('POST', '/control/iam', {'operation': op, 'params': params})
147
+ with ApiClient('gcp', read_only=op in {'list', 'get', 'get-policy'}) as api: return api.request('POST', '/control/iam', {'operation': op, 'params': params})
147
148
 
148
149
 
149
150
  def gateway_command(argv, Parser):
@@ -0,0 +1,53 @@
1
+ """gcloud environment mutation syntax; values are never included in errors."""
2
+ from pathlib import Path
3
+ import yaml
4
+ from .errors import require
5
+ from .manifest import environment
6
+
7
+
8
+ def dictionary(value):
9
+ delimiter = ','
10
+ if value.startswith('^'):
11
+ _, delimiter, value = value.split('^', 2) if value.count('^') >= 2 else ('', '', '')
12
+ require(bool(delimiter), 'INVALID_INPUT', '自訂 delimiter 格式為 ^DELIMITER^key=value。')
13
+ pairs = value.split(delimiter) if value else []
14
+ result = {}
15
+ for pair in pairs:
16
+ key, sep, val = pair.partition('=')
17
+ require(sep and key and key not in result, 'INVALID_INPUT', '請使用不重複的 key=value 配置。')
18
+ result[key] = val
19
+ return result
20
+
21
+
22
+ def flags(parser):
23
+ group = parser.add_mutually_exclusive_group()
24
+ group.add_argument('--set-env-vars', action='append')
25
+ group.add_argument('--env-vars-file')
26
+ group.add_argument('--clear-env-vars', action='store_true')
27
+ parser.add_argument('--update-env-vars', action='append')
28
+ parser.add_argument('--remove-env-vars', action='append')
29
+
30
+
31
+ def change(args):
32
+ replace = any((getattr(args, 'set_env_vars', None) is not None, getattr(args, 'env_vars_file', None), getattr(args, 'clear_env_vars', False)))
33
+ updates, removes = getattr(args, 'update_env_vars', None), getattr(args, 'remove_env_vars', None)
34
+ require(not replace or not (updates or removes), 'CONFLICTING_ARGUMENTS', '替換/清空環境變數不能與更新/移除並用。')
35
+ require(not getattr(args, 'env', None) or not (replace or updates or removes), 'CONFLICTING_ARGUMENTS', '--env 與原生環境旗標不可混用。')
36
+ values = {}
37
+ for item in getattr(args, 'set_env_vars', None) or updates or []: values.update(dictionary(item))
38
+ if getattr(args, 'env_vars_file', None):
39
+ file = Path(args.env_vars_file)
40
+ require(file.is_file() and not file.is_symlink() and file.stat().st_size <= 1024**2, 'INVALID_INPUT', '環境檔必須是不超過 1 MiB 的一般 YAML/JSON 檔。')
41
+ values = yaml.safe_load(file.read_text())
42
+ if replace or updates or removes:
43
+ names = [name for item in removes or [] for name in item.split(',')]
44
+ environment({name: '' for name in names})
45
+ return {'replace': replace, 'values': environment(values), 'remove': names}
46
+ return None
47
+
48
+
49
+ def merge(current, mutation):
50
+ result = {} if mutation['replace'] else dict(current)
51
+ for name in mutation['remove']: result.pop(name, None)
52
+ result.update(mutation['values'])
53
+ return environment(result)
@@ -0,0 +1,196 @@
1
+ """Explicit gcloud output and list-selection subset, without a Cloud SDK dependency."""
2
+ import copy
3
+ import csv
4
+ import io
5
+ import json
6
+ import re
7
+
8
+ import yaml
9
+
10
+ from .errors import require
11
+
12
+
13
+ def field(spec):
14
+ match = re.fullmatch(r'([A-Za-z_][\w-]*(?:\.[A-Za-z_][\w-]*|\[\d+\])*)(\.basename\(\))?(?::label=([A-Za-z_][\w -]*))?', spec)
15
+ require(match, 'UNSUPPORTED_FORMAT', '欄位支援 dotted path、數字索引、basename() 與 :label=;其他投影尚未支援。')
16
+ path, transform, label = match.groups()
17
+ parts = re.findall(r'[A-Za-z_][\w-]*|\d+', path)
18
+ return parts, bool(transform), label or parts[-1].upper()
19
+
20
+
21
+ def lookup(value, parts):
22
+ for part in parts:
23
+ if isinstance(value, list) and part.isdigit(): value = value[int(part)] if int(part) < len(value) else None
24
+ elif isinstance(value, dict): value = value.get(part)
25
+ else: return None
26
+ return value
27
+
28
+
29
+ def cell(value):
30
+ if value is None: return ''
31
+ if isinstance(value, (dict, list)): return json.dumps(value, ensure_ascii=False, sort_keys=True)
32
+ return str(value)
33
+
34
+
35
+ def project(row, columns):
36
+ result = {}
37
+ for parts, transform, _ in columns:
38
+ require(not transform and not any(p.isdigit() for p in parts), 'UNSUPPORTED_FORMAT', 'JSON/YAML 投影目前支援物件欄位;轉換與索引請使用 value/table/csv。')
39
+ target = result
40
+ for p in parts[:-1]:
41
+ require(not p in target or isinstance(target[p], dict), 'UNSUPPORTED_FORMAT', '投影欄位彼此衝突。')
42
+ target = target.setdefault(p, {})
43
+ value = lookup(row, parts)
44
+ if value is not None: target[parts[-1]] = value
45
+ return result
46
+
47
+
48
+ def format_spec(spec):
49
+ match = re.fullmatch(r'(json|yaml|value|table|csv)(\[no-heading\])?(?:\((.*)\))?', spec)
50
+ require(match, 'UNSUPPORTED_FORMAT', '支援 json、yaml、value(...)、table(...)、csv(...);尚未支援的格式會明確拒絕。')
51
+ kind, no_heading, fields = match.groups()
52
+ columns = [field(s.strip()) for s in fields.split(',')] if fields else []
53
+ require(kind in {'json', 'yaml'} or columns, 'UNSUPPORTED_FORMAT', 'value/table/csv 請明列欄位。')
54
+ require(not no_heading or kind in {'table', 'csv'}, 'UNSUPPORTED_FORMAT', 'no-heading 只用於 table/csv。')
55
+ if kind in {'json', 'yaml'}: project({}, columns)
56
+ return kind, bool(no_heading), columns
57
+
58
+
59
+ def render(value, spec):
60
+ kind, no_heading, columns = format_spec(spec)
61
+ rows = value if isinstance(value, list) else [value]
62
+ if kind in {'json', 'yaml'}:
63
+ projected = [project(row, columns) for row in rows] if columns else rows
64
+ if kind == 'json':
65
+ return json.dumps(projected if isinstance(value, list) else projected[0], ensure_ascii=False, sort_keys=True, indent=2) + '\n'
66
+ if isinstance(value, list):
67
+ return yaml.safe_dump_all(projected, allow_unicode=True, sort_keys=True, explicit_start=True) if projected else ''
68
+ return yaml.safe_dump(projected[0], allow_unicode=True, sort_keys=True)
69
+ data = []
70
+ for row in rows:
71
+ values = []
72
+ for parts, transform, _ in columns:
73
+ v = cell(lookup(row, parts))
74
+ values.append(v.rstrip('/').rsplit('/', 1)[-1] if transform else v)
75
+ data.append(values)
76
+ headers = [c[2] for c in columns]
77
+ if kind == 'value': return ''.join('\t'.join(row) + '\n' for row in data)
78
+ if not data: return ''
79
+ if kind == 'csv':
80
+ out = io.StringIO(); writer = csv.writer(out, lineterminator='\n')
81
+ if not no_heading: writer.writerow([h.lower() for h in headers])
82
+ writer.writerows(data); return out.getvalue()
83
+ table = data if no_heading else [headers, *data]
84
+ widths = [max(len(row[i]) for row in table) for i in range(len(columns))]
85
+ return ''.join(' '.join(v.ljust(widths[i]) for i, v in enumerate(row)).rstrip() + '\n' for row in table)
86
+
87
+
88
+ class Filter:
89
+ """Bounded parser: no eval, shell execution or caller-controlled regular expressions."""
90
+ def __init__(self, expression):
91
+ require(len(expression) <= 4096, 'UNSUPPORTED_FILTER', 'Filter 長度上限為 4096。')
92
+ self.tokens = []; self.index = 0
93
+ pattern = re.compile(r'\s*(>=|<=|!=|[():=<>]|"(?:[^"\\]|\\.)*"|\x27(?:[^\x27\\]|\\.)*\x27|[^\s():=<>!]+)')
94
+ position = 0
95
+ while position < len(expression.rstrip()):
96
+ match = pattern.match(expression, position)
97
+ require(match, 'UNSUPPORTED_FILTER', 'Filter 語法尚未支援。')
98
+ self.tokens.append(match[1]); position = match.end()
99
+ require(len(self.tokens) <= 256, 'UNSUPPORTED_FILTER', 'Filter 條件過多。')
100
+ self.tree = self.group(0)
101
+ require(self.index == len(self.tokens), 'UNSUPPORTED_FILTER', 'Filter 必須以 AND/OR 連接條件;不支援的語法不會被忽略。')
102
+
103
+ def pop(self):
104
+ require(self.index < len(self.tokens), 'UNSUPPORTED_FILTER', 'Filter 不完整。')
105
+ v = self.tokens[self.index]; self.index += 1; return v
106
+
107
+ def peek(self): return self.tokens[self.index] if self.index < len(self.tokens) else None
108
+
109
+ def group(self, depth):
110
+ require(depth < 16, 'UNSUPPORTED_FILTER', 'Filter 巢狀層數過多。')
111
+ items = [self.term(depth)]; operator = None
112
+ while self.peek() in {'AND', 'OR'}:
113
+ op = self.pop()
114
+ require(operator is None or operator == op, 'UNSUPPORTED_FILTER', 'AND 與 OR 混用必須加括號。')
115
+ operator = op; items.append(self.term(depth))
116
+ return (operator or 'AND', items)
117
+
118
+ def term(self, depth):
119
+ require(depth < 16, 'UNSUPPORTED_FILTER', 'Filter 巢狀層數過多。')
120
+ if self.peek() == 'NOT': self.pop(); return ('NOT', self.term(depth + 1))
121
+ if self.peek() == '(':
122
+ self.pop(); result = self.group(depth + 1)
123
+ require(self.pop() == ')', 'UNSUPPORTED_FILTER', 'Filter 括號未配對。'); return result
124
+ parts, transform, _ = field(self.pop())
125
+ require(not transform, 'UNSUPPORTED_FILTER', 'Filter 轉換尚未支援。')
126
+ op, value = self.pop(), self.pop()
127
+ require(op in {'=', '!=', ':', '>', '<', '>=', '<='} and value not in {'(', ')', 'AND', 'OR'}, 'UNSUPPORTED_FILTER', '不支援此 Filter 運算子或值。')
128
+ if value.startswith(('"', "'")):
129
+ require('\\' not in value, 'UNSUPPORTED_FILTER', 'Filter 跳脫字元尚未支援。'); value = value[1:-1]
130
+ require('~' not in value and (not '*' in value or op == ':' and value.count('*') == 1), 'UNSUPPORTED_FILTER', '萬用字元目前僅支援 : 運算子的單一 *。')
131
+ return ('compare', parts, op, value)
132
+
133
+ def matches(self, row):
134
+ def compare(actual, op, expected):
135
+ if op == ':' and expected == '*': return actual is not None
136
+ if actual is None: return op == '!='
137
+ if isinstance(actual, list): return any(compare(v, op, expected) for v in actual)
138
+ left, right = cell(actual).casefold(), expected.casefold()
139
+ if op == ':':
140
+ if '*' in right:
141
+ start, end = right.split('*'); return left.startswith(start) and left.endswith(end) and len(left) >= len(start) + len(end)
142
+ return right in left
143
+ try: left, right = float(left), float(right)
144
+ except ValueError: pass
145
+ if type(left) != type(right): return op == '!='
146
+ return {'=': lambda: left == right, '!=': lambda: left != right, '>': lambda: left > right,
147
+ '<': lambda: left < right, '>=': lambda: left >= right, '<=': lambda: left <= right}[op]()
148
+ def visit(node):
149
+ if node[0] == 'NOT': return not visit(node[1])
150
+ if node[0] in {'AND', 'OR'}: return (all if node[0] == 'AND' else any)(visit(n) for n in node[1])
151
+ return compare(lookup(row, node[1]), node[2], node[3])
152
+ return visit(self.tree)
153
+
154
+
155
+ def select(rows, expression=None, sort_by=None, limit=None):
156
+ require(isinstance(rows, list), 'UNSUPPORTED_ARGUMENT', 'Filter、排序與 limit 只適用於資源 list。')
157
+ result = list(rows)
158
+ if expression:
159
+ predicate = Filter(expression)
160
+ result = [row for row in result if predicate.matches(row)]
161
+ for spec in reversed(sort_by.split(',') if sort_by else []):
162
+ descending = spec.startswith('~'); parts, transform, _ = field(spec.lstrip('~'))
163
+ require(not transform, 'UNSUPPORTED_ARGUMENT', '排序請使用物件欄位。')
164
+ def key(row):
165
+ value = lookup(row, parts)
166
+ return (value is not None, 0 if isinstance(value, (int, float)) else 1, value if isinstance(value, (int, float)) else cell(value))
167
+ result.sort(key=key, reverse=descending)
168
+ return result[:limit] if limit is not None else result
169
+
170
+
171
+ def native(value, argv):
172
+ """Shape only known resources; retain explicit FUFU metadata on individual records."""
173
+ if not isinstance(value, dict): return value
174
+ result = copy.deepcopy(value)
175
+ if tuple(argv[:3]) in {('pubsub', 'topics', 'create'), ('pubsub', 'subscriptions', 'create')}:
176
+ return [result]
177
+ if tuple(argv[:3]) == ('pubsub', 'subscriptions', 'pull'):
178
+ return result.get('receivedMessages', [])
179
+ if argv[:1] in [['run'], ['functions']] and 'kind' in result and 'name' in result:
180
+ run = result['kind'] == 'cloud-run-http'
181
+ url = result.get('publicAccess', {}).get('url')
182
+ if run:
183
+ result.update(apiVersion='serving.knative.dev/v1', kind='Service')
184
+ result['metadata'] = {'name': result['name']}
185
+ result['status'] = {'url': url} if url else {}
186
+ elif result['kind'] == 'cloud-run-functions-http':
187
+ result['serviceConfig'] = {'uri': url} if url else {}
188
+ else: result['httpsTrigger'] = {'url': url} if url else {}
189
+ lists = {('run', 'services', 'list'): 'services', ('functions', 'list'): 'services',
190
+ ('iam', 'service-accounts', 'list'): 'accounts', ('pubsub', 'topics', 'list'): 'topics',
191
+ ('pubsub', 'subscriptions', 'list'): 'subscriptions', ('secrets', 'list'): 'secrets',
192
+ ('secrets', 'versions', 'list'): 'versions', ('storage', 'buckets', 'list'): 'buckets'}
193
+ for prefix, key in lists.items():
194
+ if tuple(argv[:len(prefix)]) == prefix and key in result:
195
+ return [native(row, argv) for row in result[key]]
196
+ return result
@@ -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:
@@ -98,6 +98,12 @@ def gcp_command(argv, Parser):
98
98
  p.set_defaults(gen2=True)
99
99
  if action == 'update':
100
100
  deployment_flags(p); run_iam_flags(p)
101
+ from .gcloud_environment import flags
102
+ from .cli import deployment_options
103
+ flags(p); deployment_options(p)
104
+ p.add_argument('--service-account')
105
+ secrets = p.add_mutually_exclusive_group()
106
+ secrets.add_argument('--set-secrets'); secrets.add_argument('--update-secrets'); secrets.add_argument('--clear-secrets', action='store_true')
101
107
  elif action in {'add-iam-policy-binding', 'remove-iam-policy-binding'}:
102
108
  p.add_argument('--member', required=True); p.add_argument('--role', required=True)
103
109
  elif action == 'set-iam-policy':
@@ -105,8 +111,22 @@ def gcp_command(argv, Parser):
105
111
  a = p.parse_args(args[1:])
106
112
  kind = 'cloud-run-http' if run else 'cloud-run-functions-http' if a.gen2 else 'cloud-functions-gen1-http'
107
113
  if action == 'update':
108
- require(configuration(a, kind) or a.anonymous is not None, 'MISSING_CONFIGURATION', '至少提供一個存取設定旗標。')
109
- return configure_gcp(a, kind, a.name)
114
+ from .gcloud_environment import change
115
+ from .cli import resource_options, env_from_names
116
+ from .remote_compute import RemoteBackend
117
+ from .secrets import references
118
+ mutation = change(a); body = {}
119
+ if mutation is not None: body['environment_patch'] = mutation
120
+ if a.env: body['env'] = env_from_names(a.env)
121
+ resources = resource_options(a, kind)
122
+ if resources: body['resources'] = resources
123
+ if a.service_account: body['role'] = a.service_account
124
+ if a.set_secrets is not None or a.clear_secrets: body['secret_references'] = references(a.set_secrets)
125
+ if a.update_secrets is not None: body['secret_updates'] = references(a.update_secrets)
126
+ require(configuration(a, kind) or a.anonymous is not None or body, 'MISSING_CONFIGURATION', '至少提供一個服務設定旗標。')
127
+ require(api_selected(), 'API_BACKEND_REQUIRED', '服務設定更新需要 FUFU API 模式。')
128
+ deployed = RemoteBackend(kind).configure(kind, a.name, body) if body else None
129
+ return configure_gcp(a, kind, a.name, deployed)
110
130
  path = access_path(kind, a.name)
111
131
  with ApiClient('gcp', read_only=action == 'get-iam-policy') as api:
112
132
  current = api.request('GET', path)
@@ -1,8 +1,10 @@
1
1
  """gcloud Pub/Sub commands using the tenant API shared with Terraform."""
2
2
  import base64
3
3
  import re
4
+ import time
4
5
  from .api_backend import ApiClient
5
6
  from .errors import require
7
+ from .gcloud_environment import dictionary
6
8
 
7
9
 
8
10
  def command(argv, Parser):
@@ -13,17 +15,18 @@ def command(argv, Parser):
13
15
  for action in ['create', 'describe', 'update', 'delete', 'list']:
14
16
  q = actions.add_parser(action)
15
17
  if action != 'list': q.add_argument('name')
16
- else: q.add_argument('--limit', type=int, default=100); q.add_argument('--page-token')
18
+ else: q.add_argument('--page-size', type=int, default=100); q.add_argument('--page-token')
17
19
  if action in {'create', 'update'}:
18
20
  q.add_argument('--labels' if action == 'create' else '--update-labels', dest='labels')
19
21
  if action == 'update': q.add_argument('--clear-labels', action='store_true')
20
22
  if group == 'subscriptions': q.add_argument('--ack-deadline', type=int)
21
23
  if action == 'create' and group == 'subscriptions': q.add_argument('--topic', required=True)
22
24
  if group == 'topics':
23
- q = actions.add_parser('publish'); q.add_argument('name'); q.add_argument('--message', required=True)
25
+ q = actions.add_parser('publish'); q.add_argument('name'); q.add_argument('--message'); q.add_argument('--attribute', action='append')
24
26
  else:
25
27
  q = actions.add_parser('pull'); q.add_argument('name'); q.add_argument('--limit', type=int, default=1); q.add_argument('--auto-ack', action='store_true')
26
28
  q = actions.add_parser('ack'); q.add_argument('name'); q.add_argument('--ack-ids', nargs='+', required=True)
29
+ q = actions.add_parser('modify-message-ack-deadline'); q.add_argument('name'); q.add_argument('--ack-ids', nargs='+', required=True); q.add_argument('--ack-deadline', type=int, required=True)
27
30
  a = p.parse_args(argv)
28
31
  with ApiClient('gcp', read_only=a.action in {'describe', 'list'}) as api:
29
32
  project = api.whoami()['projectId']
@@ -56,11 +59,33 @@ def command(argv, Parser):
56
59
  fields = [key for key in body if key != 'name']
57
60
  require(fields, 'INVALID_INPUT', 'update 需要 labels 或 ack-deadline。')
58
61
  return call('Update' + singular, {singular.lower(): body, 'updateMask': {'paths': fields}})
59
- if a.action == 'list': return call('List' + singular + 's', {'pageSize': a.limit, **({'pageToken': a.page_token} if a.page_token else {})})
62
+ if a.action == 'list':
63
+ require(1 <= a.page_size <= 1000, 'INVALID_INPUT', 'page-size 必須介於 1–1000。')
64
+ rows, token, seen = [], a.page_token, set()
65
+ deadline = time.monotonic() + 60
66
+ for _ in range(1000):
67
+ require(time.monotonic() < deadline, 'PAGINATION_FAILED', '清單讀取超過 60 秒;未回傳不完整結果。')
68
+ result = call('List' + singular + 's', {'pageSize': a.page_size, **({'pageToken': token} if token else {})})
69
+ rows.extend(result.get(a.group, [])); token = result.get('nextPageToken')
70
+ if not token: return {a.group: rows}
71
+ require(token not in seen and len(rows) <= 100000, 'PAGINATION_FAILED', '清單分頁未前進或超過本次安全讀取範圍;未回傳不完整結果。')
72
+ seen.add(token)
73
+ require(False, 'PAGINATION_FAILED', '清單分頁超過本次安全讀取範圍;未回傳不完整結果。')
60
74
  if a.action in {'describe', 'delete'}: return call(('Get' if a.action == 'describe' else 'Delete') + singular, {singular.lower(): full})
61
- if a.action == 'publish': return call('Publish', {'topic': full, 'messages': [{'data': base64.b64encode(a.message.encode()).decode()}]})
62
- if a.action == 'ack': return call('Acknowledge', {'subscription': full, 'ackIds': a.ack_ids})
75
+ if a.action == 'publish':
76
+ attributes = {}
77
+ for value in a.attribute or []: attributes.update(dictionary(value))
78
+ require(a.message or attributes, 'INVALID_INPUT', 'Publish 需要非空 message 或 attribute。')
79
+ return call('Publish', {'topic': full, 'messages': [{'data': base64.b64encode((a.message or '').encode()).decode(), 'attributes': attributes}]})
80
+ if a.action in {'ack', 'modify-message-ack-deadline'}:
81
+ ids = [item for group in a.ack_ids for item in group.split(',')]
82
+ params = {'subscription': full, 'ackIds': ids}
83
+ if a.action == 'modify-message-ack-deadline':
84
+ require(0 <= a.ack_deadline <= 600, 'INVALID_INPUT', 'ack-deadline 必須介於 0–600;0 表示立即重新投遞。')
85
+ params['ackDeadlineSeconds'] = a.ack_deadline
86
+ return call('Acknowledge' if a.action == 'ack' else 'ModifyAckDeadline', params)
63
87
  result = call('Pull', {'subscription': full, 'maxMessages': a.limit})
64
88
  if a.auto_ack and result.get('receivedMessages'):
65
89
  call('Acknowledge', {'subscription': full, 'ackIds': [message['ackId'] for message in result['receivedMessages']]})
90
+ for message in result['receivedMessages']: message['ackStatus'] = 'SUCCESS'
66
91
  return result
@@ -114,7 +114,7 @@ class RemoteBackend:
114
114
 
115
115
  def deploy(self, *, kind, service, image=None, port=8080, env=None, startup_timeout=30,
116
116
  replace=True, require_existing=False, archive=None, runtime=None, handler=None, preserve_environment=False, role=None, resources=None,
117
- secret_references=None, secret_updates=None):
117
+ secret_references=None, secret_updates=None, environment_patch=None):
118
118
  body = dict(kind=kind, name=service, port=port, env=env or {}, startup_timeout=startup_timeout,
119
119
  replace=replace, require_existing=require_existing, preserve_environment=preserve_environment)
120
120
  if image is not None:
@@ -125,6 +125,7 @@ class RemoteBackend:
125
125
  if resources: body['resources'] = resources
126
126
  if secret_references is not None: body['secret_references'] = secret_references
127
127
  if secret_updates is not None: body['secret_updates'] = secret_updates
128
+ if environment_patch is not None: body['environment_patch'] = environment_patch
128
129
  with ApiClient(self.provider) as api:
129
130
  if archive is not None and len(archive) > 8*1024**2:
130
131
  body['archive_ref'] = upload_archive(api, kind, service, archive, require_existing)
@@ -142,6 +143,10 @@ class RemoteBackend:
142
143
  with ApiClient(self.provider, read_only=True) as api:
143
144
  return api.request('GET', '/compute/services/' + segment(kind) + '/' + segment(service))
144
145
 
146
+ def configure(self, kind, service, body):
147
+ with ApiClient(self.provider) as api:
148
+ return self.wait(api, api.request('PATCH', '/compute/services/' + segment(kind) + '/' + segment(service) + '/configuration', body))
149
+
145
150
  def listing(self, kind):
146
151
  with ApiClient(self.provider, read_only=True) as api:
147
152
  return api.request('GET', '/compute/services?' + urlencode({'kind': kind}))['services']
@@ -15,7 +15,7 @@ def account(bridge, value):
15
15
  project = bridge.identities['gcp']['projectId']
16
16
  return {'name': 'projects/' + project + '/serviceAccounts/' + email, 'projectId': project, 'email': email,
17
17
  'uniqueId': value.get('uniqueId', str(int(hashlib.sha256(email.encode()).hexdigest()[:16], 16))),
18
- 'displayName': value['displayName'], 'description': value.get('description', ''), 'disabled': False}
18
+ 'displayName': value['displayName'], 'description': value.get('description', ''), 'disabled': value.get('disabled', False)}
19
19
 
20
20
 
21
21
  def iam(bridge, method, path, body):
@@ -25,6 +25,10 @@ def iam(bridge, method, path, body):
25
25
  if project != '-': bridge.project(project)
26
26
  elif not email: raise FufuError('ACCESS_DENIED', '需要明確的 project。')
27
27
  if email: email = unquote(email)
28
+ if email and method == 'POST' and email.endswith((':enable', ':disable')):
29
+ email, action = email.rsplit(':', 1)
30
+ only(body, [], 'Service Account lifecycle')
31
+ bridge.iam(action, {'email': email}); return 200, {}, {}
28
32
  if method == 'POST' and not email:
29
33
  only(body, ['accountId', 'serviceAccount'], 'Service Account')
30
34
  options = only(body.get('serviceAccount', {}), ['displayName', 'description'], 'Service Account')
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: fufu-cloud-cli
3
- Version: 0.9.2
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.2 新增 `gcloud-fufu secrets versions describe`,以 metadata 權限查看版本狀態而不讀取秘密;GCP 秘密內容不可為空。配套後端依真實 GCP 驗證修正 Editor/Viewer 等角色的明列權限子集、Gen 1 HTTP invoker Pub/Sub 0 秒預設 ack deadline。這些新能力需要同步更新 FUFU 服務端;沒有完整 Google IAM、SDK/ADC 或自動擴縮相容保證。
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.2'
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 |
@@ -13,6 +13,9 @@ src/fufu_cloud_cli/cloudflare.py
13
13
  src/fufu_cloud_cli/control.py
14
14
  src/fufu_cloud_cli/docker_backend.py
15
15
  src/fufu_cloud_cli/errors.py
16
+ src/fufu_cloud_cli/gcloud_environment.py
17
+ src/fufu_cloud_cli/gcloud_output.py
18
+ src/fufu_cloud_cli/gcs.py
16
19
  src/fufu_cloud_cli/http_runtime.py
17
20
  src/fufu_cloud_cli/iac_state.py
18
21
  src/fufu_cloud_cli/lambda_adapter.py
File without changes
File without changes