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.
- {fufu_cloud_cli-0.9.2/src/fufu_cloud_cli.egg-info → fufu_cloud_cli-0.9.3}/PKG-INFO +3 -3
- {fufu_cloud_cli-0.9.2 → fufu_cloud_cli-0.9.3}/README.md +2 -2
- {fufu_cloud_cli-0.9.2 → fufu_cloud_cli-0.9.3}/pyproject.toml +1 -1
- {fufu_cloud_cli-0.9.2 → fufu_cloud_cli-0.9.3}/src/fufu_cloud_cli/__init__.py +1 -1
- {fufu_cloud_cli-0.9.2 → fufu_cloud_cli-0.9.3}/src/fufu_cloud_cli/cli.py +61 -3
- {fufu_cloud_cli-0.9.2 → fufu_cloud_cli-0.9.3}/src/fufu_cloud_cli/control.py +3 -2
- fufu_cloud_cli-0.9.3/src/fufu_cloud_cli/gcloud_environment.py +53 -0
- fufu_cloud_cli-0.9.3/src/fufu_cloud_cli/gcloud_output.py +196 -0
- {fufu_cloud_cli-0.9.2 → fufu_cloud_cli-0.9.3}/src/fufu_cloud_cli/public_access.py +22 -2
- {fufu_cloud_cli-0.9.2 → fufu_cloud_cli-0.9.3}/src/fufu_cloud_cli/pubsub.py +30 -5
- {fufu_cloud_cli-0.9.2 → fufu_cloud_cli-0.9.3}/src/fufu_cloud_cli/remote_compute.py +6 -1
- {fufu_cloud_cli-0.9.2 → fufu_cloud_cli-0.9.3}/src/fufu_cloud_cli/terraform_bridge/google.py +5 -1
- {fufu_cloud_cli-0.9.2 → fufu_cloud_cli-0.9.3/src/fufu_cloud_cli.egg-info}/PKG-INFO +3 -3
- {fufu_cloud_cli-0.9.2 → fufu_cloud_cli-0.9.3}/src/fufu_cloud_cli.egg-info/SOURCES.txt +2 -0
- {fufu_cloud_cli-0.9.2 → fufu_cloud_cli-0.9.3}/LICENSE +0 -0
- {fufu_cloud_cli-0.9.2 → fufu_cloud_cli-0.9.3}/MANIFEST.in +0 -0
- {fufu_cloud_cli-0.9.2 → fufu_cloud_cli-0.9.3}/setup.cfg +0 -0
- {fufu_cloud_cli-0.9.2 → fufu_cloud_cli-0.9.3}/src/fufu_cloud_cli/__main__.py +0 -0
- {fufu_cloud_cli-0.9.2 → fufu_cloud_cli-0.9.3}/src/fufu_cloud_cli/api_backend.py +0 -0
- {fufu_cloud_cli-0.9.2 → fufu_cloud_cli-0.9.3}/src/fufu_cloud_cli/capabilities.json +0 -0
- {fufu_cloud_cli-0.9.2 → fufu_cloud_cli-0.9.3}/src/fufu_cloud_cli/capacity.py +0 -0
- {fufu_cloud_cli-0.9.2 → fufu_cloud_cli-0.9.3}/src/fufu_cloud_cli/cloud_data.py +0 -0
- {fufu_cloud_cli-0.9.2 → fufu_cloud_cli-0.9.3}/src/fufu_cloud_cli/cloudflare.py +0 -0
- {fufu_cloud_cli-0.9.2 → fufu_cloud_cli-0.9.3}/src/fufu_cloud_cli/docker_backend.py +0 -0
- {fufu_cloud_cli-0.9.2 → fufu_cloud_cli-0.9.3}/src/fufu_cloud_cli/errors.py +0 -0
- {fufu_cloud_cli-0.9.2 → fufu_cloud_cli-0.9.3}/src/fufu_cloud_cli/http_runtime.py +0 -0
- {fufu_cloud_cli-0.9.2 → fufu_cloud_cli-0.9.3}/src/fufu_cloud_cli/iac_state.py +0 -0
- {fufu_cloud_cli-0.9.2 → fufu_cloud_cli-0.9.3}/src/fufu_cloud_cli/lambda_adapter.py +0 -0
- {fufu_cloud_cli-0.9.2 → fufu_cloud_cli-0.9.3}/src/fufu_cloud_cli/limits.py +0 -0
- {fufu_cloud_cli-0.9.2 → fufu_cloud_cli-0.9.3}/src/fufu_cloud_cli/manifest.py +0 -0
- {fufu_cloud_cli-0.9.2 → fufu_cloud_cli-0.9.3}/src/fufu_cloud_cli/object_transfer.py +0 -0
- {fufu_cloud_cli-0.9.2 → fufu_cloud_cli-0.9.3}/src/fufu_cloud_cli/pulumi-endpoints.json +0 -0
- {fufu_cloud_cli-0.9.2 → fufu_cloud_cli-0.9.3}/src/fufu_cloud_cli/pulumi_bridge.py +0 -0
- {fufu_cloud_cli-0.9.2 → fufu_cloud_cli-0.9.3}/src/fufu_cloud_cli/runtime/functions/Dockerfile +0 -0
- {fufu_cloud_cli-0.9.2 → fufu_cloud_cli-0.9.3}/src/fufu_cloud_cli/runtime/ingress/Dockerfile +0 -0
- {fufu_cloud_cli-0.9.2 → fufu_cloud_cli-0.9.3}/src/fufu_cloud_cli/runtime/ingress/proxy.py +0 -0
- {fufu_cloud_cli-0.9.2 → fufu_cloud_cli-0.9.3}/src/fufu_cloud_cli/runtime_sdk.py +0 -0
- {fufu_cloud_cli-0.9.2 → fufu_cloud_cli-0.9.3}/src/fufu_cloud_cli/secrets.py +0 -0
- {fufu_cloud_cli-0.9.2 → fufu_cloud_cli-0.9.3}/src/fufu_cloud_cli/serverless.py +0 -0
- {fufu_cloud_cli-0.9.2 → fufu_cloud_cli-0.9.3}/src/fufu_cloud_cli/terraform_bridge/__init__.py +0 -0
- {fufu_cloud_cli-0.9.2 → fufu_cloud_cli-0.9.3}/src/fufu_cloud_cli/terraform_bridge/aws.py +0 -0
- {fufu_cloud_cli-0.9.2 → fufu_cloud_cli-0.9.3}/src/fufu_cloud_cli/terraform_bridge/aws_data.py +0 -0
- {fufu_cloud_cli-0.9.2 → fufu_cloud_cli-0.9.3}/src/fufu_cloud_cli/terraform_bridge/common.py +0 -0
- {fufu_cloud_cli-0.9.2 → fufu_cloud_cli-0.9.3}/src/fufu_cloud_cli/terraform_bridge/google_pubsub.py +0 -0
- {fufu_cloud_cli-0.9.2 → fufu_cloud_cli-0.9.3}/src/fufu_cloud_cli/terraform_bridge/remote_state.py +0 -0
- {fufu_cloud_cli-0.9.2 → fufu_cloud_cli-0.9.3}/src/fufu_cloud_cli/terraform_bridge/runner.py +0 -0
- {fufu_cloud_cli-0.9.2 → fufu_cloud_cli-0.9.3}/src/fufu_cloud_cli.egg-info/dependency_links.txt +0 -0
- {fufu_cloud_cli-0.9.2 → fufu_cloud_cli-0.9.3}/src/fufu_cloud_cli.egg-info/entry_points.txt +0 -0
- {fufu_cloud_cli-0.9.2 → fufu_cloud_cli-0.9.3}/src/fufu_cloud_cli.egg-info/requires.txt +0 -0
- {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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
|
@@ -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
|
|
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': '
|
|
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
|
-
|
|
109
|
-
|
|
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('--
|
|
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',
|
|
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':
|
|
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':
|
|
62
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{fufu_cloud_cli-0.9.2 → fufu_cloud_cli-0.9.3}/src/fufu_cloud_cli/runtime/functions/Dockerfile
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{fufu_cloud_cli-0.9.2 → fufu_cloud_cli-0.9.3}/src/fufu_cloud_cli/terraform_bridge/__init__.py
RENAMED
|
File without changes
|
|
File without changes
|
{fufu_cloud_cli-0.9.2 → fufu_cloud_cli-0.9.3}/src/fufu_cloud_cli/terraform_bridge/aws_data.py
RENAMED
|
File without changes
|
|
File without changes
|
{fufu_cloud_cli-0.9.2 → fufu_cloud_cli-0.9.3}/src/fufu_cloud_cli/terraform_bridge/google_pubsub.py
RENAMED
|
File without changes
|
{fufu_cloud_cli-0.9.2 → fufu_cloud_cli-0.9.3}/src/fufu_cloud_cli/terraform_bridge/remote_state.py
RENAMED
|
File without changes
|
|
File without changes
|
{fufu_cloud_cli-0.9.2 → fufu_cloud_cli-0.9.3}/src/fufu_cloud_cli.egg-info/dependency_links.txt
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|