fufu-cloud-cli 0.9.2__tar.gz → 0.9.3__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 (50) hide show
  1. {fufu_cloud_cli-0.9.2/src/fufu_cloud_cli.egg-info → fufu_cloud_cli-0.9.3}/PKG-INFO +3 -3
  2. {fufu_cloud_cli-0.9.2 → fufu_cloud_cli-0.9.3}/README.md +2 -2
  3. {fufu_cloud_cli-0.9.2 → fufu_cloud_cli-0.9.3}/pyproject.toml +1 -1
  4. {fufu_cloud_cli-0.9.2 → fufu_cloud_cli-0.9.3}/src/fufu_cloud_cli/__init__.py +1 -1
  5. {fufu_cloud_cli-0.9.2 → fufu_cloud_cli-0.9.3}/src/fufu_cloud_cli/cli.py +61 -3
  6. {fufu_cloud_cli-0.9.2 → fufu_cloud_cli-0.9.3}/src/fufu_cloud_cli/control.py +3 -2
  7. fufu_cloud_cli-0.9.3/src/fufu_cloud_cli/gcloud_environment.py +53 -0
  8. fufu_cloud_cli-0.9.3/src/fufu_cloud_cli/gcloud_output.py +196 -0
  9. {fufu_cloud_cli-0.9.2 → fufu_cloud_cli-0.9.3}/src/fufu_cloud_cli/public_access.py +22 -2
  10. {fufu_cloud_cli-0.9.2 → fufu_cloud_cli-0.9.3}/src/fufu_cloud_cli/pubsub.py +30 -5
  11. {fufu_cloud_cli-0.9.2 → fufu_cloud_cli-0.9.3}/src/fufu_cloud_cli/remote_compute.py +6 -1
  12. {fufu_cloud_cli-0.9.2 → fufu_cloud_cli-0.9.3}/src/fufu_cloud_cli/terraform_bridge/google.py +5 -1
  13. {fufu_cloud_cli-0.9.2 → fufu_cloud_cli-0.9.3/src/fufu_cloud_cli.egg-info}/PKG-INFO +3 -3
  14. {fufu_cloud_cli-0.9.2 → fufu_cloud_cli-0.9.3}/src/fufu_cloud_cli.egg-info/SOURCES.txt +2 -0
  15. {fufu_cloud_cli-0.9.2 → fufu_cloud_cli-0.9.3}/LICENSE +0 -0
  16. {fufu_cloud_cli-0.9.2 → fufu_cloud_cli-0.9.3}/MANIFEST.in +0 -0
  17. {fufu_cloud_cli-0.9.2 → fufu_cloud_cli-0.9.3}/setup.cfg +0 -0
  18. {fufu_cloud_cli-0.9.2 → fufu_cloud_cli-0.9.3}/src/fufu_cloud_cli/__main__.py +0 -0
  19. {fufu_cloud_cli-0.9.2 → fufu_cloud_cli-0.9.3}/src/fufu_cloud_cli/api_backend.py +0 -0
  20. {fufu_cloud_cli-0.9.2 → fufu_cloud_cli-0.9.3}/src/fufu_cloud_cli/capabilities.json +0 -0
  21. {fufu_cloud_cli-0.9.2 → fufu_cloud_cli-0.9.3}/src/fufu_cloud_cli/capacity.py +0 -0
  22. {fufu_cloud_cli-0.9.2 → fufu_cloud_cli-0.9.3}/src/fufu_cloud_cli/cloud_data.py +0 -0
  23. {fufu_cloud_cli-0.9.2 → fufu_cloud_cli-0.9.3}/src/fufu_cloud_cli/cloudflare.py +0 -0
  24. {fufu_cloud_cli-0.9.2 → fufu_cloud_cli-0.9.3}/src/fufu_cloud_cli/docker_backend.py +0 -0
  25. {fufu_cloud_cli-0.9.2 → fufu_cloud_cli-0.9.3}/src/fufu_cloud_cli/errors.py +0 -0
  26. {fufu_cloud_cli-0.9.2 → fufu_cloud_cli-0.9.3}/src/fufu_cloud_cli/http_runtime.py +0 -0
  27. {fufu_cloud_cli-0.9.2 → fufu_cloud_cli-0.9.3}/src/fufu_cloud_cli/iac_state.py +0 -0
  28. {fufu_cloud_cli-0.9.2 → fufu_cloud_cli-0.9.3}/src/fufu_cloud_cli/lambda_adapter.py +0 -0
  29. {fufu_cloud_cli-0.9.2 → fufu_cloud_cli-0.9.3}/src/fufu_cloud_cli/limits.py +0 -0
  30. {fufu_cloud_cli-0.9.2 → fufu_cloud_cli-0.9.3}/src/fufu_cloud_cli/manifest.py +0 -0
  31. {fufu_cloud_cli-0.9.2 → fufu_cloud_cli-0.9.3}/src/fufu_cloud_cli/object_transfer.py +0 -0
  32. {fufu_cloud_cli-0.9.2 → fufu_cloud_cli-0.9.3}/src/fufu_cloud_cli/pulumi-endpoints.json +0 -0
  33. {fufu_cloud_cli-0.9.2 → fufu_cloud_cli-0.9.3}/src/fufu_cloud_cli/pulumi_bridge.py +0 -0
  34. {fufu_cloud_cli-0.9.2 → fufu_cloud_cli-0.9.3}/src/fufu_cloud_cli/runtime/functions/Dockerfile +0 -0
  35. {fufu_cloud_cli-0.9.2 → fufu_cloud_cli-0.9.3}/src/fufu_cloud_cli/runtime/ingress/Dockerfile +0 -0
  36. {fufu_cloud_cli-0.9.2 → fufu_cloud_cli-0.9.3}/src/fufu_cloud_cli/runtime/ingress/proxy.py +0 -0
  37. {fufu_cloud_cli-0.9.2 → fufu_cloud_cli-0.9.3}/src/fufu_cloud_cli/runtime_sdk.py +0 -0
  38. {fufu_cloud_cli-0.9.2 → fufu_cloud_cli-0.9.3}/src/fufu_cloud_cli/secrets.py +0 -0
  39. {fufu_cloud_cli-0.9.2 → fufu_cloud_cli-0.9.3}/src/fufu_cloud_cli/serverless.py +0 -0
  40. {fufu_cloud_cli-0.9.2 → fufu_cloud_cli-0.9.3}/src/fufu_cloud_cli/terraform_bridge/__init__.py +0 -0
  41. {fufu_cloud_cli-0.9.2 → fufu_cloud_cli-0.9.3}/src/fufu_cloud_cli/terraform_bridge/aws.py +0 -0
  42. {fufu_cloud_cli-0.9.2 → fufu_cloud_cli-0.9.3}/src/fufu_cloud_cli/terraform_bridge/aws_data.py +0 -0
  43. {fufu_cloud_cli-0.9.2 → fufu_cloud_cli-0.9.3}/src/fufu_cloud_cli/terraform_bridge/common.py +0 -0
  44. {fufu_cloud_cli-0.9.2 → fufu_cloud_cli-0.9.3}/src/fufu_cloud_cli/terraform_bridge/google_pubsub.py +0 -0
  45. {fufu_cloud_cli-0.9.2 → fufu_cloud_cli-0.9.3}/src/fufu_cloud_cli/terraform_bridge/remote_state.py +0 -0
  46. {fufu_cloud_cli-0.9.2 → fufu_cloud_cli-0.9.3}/src/fufu_cloud_cli/terraform_bridge/runner.py +0 -0
  47. {fufu_cloud_cli-0.9.2 → fufu_cloud_cli-0.9.3}/src/fufu_cloud_cli.egg-info/dependency_links.txt +0 -0
  48. {fufu_cloud_cli-0.9.2 → fufu_cloud_cli-0.9.3}/src/fufu_cloud_cli.egg-info/entry_points.txt +0 -0
  49. {fufu_cloud_cli-0.9.2 → fufu_cloud_cli-0.9.3}/src/fufu_cloud_cli.egg-info/requires.txt +0 -0
  50. {fufu_cloud_cli-0.9.2 → fufu_cloud_cli-0.9.3}/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.3
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,7 @@ 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.3 新增 gcloud 常用輸出投影、資源清單篩選/排序/limit、Cloud Run 設定與環境變數更新、Service Account 查詢/停用/啟用,以及 Pub/Sub attributes、分頁和 message ACK deadline。這些行為已用官方 gcloud、小用量真實 GCP 與隔離 Docker 驗收;不表示完整 Google IAM、SDK/ADC 或自動擴縮相容。新增後端能力需要同步更新 FUFU Console 和 compute worker。
26
26
 
