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.
- {fufu_cloud_cli-0.9.2/src/fufu_cloud_cli.egg-info → fufu_cloud_cli-0.9.4}/PKG-INFO +7 -4
- {fufu_cloud_cli-0.9.2 → fufu_cloud_cli-0.9.4}/README.md +6 -3
- {fufu_cloud_cli-0.9.2 → fufu_cloud_cli-0.9.4}/pyproject.toml +1 -1
- {fufu_cloud_cli-0.9.2 → fufu_cloud_cli-0.9.4}/src/fufu_cloud_cli/__init__.py +1 -1
- {fufu_cloud_cli-0.9.2 → fufu_cloud_cli-0.9.4}/src/fufu_cloud_cli/api_backend.py +6 -2
- {fufu_cloud_cli-0.9.2 → fufu_cloud_cli-0.9.4}/src/fufu_cloud_cli/capabilities.json +11 -3
- {fufu_cloud_cli-0.9.2 → fufu_cloud_cli-0.9.4}/src/fufu_cloud_cli/cli.py +61 -3
- {fufu_cloud_cli-0.9.2 → fufu_cloud_cli-0.9.4}/src/fufu_cloud_cli/control.py +3 -2
- fufu_cloud_cli-0.9.4/src/fufu_cloud_cli/gcloud_environment.py +53 -0
- fufu_cloud_cli-0.9.4/src/fufu_cloud_cli/gcloud_output.py +196 -0
- fufu_cloud_cli-0.9.4/src/fufu_cloud_cli/gcs.py +137 -0
- {fufu_cloud_cli-0.9.2 → fufu_cloud_cli-0.9.4}/src/fufu_cloud_cli/object_transfer.py +4 -3
- {fufu_cloud_cli-0.9.2 → fufu_cloud_cli-0.9.4}/src/fufu_cloud_cli/public_access.py +22 -2
- {fufu_cloud_cli-0.9.2 → fufu_cloud_cli-0.9.4}/src/fufu_cloud_cli/pubsub.py +30 -5
- {fufu_cloud_cli-0.9.2 → fufu_cloud_cli-0.9.4}/src/fufu_cloud_cli/remote_compute.py +6 -1
- {fufu_cloud_cli-0.9.2 → fufu_cloud_cli-0.9.4}/src/fufu_cloud_cli/terraform_bridge/google.py +5 -1
- {fufu_cloud_cli-0.9.2 → fufu_cloud_cli-0.9.4/src/fufu_cloud_cli.egg-info}/PKG-INFO +7 -4
- {fufu_cloud_cli-0.9.2 → fufu_cloud_cli-0.9.4}/src/fufu_cloud_cli.egg-info/SOURCES.txt +3 -0
- {fufu_cloud_cli-0.9.2 → fufu_cloud_cli-0.9.4}/LICENSE +0 -0
- {fufu_cloud_cli-0.9.2 → fufu_cloud_cli-0.9.4}/MANIFEST.in +0 -0
- {fufu_cloud_cli-0.9.2 → fufu_cloud_cli-0.9.4}/setup.cfg +0 -0
- {fufu_cloud_cli-0.9.2 → fufu_cloud_cli-0.9.4}/src/fufu_cloud_cli/__main__.py +0 -0
- {fufu_cloud_cli-0.9.2 → fufu_cloud_cli-0.9.4}/src/fufu_cloud_cli/capacity.py +0 -0
- {fufu_cloud_cli-0.9.2 → fufu_cloud_cli-0.9.4}/src/fufu_cloud_cli/cloud_data.py +0 -0
- {fufu_cloud_cli-0.9.2 → fufu_cloud_cli-0.9.4}/src/fufu_cloud_cli/cloudflare.py +0 -0
- {fufu_cloud_cli-0.9.2 → fufu_cloud_cli-0.9.4}/src/fufu_cloud_cli/docker_backend.py +0 -0
- {fufu_cloud_cli-0.9.2 → fufu_cloud_cli-0.9.4}/src/fufu_cloud_cli/errors.py +0 -0
- {fufu_cloud_cli-0.9.2 → fufu_cloud_cli-0.9.4}/src/fufu_cloud_cli/http_runtime.py +0 -0
- {fufu_cloud_cli-0.9.2 → fufu_cloud_cli-0.9.4}/src/fufu_cloud_cli/iac_state.py +0 -0
- {fufu_cloud_cli-0.9.2 → fufu_cloud_cli-0.9.4}/src/fufu_cloud_cli/lambda_adapter.py +0 -0
- {fufu_cloud_cli-0.9.2 → fufu_cloud_cli-0.9.4}/src/fufu_cloud_cli/limits.py +0 -0
- {fufu_cloud_cli-0.9.2 → fufu_cloud_cli-0.9.4}/src/fufu_cloud_cli/manifest.py +0 -0
- {fufu_cloud_cli-0.9.2 → fufu_cloud_cli-0.9.4}/src/fufu_cloud_cli/pulumi-endpoints.json +0 -0
- {fufu_cloud_cli-0.9.2 → fufu_cloud_cli-0.9.4}/src/fufu_cloud_cli/pulumi_bridge.py +0 -0
- {fufu_cloud_cli-0.9.2 → fufu_cloud_cli-0.9.4}/src/fufu_cloud_cli/runtime/functions/Dockerfile +0 -0
- {fufu_cloud_cli-0.9.2 → fufu_cloud_cli-0.9.4}/src/fufu_cloud_cli/runtime/ingress/Dockerfile +0 -0
- {fufu_cloud_cli-0.9.2 → fufu_cloud_cli-0.9.4}/src/fufu_cloud_cli/runtime/ingress/proxy.py +0 -0
- {fufu_cloud_cli-0.9.2 → fufu_cloud_cli-0.9.4}/src/fufu_cloud_cli/runtime_sdk.py +0 -0
- {fufu_cloud_cli-0.9.2 → fufu_cloud_cli-0.9.4}/src/fufu_cloud_cli/secrets.py +0 -0
- {fufu_cloud_cli-0.9.2 → fufu_cloud_cli-0.9.4}/src/fufu_cloud_cli/serverless.py +0 -0
- {fufu_cloud_cli-0.9.2 → fufu_cloud_cli-0.9.4}/src/fufu_cloud_cli/terraform_bridge/__init__.py +0 -0
- {fufu_cloud_cli-0.9.2 → fufu_cloud_cli-0.9.4}/src/fufu_cloud_cli/terraform_bridge/aws.py +0 -0
- {fufu_cloud_cli-0.9.2 → fufu_cloud_cli-0.9.4}/src/fufu_cloud_cli/terraform_bridge/aws_data.py +0 -0
- {fufu_cloud_cli-0.9.2 → fufu_cloud_cli-0.9.4}/src/fufu_cloud_cli/terraform_bridge/common.py +0 -0
- {fufu_cloud_cli-0.9.2 → fufu_cloud_cli-0.9.4}/src/fufu_cloud_cli/terraform_bridge/google_pubsub.py +0 -0
- {fufu_cloud_cli-0.9.2 → fufu_cloud_cli-0.9.4}/src/fufu_cloud_cli/terraform_bridge/remote_state.py +0 -0
- {fufu_cloud_cli-0.9.2 → fufu_cloud_cli-0.9.4}/src/fufu_cloud_cli/terraform_bridge/runner.py +0 -0
- {fufu_cloud_cli-0.9.2 → fufu_cloud_cli-0.9.4}/src/fufu_cloud_cli.egg-info/dependency_links.txt +0 -0
- {fufu_cloud_cli-0.9.2 → fufu_cloud_cli-0.9.4}/src/fufu_cloud_cli.egg-info/entry_points.txt +0 -0
- {fufu_cloud_cli-0.9.2 → fufu_cloud_cli-0.9.4}/src/fufu_cloud_cli.egg-info/requires.txt +0 -0
- {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.
|
|
3
|
+
Version: 0.9.4
|
|
4
4
|
Summary: FUFU Cloud CLI for tenant-scoped mock cloud development and deployment
|
|
5
5
|
License-Expression: LicenseRef-Proprietary
|
|
6
6
|
Requires-Python: >=3.10
|
|
@@ -22,7 +22,9 @@ Dynamic: license-file
|
|
|
22
22
|
`gcloud-fufu`、`az-fufu`、`wrangler-fufu`。這是 AWS、Google Cloud、Azure 與 Cloudflare 的**明確子集**,
|
|
23
23
|
只供 FUFU mock 環境使用;不會把部署轉送到正式雲端。
|
|
24
24
|
|
|
25
|
-
0.9.
|
|
25
|
+
0.9.4 新增 GCS generation/metageneration 條件、`storage objects describe/update/list`、8 KiB 自訂 metadata 與二進位 `cat`。競爭寫入、過期條件、CLI metadata 和官方 gcloud 輸出欄位已對照驗收;不相符條件回傳 `PRECONDITION_FAILED`/HTTP 412。新功能需要同步更新 FUFU Console、GCS 儲存映像和 Cloud Shell。儲存升級前須停寫並備份完整 volume;新 metadata 格式可讀舊資料,回滾須還原升級前備份。
|
|
26
|
+
|
|
27
|
+
0.9.3 的常用輸出投影、清單篩選/排序/limit、Cloud Run 設定更新、Service Account 啟停及 Pub/Sub attributes/ACK deadline 持續保留。僅明列子集經過官方 gcloud、小用量真實 GCP 與隔離 Docker 驗收;不表示完整 Google IAM、SDK/ADC 或自動擴縮相容。
|
|
26
28
|
|
|
27
29
|
0.9.1 統一所有 CLI API 請求的 `User-Agent: fufu-cloud-cli`,涵蓋物件傳輸、
|
|
28
30
|
Cloudflare 靜態檔案部署、IaC 遠端 state 與 Cloud Shell session,避免沿用
|
|
@@ -34,7 +36,7 @@ Python 預設識別而遇到 Cloudflare HTTP 403/1010。識別標頭不取代
|
|
|
34
36
|
## Cloudflare 前端部署與 R2
|
|
35
37
|
|
|
36
38
|
```bash
|
|
37
|
-
pip install 'fufu-cloud-cli==0.9.
|
|
39
|
+
pip install 'fufu-cloud-cli==0.9.4'
|
|
38
40
|
export FUFU_CLOUDFLARE_CREDENTIAL_FILE=/run/secrets/fufu-cloudflare.json
|
|
39
41
|
wrangler-fufu capabilities
|
|
40
42
|
wrangler-fufu pages project create frontend --production-branch main
|
|
@@ -205,7 +207,8 @@ CLI 無法判斷任意原始碼或 ZIP 內的資料是否可以分享。
|
|
|
205
207
|
| Cloud Run | 已核准 HTTP 容器或 API 自動建置 Python ASGI 來源;支援配置後的 HTTPS 入口、Invoker policy;無 autoscaling、traffic splitting、Jobs |
|
|
206
208
|
| Cloud Run functions / Functions Gen 1 | Python 3.12 HTTP 函式;Gen 1 為相同 HTTP runtime 的模擬子集,無背景事件 |
|
|
207
209
|
| AWS Lambda | Python 3.10 / 3.12 ZIP、同步 invoke、程式碼更新、BUFFERED Function URL;ZIP 50 MiB/解壓 250 MiB,同步 payload 6 MiB |
|
|
208
|
-
| GCS
|
|
210
|
+
| GCS | bucket/object 子集、分段上傳/串流下載/binary cat、generation/metageneration 條件、metadata describe/update、前綴分頁;8 KiB custom metadata;無 versioning、signed URL、bucket IAM、一般 SDK 或 GCS IaC 資源 |
|
|
211
|
+
| S3 | bucket 與 object 子集;分段上傳/串流下載,受主機磁碟限制;已驗證超過 1 GiB,無 versioning / signed URL |
|
|
209
212
|
| DynamoDB / SQS / SSM | 明列資料操作;無完整交易、Streams、FIFO 或 KMS / SecureString |
|
|
210
213
|
| AWS IAM / GCP IAM | 租戶內 role / service account / policy 子集;default deny、限定 session;無原生雲端全量 IAM 語義 |
|
|
211
214
|
| API Gateway / Serverless profile | 簡化 HTTP Lambda proxy 和明確來源清單;不執行 plugins / CloudFormation |
|
|
@@ -4,7 +4,9 @@
|
|
|
4
4
|
`gcloud-fufu`、`az-fufu`、`wrangler-fufu`。這是 AWS、Google Cloud、Azure 與 Cloudflare 的**明確子集**,
|
|
5
5
|
只供 FUFU mock 環境使用;不會把部署轉送到正式雲端。
|
|
6
6
|
|
|
7
|
-
0.9.
|
|
7
|
+
0.9.4 新增 GCS generation/metageneration 條件、`storage objects describe/update/list`、8 KiB 自訂 metadata 與二進位 `cat`。競爭寫入、過期條件、CLI metadata 和官方 gcloud 輸出欄位已對照驗收;不相符條件回傳 `PRECONDITION_FAILED`/HTTP 412。新功能需要同步更新 FUFU Console、GCS 儲存映像和 Cloud Shell。儲存升級前須停寫並備份完整 volume;新 metadata 格式可讀舊資料,回滾須還原升級前備份。
|
|
8
|
+
|
|
9
|
+
0.9.3 的常用輸出投影、清單篩選/排序/limit、Cloud Run 設定更新、Service Account 啟停及 Pub/Sub attributes/ACK deadline 持續保留。僅明列子集經過官方 gcloud、小用量真實 GCP 與隔離 Docker 驗收;不表示完整 Google IAM、SDK/ADC 或自動擴縮相容。
|
|
8
10
|
|
|
9
11
|
0.9.1 統一所有 CLI API 請求的 `User-Agent: fufu-cloud-cli`,涵蓋物件傳輸、
|
|
10
12
|
Cloudflare 靜態檔案部署、IaC 遠端 state 與 Cloud Shell session,避免沿用
|
|
@@ -16,7 +18,7 @@ Python 預設識別而遇到 Cloudflare HTTP 403/1010。識別標頭不取代
|
|
|
16
18
|
## Cloudflare 前端部署與 R2
|
|
17
19
|
|
|
18
20
|
```bash
|
|
19
|
-
pip install 'fufu-cloud-cli==0.9.
|
|
21
|
+
pip install 'fufu-cloud-cli==0.9.4'
|
|
20
22
|
export FUFU_CLOUDFLARE_CREDENTIAL_FILE=/run/secrets/fufu-cloudflare.json
|
|
21
23
|
wrangler-fufu capabilities
|
|
22
24
|
wrangler-fufu pages project create frontend --production-branch main
|
|
@@ -187,7 +189,8 @@ CLI 無法判斷任意原始碼或 ZIP 內的資料是否可以分享。
|
|
|
187
189
|
| Cloud Run | 已核准 HTTP 容器或 API 自動建置 Python ASGI 來源;支援配置後的 HTTPS 入口、Invoker policy;無 autoscaling、traffic splitting、Jobs |
|
|
188
190
|
| Cloud Run functions / Functions Gen 1 | Python 3.12 HTTP 函式;Gen 1 為相同 HTTP runtime 的模擬子集,無背景事件 |
|
|
189
191
|
| AWS Lambda | Python 3.10 / 3.12 ZIP、同步 invoke、程式碼更新、BUFFERED Function URL;ZIP 50 MiB/解壓 250 MiB,同步 payload 6 MiB |
|
|
190
|
-
| GCS
|
|
192
|
+
| GCS | bucket/object 子集、分段上傳/串流下載/binary cat、generation/metageneration 條件、metadata describe/update、前綴分頁;8 KiB custom metadata;無 versioning、signed URL、bucket IAM、一般 SDK 或 GCS IaC 資源 |
|
|
193
|
+
| S3 | bucket 與 object 子集;分段上傳/串流下載,受主機磁碟限制;已驗證超過 1 GiB,無 versioning / signed URL |
|
|
191
194
|
| DynamoDB / SQS / SSM | 明列資料操作;無完整交易、Streams、FIFO 或 KMS / SecureString |
|
|
192
195
|
| AWS IAM / GCP IAM | 租戶內 role / service account / policy 子集;default deny、限定 session;無原生雲端全量 IAM 語義 |
|
|
193
196
|
| API Gateway / Serverless profile | 簡化 HTTP Lambda proxy 和明確來源清單;不執行 plugins / CloudFormation |
|
|
@@ -185,7 +185,7 @@ class ApiClient:
|
|
|
185
185
|
except HTTPError as exc:
|
|
186
186
|
status = exc.code
|
|
187
187
|
exc.close()
|
|
188
|
-
codes = {401: "AUTHENTICATION_FAILED", 403: "ACCESS_DENIED", 404: "RESOURCE_NOT_FOUND", 409: "RESOURCE_CONFLICT", 410: "UPLOAD_EXPIRED", 413: "PAYLOAD_LIMIT", 429: "RATE_LIMITED", 507: "STORAGE_CAPACITY"}
|
|
188
|
+
codes = {401: "AUTHENTICATION_FAILED", 403: "ACCESS_DENIED", 404: "RESOURCE_NOT_FOUND", 409: "RESOURCE_CONFLICT", 410: "UPLOAD_EXPIRED", 412: "PRECONDITION_FAILED", 413: "PAYLOAD_LIMIT", 429: "RATE_LIMITED", 507: "STORAGE_CAPACITY"}
|
|
189
189
|
if status == 507: raise FufuError('STORAGE_CAPACITY', 'FUFU 主機磁碟容量不足;這是目前可用空間的限制,並非雲端物件大小上限。') from None
|
|
190
190
|
raise FufuError(codes.get(status, "API_REQUEST_FAILED"), f"FUFU API 拒絕請求(HTTP {status});未輸出憑證或原始回應。") from None
|
|
191
191
|
except (URLError, TimeoutError, ConnectionError) as exc:
|
|
@@ -211,7 +211,8 @@ class ApiClient:
|
|
|
211
211
|
return json.loads(response.read(65537))
|
|
212
212
|
except HTTPError as error:
|
|
213
213
|
status = error.code; error.close()
|
|
214
|
-
|
|
214
|
+
code = 'PRECONDITION_FAILED' if self.provider == 'gcp' and status == 412 else 'OBJECT_TRANSFER_FAILED'
|
|
215
|
+
raise FufuError(code, f'物件傳輸未完成(HTTP {status});未覆寫下載目的地。') from None
|
|
215
216
|
|
|
216
217
|
def close(self):
|
|
217
218
|
if self.token and self.owns_token:
|
|
@@ -243,6 +244,9 @@ def storage_path(value):
|
|
|
243
244
|
|
|
244
245
|
|
|
245
246
|
def remote_command(provider, argv, Parser):
|
|
247
|
+
if provider == 'gcp' and argv[:1] == ['storage']:
|
|
248
|
+
from .gcs import command
|
|
249
|
+
return command(argv[1:], Parser)
|
|
246
250
|
p = Parser(prog={"aws": "aws-fufu", "gcp": "gcloud-fufu", "azure": "az-fufu"}[provider],
|
|
247
251
|
description="FUFU API 憑證與租戶資源操作子集;無原生雲端 IAM/部署相容承諾。")
|
|
248
252
|
roots = p.add_subparsers(dest="service", required=True)
|
|
@@ -188,16 +188,24 @@
|
|
|
188
188
|
"operations": [
|
|
189
189
|
"bucket-create-list-delete",
|
|
190
190
|
"binary-object-upload-download-list-delete",
|
|
191
|
+
"generation-match-preconditions",
|
|
192
|
+
"metageneration-match-preconditions",
|
|
193
|
+
"metadata-describe-update-merge-delete",
|
|
194
|
+
"paginated-object-list",
|
|
195
|
+
"binary-cat",
|
|
191
196
|
"UTF8-preview"
|
|
192
197
|
],
|
|
193
198
|
"limitations": [
|
|
194
199
|
"object-5TiB-policy-subject-to-host-disk-capacity",
|
|
195
200
|
"chunked-upload-streaming-download",
|
|
201
|
+
"custom-metadata-8KiB",
|
|
196
202
|
"filesystem-object-name-subset",
|
|
197
|
-
"preview-32KiB",
|
|
198
|
-
"
|
|
203
|
+
"UI-preview-32KiB-CLI-cat-streams",
|
|
204
|
+
"requires-generation-metadata-storage-backend",
|
|
205
|
+
"single-writer-per-volume",
|
|
199
206
|
"no-signed-URLs",
|
|
200
|
-
"no-versioning"
|
|
207
|
+
"no-versioning-retention-ACL-KMS",
|
|
208
|
+
"no-GCS-Terraform-or-Pulumi-resource-adapter"
|
|
201
209
|
]
|
|
202
210
|
},
|
|
203
211
|
"azure-catalog-api": {
|
|
@@ -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'), ('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': '
|
|
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
|
-
|
|
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.4
|
|
4
4
|
Summary: FUFU Cloud CLI for tenant-scoped mock cloud development and deployment
|
|
5
5
|
License-Expression: LicenseRef-Proprietary
|
|
6
6
|
Requires-Python: >=3.10
|
|
@@ -22,7 +22,9 @@ Dynamic: license-file
|
|
|
22
22
|
`gcloud-fufu`、`az-fufu`、`wrangler-fufu`。這是 AWS、Google Cloud、Azure 與 Cloudflare 的**明確子集**,
|
|
23
23
|
只供 FUFU mock 環境使用;不會把部署轉送到正式雲端。
|
|
24
24
|
|
|
25
|
-
0.9.
|
|
25
|
+
0.9.4 新增 GCS generation/metageneration 條件、`storage objects describe/update/list`、8 KiB 自訂 metadata 與二進位 `cat`。競爭寫入、過期條件、CLI metadata 和官方 gcloud 輸出欄位已對照驗收;不相符條件回傳 `PRECONDITION_FAILED`/HTTP 412。新功能需要同步更新 FUFU Console、GCS 儲存映像和 Cloud Shell。儲存升級前須停寫並備份完整 volume;新 metadata 格式可讀舊資料,回滾須還原升級前備份。
|
|
26
|
+
|
|
27
|
+
0.9.3 的常用輸出投影、清單篩選/排序/limit、Cloud Run 設定更新、Service Account 啟停及 Pub/Sub attributes/ACK deadline 持續保留。僅明列子集經過官方 gcloud、小用量真實 GCP 與隔離 Docker 驗收;不表示完整 Google IAM、SDK/ADC 或自動擴縮相容。
|
|
26
28
|
|
|
27
29
|
0.9.1 統一所有 CLI API 請求的 `User-Agent: fufu-cloud-cli`,涵蓋物件傳輸、
|
|
28
30
|
Cloudflare 靜態檔案部署、IaC 遠端 state 與 Cloud Shell session,避免沿用
|
|
@@ -34,7 +36,7 @@ Python 預設識別而遇到 Cloudflare HTTP 403/1010。識別標頭不取代
|
|
|
34
36
|
## Cloudflare 前端部署與 R2
|
|
35
37
|
|
|
36
38
|
```bash
|
|
37
|
-
pip install 'fufu-cloud-cli==0.9.
|
|
39
|
+
pip install 'fufu-cloud-cli==0.9.4'
|
|
38
40
|
export FUFU_CLOUDFLARE_CREDENTIAL_FILE=/run/secrets/fufu-cloudflare.json
|
|
39
41
|
wrangler-fufu capabilities
|
|
40
42
|
wrangler-fufu pages project create frontend --production-branch main
|
|
@@ -205,7 +207,8 @@ CLI 無法判斷任意原始碼或 ZIP 內的資料是否可以分享。
|
|
|
205
207
|
| Cloud Run | 已核准 HTTP 容器或 API 自動建置 Python ASGI 來源;支援配置後的 HTTPS 入口、Invoker policy;無 autoscaling、traffic splitting、Jobs |
|
|
206
208
|
| Cloud Run functions / Functions Gen 1 | Python 3.12 HTTP 函式;Gen 1 為相同 HTTP runtime 的模擬子集,無背景事件 |
|
|
207
209
|
| AWS Lambda | Python 3.10 / 3.12 ZIP、同步 invoke、程式碼更新、BUFFERED Function URL;ZIP 50 MiB/解壓 250 MiB,同步 payload 6 MiB |
|
|
208
|
-
| GCS
|
|
210
|
+
| GCS | bucket/object 子集、分段上傳/串流下載/binary cat、generation/metageneration 條件、metadata describe/update、前綴分頁;8 KiB custom metadata;無 versioning、signed URL、bucket IAM、一般 SDK 或 GCS IaC 資源 |
|
|
211
|
+
| S3 | bucket 與 object 子集;分段上傳/串流下載,受主機磁碟限制;已驗證超過 1 GiB,無 versioning / signed URL |
|
|
209
212
|
| DynamoDB / SQS / SSM | 明列資料操作;無完整交易、Streams、FIFO 或 KMS / SecureString |
|
|
210
213
|
| AWS IAM / GCP IAM | 租戶內 role / service account / policy 子集;default deny、限定 session;無原生雲端全量 IAM 語義 |
|
|
211
214
|
| API Gateway / Serverless profile | 簡化 HTTP Lambda proxy 和明確來源清單;不執行 plugins / CloudFormation |
|
|
@@ -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
|
|
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.4}/src/fufu_cloud_cli/runtime/functions/Dockerfile
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{fufu_cloud_cli-0.9.2 → fufu_cloud_cli-0.9.4}/src/fufu_cloud_cli/terraform_bridge/__init__.py
RENAMED
|
File without changes
|
|
File without changes
|
{fufu_cloud_cli-0.9.2 → fufu_cloud_cli-0.9.4}/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.4}/src/fufu_cloud_cli/terraform_bridge/google_pubsub.py
RENAMED
|
File without changes
|
{fufu_cloud_cli-0.9.2 → fufu_cloud_cli-0.9.4}/src/fufu_cloud_cli/terraform_bridge/remote_state.py
RENAMED
|
File without changes
|
|
File without changes
|
{fufu_cloud_cli-0.9.2 → fufu_cloud_cli-0.9.4}/src/fufu_cloud_cli.egg-info/dependency_links.txt
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|