27
27
  0.9.1 統一所有 CLI API 請求的 `User-Agent: fufu-cloud-cli`,涵蓋物件傳輸、
28
28
  Cloudflare 靜態檔案部署、IaC 遠端 state 與 Cloud Shell session,避免沿用
@@ -34,7 +34,7 @@ Python 預設識別而遇到 Cloudflare HTTP 403/1010。識別標頭不取代
34
34
  ## Cloudflare 前端部署與 R2
35
35
 
36
36
  ```bash
37
- pip install 'fufu-cloud-cli==0.9.2'
37
+ pip install 'fufu-cloud-cli==0.9.3'
38
38
  export FUFU_CLOUDFLARE_CREDENTIAL_FILE=/run/secrets/fufu-cloudflare.json
39
39
  wrangler-fufu capabilities
40
40
  wrangler-fufu pages project create frontend --production-branch main
@@ -4,7 +4,7 @@
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.3 新增 gcloud 常用輸出投影、資源清單篩選/排序/limit、Cloud Run 設定與環境變數更新、Service Account 查詢/停用/啟用,以及 Pub/Sub attributes、分頁和 message ACK deadline。這些行為已用官方 gcloud、小用量真實 GCP 與隔離 Docker 驗收;不表示完整 Google IAM、SDK/ADC 或自動擴縮相容。新增後端能力需要同步更新 FUFU Console 和 compute worker。
8
8
 
9
9
  0.9.1 統一所有 CLI API 請求的 `User-Agent: fufu-cloud-cli`,涵蓋物件傳輸、
10
10
  Cloudflare 靜態檔案部署、IaC 遠端 state 與 Cloud Shell session,避免沿用
@@ -16,7 +16,7 @@ Python 預設識別而遇到 Cloudflare HTTP 403/1010。識別標頭不取代
16
16
  ## Cloudflare 前端部署與 R2
17
17
 
18
18
  ```bash
19
- pip install 'fufu-cloud-cli==0.9.2'
19
+ pip install 'fufu-cloud-cli==0.9.3'
20
20
  export FUFU_CLOUDFLARE_CREDENTIAL_FILE=/run/secrets/fufu-cloudflare.json
21
21
  wrangler-fufu capabilities
22
22
  wrangler-fufu pages project create frontend --production-branch main
@@ -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.3"
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.3"
@@ -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')]
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
@@ -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.3
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,7 @@ 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.3 新增 gcloud 常用輸出投影、資源清單篩選/排序/limit、Cloud Run 設定與環境變數更新、Service Account 查詢/停用/啟用,以及 Pub/Sub attributes、分頁和 message ACK deadline。這些行為已用官方 gcloud、小用量真實 GCP 與隔離 Docker 驗收;不表示完整 Google IAM、SDK/ADC 或自動擴縮相容。新增後端能力需要同步更新 FUFU Console 和 compute worker。
26
26
 
27
27
  0.9.1 統一所有 CLI API 請求的 `User-Agent: fufu-cloud-cli`,涵蓋物件傳輸、
28
28
  Cloudflare 靜態檔案部署、IaC 遠端 state 與 Cloud Shell session,避免沿用
@@ -34,7 +34,7 @@ Python 預設識別而遇到 Cloudflare HTTP 403/1010。識別標頭不取代
34
34
  ## Cloudflare 前端部署與 R2
35
35
 
36
36
  ```bash
37
- pip install 'fufu-cloud-cli==0.9.2'
37
+ pip install 'fufu-cloud-cli==0.9.3'
38
38
  export FUFU_CLOUDFLARE_CREDENTIAL_FILE=/run/secrets/fufu-cloudflare.json
39
39
  wrangler-fufu capabilities
40
40
  wrangler-fufu pages project create frontend --production-branch main
@@ -13,6 +13,8 @@ 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
16
18
  src/fufu_cloud_cli/http_runtime.py
17
19
  src/fufu_cloud_cli/iac_state.py
18
20
  src/fufu_cloud_cli/lambda_adapter.py
File without changes
File without changes