fufu-cloud-cli 0.9.4__tar.gz → 0.9.5__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.4 → fufu_cloud_cli-0.9.5}/PKG-INFO +40 -10
- fufu_cloud_cli-0.9.4/src/fufu_cloud_cli.egg-info/PKG-INFO → fufu_cloud_cli-0.9.5/README.md +35 -27
- {fufu_cloud_cli-0.9.4 → fufu_cloud_cli-0.9.5}/pyproject.toml +3 -1
- {fufu_cloud_cli-0.9.4 → fufu_cloud_cli-0.9.5}/src/fufu_cloud_cli/__init__.py +1 -1
- {fufu_cloud_cli-0.9.4 → fufu_cloud_cli-0.9.5}/src/fufu_cloud_cli/__main__.py +1 -1
- fufu_cloud_cli-0.9.5/src/fufu_cloud_cli/bigquery.py +92 -0
- {fufu_cloud_cli-0.9.4 → fufu_cloud_cli-0.9.5}/src/fufu_cloud_cli/capabilities.json +159 -18
- {fufu_cloud_cli-0.9.4 → fufu_cloud_cli-0.9.5}/src/fufu_cloud_cli/cli.py +25 -3
- fufu_cloud_cli-0.9.5/src/fufu_cloud_cli/gcp_infra.py +222 -0
- fufu_cloud_cli-0.9.5/src/fufu_cloud_cli/gcp_sdk.py +60 -0
- fufu_cloud_cli-0.9.4/README.md → fufu_cloud_cli-0.9.5/src/fufu_cloud_cli.egg-info/PKG-INFO +57 -9
- {fufu_cloud_cli-0.9.4 → fufu_cloud_cli-0.9.5}/src/fufu_cloud_cli.egg-info/SOURCES.txt +3 -0
- {fufu_cloud_cli-0.9.4 → fufu_cloud_cli-0.9.5}/src/fufu_cloud_cli.egg-info/entry_points.txt +1 -0
- {fufu_cloud_cli-0.9.4 → fufu_cloud_cli-0.9.5}/src/fufu_cloud_cli.egg-info/requires.txt +5 -0
- {fufu_cloud_cli-0.9.4 → fufu_cloud_cli-0.9.5}/LICENSE +0 -0
- {fufu_cloud_cli-0.9.4 → fufu_cloud_cli-0.9.5}/MANIFEST.in +0 -0
- {fufu_cloud_cli-0.9.4 → fufu_cloud_cli-0.9.5}/setup.cfg +0 -0
- {fufu_cloud_cli-0.9.4 → fufu_cloud_cli-0.9.5}/src/fufu_cloud_cli/api_backend.py +0 -0
- {fufu_cloud_cli-0.9.4 → fufu_cloud_cli-0.9.5}/src/fufu_cloud_cli/capacity.py +0 -0
- {fufu_cloud_cli-0.9.4 → fufu_cloud_cli-0.9.5}/src/fufu_cloud_cli/cloud_data.py +0 -0
- {fufu_cloud_cli-0.9.4 → fufu_cloud_cli-0.9.5}/src/fufu_cloud_cli/cloudflare.py +0 -0
- {fufu_cloud_cli-0.9.4 → fufu_cloud_cli-0.9.5}/src/fufu_cloud_cli/control.py +0 -0
- {fufu_cloud_cli-0.9.4 → fufu_cloud_cli-0.9.5}/src/fufu_cloud_cli/docker_backend.py +0 -0
- {fufu_cloud_cli-0.9.4 → fufu_cloud_cli-0.9.5}/src/fufu_cloud_cli/errors.py +0 -0
- {fufu_cloud_cli-0.9.4 → fufu_cloud_cli-0.9.5}/src/fufu_cloud_cli/gcloud_environment.py +0 -0
- {fufu_cloud_cli-0.9.4 → fufu_cloud_cli-0.9.5}/src/fufu_cloud_cli/gcloud_output.py +0 -0
- {fufu_cloud_cli-0.9.4 → fufu_cloud_cli-0.9.5}/src/fufu_cloud_cli/gcs.py +0 -0
- {fufu_cloud_cli-0.9.4 → fufu_cloud_cli-0.9.5}/src/fufu_cloud_cli/http_runtime.py +0 -0
- {fufu_cloud_cli-0.9.4 → fufu_cloud_cli-0.9.5}/src/fufu_cloud_cli/iac_state.py +0 -0
- {fufu_cloud_cli-0.9.4 → fufu_cloud_cli-0.9.5}/src/fufu_cloud_cli/lambda_adapter.py +0 -0
- {fufu_cloud_cli-0.9.4 → fufu_cloud_cli-0.9.5}/src/fufu_cloud_cli/limits.py +0 -0
- {fufu_cloud_cli-0.9.4 → fufu_cloud_cli-0.9.5}/src/fufu_cloud_cli/manifest.py +0 -0
- {fufu_cloud_cli-0.9.4 → fufu_cloud_cli-0.9.5}/src/fufu_cloud_cli/object_transfer.py +0 -0
- {fufu_cloud_cli-0.9.4 → fufu_cloud_cli-0.9.5}/src/fufu_cloud_cli/public_access.py +0 -0
- {fufu_cloud_cli-0.9.4 → fufu_cloud_cli-0.9.5}/src/fufu_cloud_cli/pubsub.py +0 -0
- {fufu_cloud_cli-0.9.4 → fufu_cloud_cli-0.9.5}/src/fufu_cloud_cli/pulumi-endpoints.json +0 -0
- {fufu_cloud_cli-0.9.4 → fufu_cloud_cli-0.9.5}/src/fufu_cloud_cli/pulumi_bridge.py +0 -0
- {fufu_cloud_cli-0.9.4 → fufu_cloud_cli-0.9.5}/src/fufu_cloud_cli/remote_compute.py +0 -0
- {fufu_cloud_cli-0.9.4 → fufu_cloud_cli-0.9.5}/src/fufu_cloud_cli/runtime/functions/Dockerfile +0 -0
- {fufu_cloud_cli-0.9.4 → fufu_cloud_cli-0.9.5}/src/fufu_cloud_cli/runtime/ingress/Dockerfile +0 -0
- {fufu_cloud_cli-0.9.4 → fufu_cloud_cli-0.9.5}/src/fufu_cloud_cli/runtime/ingress/proxy.py +0 -0
- {fufu_cloud_cli-0.9.4 → fufu_cloud_cli-0.9.5}/src/fufu_cloud_cli/runtime_sdk.py +0 -0
- {fufu_cloud_cli-0.9.4 → fufu_cloud_cli-0.9.5}/src/fufu_cloud_cli/secrets.py +0 -0
- {fufu_cloud_cli-0.9.4 → fufu_cloud_cli-0.9.5}/src/fufu_cloud_cli/serverless.py +0 -0
- {fufu_cloud_cli-0.9.4 → fufu_cloud_cli-0.9.5}/src/fufu_cloud_cli/terraform_bridge/__init__.py +0 -0
- {fufu_cloud_cli-0.9.4 → fufu_cloud_cli-0.9.5}/src/fufu_cloud_cli/terraform_bridge/aws.py +0 -0
- {fufu_cloud_cli-0.9.4 → fufu_cloud_cli-0.9.5}/src/fufu_cloud_cli/terraform_bridge/aws_data.py +0 -0
- {fufu_cloud_cli-0.9.4 → fufu_cloud_cli-0.9.5}/src/fufu_cloud_cli/terraform_bridge/common.py +0 -0
- {fufu_cloud_cli-0.9.4 → fufu_cloud_cli-0.9.5}/src/fufu_cloud_cli/terraform_bridge/google.py +0 -0
- {fufu_cloud_cli-0.9.4 → fufu_cloud_cli-0.9.5}/src/fufu_cloud_cli/terraform_bridge/google_pubsub.py +0 -0
- {fufu_cloud_cli-0.9.4 → fufu_cloud_cli-0.9.5}/src/fufu_cloud_cli/terraform_bridge/remote_state.py +0 -0
- {fufu_cloud_cli-0.9.4 → fufu_cloud_cli-0.9.5}/src/fufu_cloud_cli/terraform_bridge/runner.py +0 -0
- {fufu_cloud_cli-0.9.4 → fufu_cloud_cli-0.9.5}/src/fufu_cloud_cli.egg-info/dependency_links.txt +0 -0
- {fufu_cloud_cli-0.9.4 → fufu_cloud_cli-0.9.5}/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.5
|
|
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
|
|
@@ -14,14 +14,43 @@ Provides-Extra: test
|
|
|
14
14
|
Requires-Dist: boto3==1.40.66; extra == "test"
|
|
15
15
|
Provides-Extra: terraform
|
|
16
16
|
Requires-Dist: python-hcl2==7.3.1; extra == "terraform"
|
|
17
|
+
Provides-Extra: gcp
|
|
18
|
+
Requires-Dist: google-cloud-bigquery==3.38.0; extra == "gcp"
|
|
19
|
+
Requires-Dist: google-cloud-tasks==2.20.0; extra == "gcp"
|
|
20
|
+
Requires-Dist: google-cloud-logging==3.12.1; extra == "gcp"
|
|
17
21
|
Dynamic: license-file
|
|
18
22
|
|
|
19
23
|
# FUFU Cloud CLI
|
|
20
24
|
|
|
21
|
-
|
|
22
|
-
`gcloud-fufu`、`az-fufu`、`wrangler-fufu`。這是 AWS、Google Cloud、Azure 與 Cloudflare 的**明確子集**,
|
|
25
|
+
以六個指令操作 FUFU Cloud 的租戶隔離開發環境:`fufu`、`aws-fufu`、
|
|
26
|
+
`gcloud-fufu`、`az-fufu`、`wrangler-fufu`、`bq-fufu`。這是 AWS、Google Cloud、Azure 與 Cloudflare 的**明確子集**,
|
|
23
27
|
只供 FUFU mock 環境使用;不會把部署轉送到正式雲端。
|
|
24
28
|
|
|
29
|
+
0.9.5 新增 `gcloud-fufu scheduler`、`tasks`、`logging` 與 `bq-fufu`。
|
|
30
|
+
支援同租戶已登錄 HTTP 應用的排程/背景任務、BigQuery dataset/table、
|
|
31
|
+
insertAll/SELECT 與持久化日誌。`fufu_cloud_cli.gcp_sdk` 提供官方 Python
|
|
32
|
+
BigQuery、Cloud Tasks、Logging SDK 的明確 REST factory;可用 `[gcp]` extra
|
|
33
|
+
安裝已驗收相依。需要對應的新後端,並非透明 ADC 或所有 Google SDK 替換。
|
|
34
|
+
|
|
35
|
+
`fufu infrastructure` 提供 14 類 typed configuration mock。Compute Engine、
|
|
36
|
+
Cloud SQL、App Engine、DNS、VPC、Artifact Registry、Cloud Build、Gemini、
|
|
37
|
+
VM Manager、Eventarc 等設定不會啟動 VM/SQL/registry/網路/推論。
|
|
38
|
+
UI 顯示僅設定,CLI stderr 保留 MOCK ONLY,不因輸出投影而隱藏。
|
|
39
|
+
新服務尚未接入 Terraform/OpenTofu/Pulumi;單機背景投遞不是雲端 HA。
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
gcloud-fufu scheduler jobs list
|
|
43
|
+
gcloud-fufu tasks queues list
|
|
44
|
+
bq-fufu query --use_legacy_sql=false 'SELECT 1 AS ready'
|
|
45
|
+
gcloud-fufu logging read 'severity>=ERROR'
|
|
46
|
+
fufu infrastructure catalog
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
BigQuery 是 tenant emulator 的 GoogleSQL 子集,無 DML/DDL、load/export、
|
|
50
|
+
外部表與效能/費用等價。Logging 保留每租戶最新 10,000 筆,不自動收集所有
|
|
51
|
+
容器 stdout。Tasks 每 queue 每秒最多 1 次、每租戶 2 個同時投遞;耗盡 task
|
|
52
|
+
留待檢查,與官方刪除策略不同。新的 dependency flow 不代表完整應用都能遷移。
|
|
53
|
+
|
|
25
54
|
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
55
|
|
|
27
56
|
0.9.3 的常用輸出投影、清單篩選/排序/limit、Cloud Run 設定更新、Service Account 啟停及 Pub/Sub attributes/ACK deadline 持續保留。僅明列子集經過官方 gcloud、小用量真實 GCP 與隔離 Docker 驗收;不表示完整 Google IAM、SDK/ADC 或自動擴縮相容。
|
|
@@ -36,7 +65,7 @@ Python 預設識別而遇到 Cloudflare HTTP 403/1010。識別標頭不取代
|
|
|
36
65
|
## Cloudflare 前端部署與 R2
|
|
37
66
|
|
|
38
67
|
```bash
|
|
39
|
-
pip install 'fufu-cloud-cli==0.9.
|
|
68
|
+
pip install 'fufu-cloud-cli==0.9.5'
|
|
40
69
|
export FUFU_CLOUDFLARE_CREDENTIAL_FILE=/run/secrets/fufu-cloudflare.json
|
|
41
70
|
wrangler-fufu capabilities
|
|
42
71
|
wrangler-fufu pages project create frontend --production-branch main
|
|
@@ -80,7 +109,8 @@ pip install fufu-cloud-cli
|
|
|
80
109
|
fufu --version
|
|
81
110
|
```
|
|
82
111
|
|
|
83
|
-
npm
|
|
112
|
+
npm registry 尚未發布;以下是 registry 可用後的安裝語法。目前可安裝經驗收
|
|
113
|
+
的 npm tarball。npm 版本需要 Node.js 18 以上,以及已安裝的 Python 3.10 以上;各入口
|
|
84
114
|
呼叫相同的 Python CLI。0.8.0 的 npm 建置包內附純 Python YAML/TOML parser,沒有安裝腳本,
|
|
85
115
|
不會自行下載 Python 或執行 pip。
|
|
86
116
|
|
|
@@ -97,12 +127,12 @@ npx --no-install aws-fufu --help
|
|
|
97
127
|
|
|
98
128
|
## 憑證與 CI
|
|
99
129
|
|
|
100
|
-
|
|
130
|
+
目前版本另提供 `fufu tofu`、`fufu pulumi`、`fufu state`。
|
|
101
131
|
Terraform/OpenTofu 可選擇租戶遠端 HTTP state,具備鎖、加密、版本與
|
|
102
132
|
復原;需配套 Console API 和平台金鑰。Pulumi 使用固定官方 Node.js SDK,
|
|
103
133
|
已驗收 SQS、SSM String、Pub/Sub topic/pull subscription,採獨立本機
|
|
104
|
-
DIY backend,秘密欄位需外部 passphrase
|
|
105
|
-
|
|
134
|
+
DIY backend,秘密欄位需外部 passphrase。這些能力從 0.9.0 起納入 CLI,
|
|
135
|
+
仍需要配套服務端;不能把套件安裝成功當成支援驗收。
|
|
106
136
|
|
|
107
137
|
此工作樹也提供 `fufu terraform`:以官方 AWS/Google Terraform provider
|
|
108
138
|
管理 Lambda ZIP、Cloud Run v2、IAM 與服務入口的明列子集,並使用獨立的
|
|
@@ -112,9 +142,9 @@ GCS/S3、Functions Gen 1/Gen 2 等一般 CLI 能力
|
|
|
112
142
|
不表示官方 Terraform 資源已相容。正式環境使用正常 Terraform、正式認證
|
|
113
143
|
與獨立 state,不可直接搬用 FUFU state。
|
|
114
144
|
|
|
115
|
-
|
|
145
|
+
目前版本另接入 SQS queue、SSM String parameter、Pub/Sub topic/pull
|
|
116
146
|
subscription;新增能力需要配套的 Console 與 Pub/Sub 後端更新,不能從
|
|
117
|
-
|
|
147
|
+
CLI 安裝成功就推定服務端已具備。`gcloud-fufu pubsub` 提供建立、
|
|
118
148
|
查詢、更新、刪除、publish/pull/ack 子集;不支援 push、ordering、
|
|
119
149
|
exactly-once、Function trigger 或保留期限保證。
|
|
120
150
|
|
|
@@ -1,27 +1,34 @@
|
|
|
1
|
-
Metadata-Version: 2.4
|
|
2
|
-
Name: fufu-cloud-cli
|
|
3
|
-
Version: 0.9.4
|
|
4
|
-
Summary: FUFU Cloud CLI for tenant-scoped mock cloud development and deployment
|
|
5
|
-
License-Expression: LicenseRef-Proprietary
|
|
6
|
-
Requires-Python: >=3.10
|
|
7
|
-
Description-Content-Type: text/markdown
|
|
8
|
-
License-File: LICENSE
|
|
9
|
-
Requires-Dist: PyYAML==6.0.3
|
|
10
|
-
Requires-Dist: tomli==2.2.1; python_version < "3.11"
|
|
11
|
-
Provides-Extra: yaml
|
|
12
|
-
Requires-Dist: PyYAML==6.0.3; extra == "yaml"
|
|
13
|
-
Provides-Extra: test
|
|
14
|
-
Requires-Dist: boto3==1.40.66; extra == "test"
|
|
15
|
-
Provides-Extra: terraform
|
|
16
|
-
Requires-Dist: python-hcl2==7.3.1; extra == "terraform"
|
|
17
|
-
Dynamic: license-file
|
|
18
|
-
|
|
19
1
|
# FUFU Cloud CLI
|
|
20
2
|
|
|
21
|
-
|
|
22
|
-
`gcloud-fufu`、`az-fufu`、`wrangler-fufu`。這是 AWS、Google Cloud、Azure 與 Cloudflare 的**明確子集**,
|
|
3
|
+
以六個指令操作 FUFU Cloud 的租戶隔離開發環境:`fufu`、`aws-fufu`、
|
|
4
|
+
`gcloud-fufu`、`az-fufu`、`wrangler-fufu`、`bq-fufu`。這是 AWS、Google Cloud、Azure 與 Cloudflare 的**明確子集**,
|
|
23
5
|
只供 FUFU mock 環境使用;不會把部署轉送到正式雲端。
|
|
24
6
|
|
|
7
|
+
0.9.5 新增 `gcloud-fufu scheduler`、`tasks`、`logging` 與 `bq-fufu`。
|
|
8
|
+
支援同租戶已登錄 HTTP 應用的排程/背景任務、BigQuery dataset/table、
|
|
9
|
+
insertAll/SELECT 與持久化日誌。`fufu_cloud_cli.gcp_sdk` 提供官方 Python
|
|
10
|
+
BigQuery、Cloud Tasks、Logging SDK 的明確 REST factory;可用 `[gcp]` extra
|
|
11
|
+
安裝已驗收相依。需要對應的新後端,並非透明 ADC 或所有 Google SDK 替換。
|
|
12
|
+
|
|
13
|
+
`fufu infrastructure` 提供 14 類 typed configuration mock。Compute Engine、
|
|
14
|
+
Cloud SQL、App Engine、DNS、VPC、Artifact Registry、Cloud Build、Gemini、
|
|
15
|
+
VM Manager、Eventarc 等設定不會啟動 VM/SQL/registry/網路/推論。
|
|
16
|
+
UI 顯示僅設定,CLI stderr 保留 MOCK ONLY,不因輸出投影而隱藏。
|
|
17
|
+
新服務尚未接入 Terraform/OpenTofu/Pulumi;單機背景投遞不是雲端 HA。
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
gcloud-fufu scheduler jobs list
|
|
21
|
+
gcloud-fufu tasks queues list
|
|
22
|
+
bq-fufu query --use_legacy_sql=false 'SELECT 1 AS ready'
|
|
23
|
+
gcloud-fufu logging read 'severity>=ERROR'
|
|
24
|
+
fufu infrastructure catalog
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
BigQuery 是 tenant emulator 的 GoogleSQL 子集,無 DML/DDL、load/export、
|
|
28
|
+
外部表與效能/費用等價。Logging 保留每租戶最新 10,000 筆,不自動收集所有
|
|
29
|
+
容器 stdout。Tasks 每 queue 每秒最多 1 次、每租戶 2 個同時投遞;耗盡 task
|
|
30
|
+
留待檢查,與官方刪除策略不同。新的 dependency flow 不代表完整應用都能遷移。
|
|
31
|
+
|
|
25
32
|
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
33
|
|
|
27
34
|
0.9.3 的常用輸出投影、清單篩選/排序/limit、Cloud Run 設定更新、Service Account 啟停及 Pub/Sub attributes/ACK deadline 持續保留。僅明列子集經過官方 gcloud、小用量真實 GCP 與隔離 Docker 驗收;不表示完整 Google IAM、SDK/ADC 或自動擴縮相容。
|
|
@@ -36,7 +43,7 @@ Python 預設識別而遇到 Cloudflare HTTP 403/1010。識別標頭不取代
|
|
|
36
43
|
## Cloudflare 前端部署與 R2
|
|
37
44
|
|
|
38
45
|
```bash
|
|
39
|
-
pip install 'fufu-cloud-cli==0.9.
|
|
46
|
+
pip install 'fufu-cloud-cli==0.9.5'
|
|
40
47
|
export FUFU_CLOUDFLARE_CREDENTIAL_FILE=/run/secrets/fufu-cloudflare.json
|
|
41
48
|
wrangler-fufu capabilities
|
|
42
49
|
wrangler-fufu pages project create frontend --production-branch main
|
|
@@ -80,7 +87,8 @@ pip install fufu-cloud-cli
|
|
|
80
87
|
fufu --version
|
|
81
88
|
```
|
|
82
89
|
|
|
83
|
-
npm
|
|
90
|
+
npm registry 尚未發布;以下是 registry 可用後的安裝語法。目前可安裝經驗收
|
|
91
|
+
的 npm tarball。npm 版本需要 Node.js 18 以上,以及已安裝的 Python 3.10 以上;各入口
|
|
84
92
|
呼叫相同的 Python CLI。0.8.0 的 npm 建置包內附純 Python YAML/TOML parser,沒有安裝腳本,
|
|
85
93
|
不會自行下載 Python 或執行 pip。
|
|
86
94
|
|
|
@@ -97,12 +105,12 @@ npx --no-install aws-fufu --help
|
|
|
97
105
|
|
|
98
106
|
## 憑證與 CI
|
|
99
107
|
|
|
100
|
-
|
|
108
|
+
目前版本另提供 `fufu tofu`、`fufu pulumi`、`fufu state`。
|
|
101
109
|
Terraform/OpenTofu 可選擇租戶遠端 HTTP state,具備鎖、加密、版本與
|
|
102
110
|
復原;需配套 Console API 和平台金鑰。Pulumi 使用固定官方 Node.js SDK,
|
|
103
111
|
已驗收 SQS、SSM String、Pub/Sub topic/pull subscription,採獨立本機
|
|
104
|
-
DIY backend,秘密欄位需外部 passphrase
|
|
105
|
-
|
|
112
|
+
DIY backend,秘密欄位需外部 passphrase。這些能力從 0.9.0 起納入 CLI,
|
|
113
|
+
仍需要配套服務端;不能把套件安裝成功當成支援驗收。
|
|
106
114
|
|
|
107
115
|
此工作樹也提供 `fufu terraform`:以官方 AWS/Google Terraform provider
|
|
108
116
|
管理 Lambda ZIP、Cloud Run v2、IAM 與服務入口的明列子集,並使用獨立的
|
|
@@ -112,9 +120,9 @@ GCS/S3、Functions Gen 1/Gen 2 等一般 CLI 能力
|
|
|
112
120
|
不表示官方 Terraform 資源已相容。正式環境使用正常 Terraform、正式認證
|
|
113
121
|
與獨立 state,不可直接搬用 FUFU state。
|
|
114
122
|
|
|
115
|
-
|
|
123
|
+
目前版本另接入 SQS queue、SSM String parameter、Pub/Sub topic/pull
|
|
116
124
|
subscription;新增能力需要配套的 Console 與 Pub/Sub 後端更新,不能從
|
|
117
|
-
|
|
125
|
+
CLI 安裝成功就推定服務端已具備。`gcloud-fufu pubsub` 提供建立、
|
|
118
126
|
查詢、更新、刪除、publish/pull/ack 子集;不支援 push、ordering、
|
|
119
127
|
exactly-once、Function trigger 或保留期限保證。
|
|
120
128
|
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "fufu-cloud-cli"
|
|
7
|
-
version = "0.9.
|
|
7
|
+
version = "0.9.5"
|
|
8
8
|
description = "FUFU Cloud CLI for tenant-scoped mock cloud development and deployment"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.10"
|
|
@@ -16,6 +16,7 @@ license-files = ["LICENSE"]
|
|
|
16
16
|
yaml = ["PyYAML==6.0.3"]
|
|
17
17
|
test = ["boto3==1.40.66"]
|
|
18
18
|
terraform = ["python-hcl2==7.3.1"]
|
|
19
|
+
gcp = ["google-cloud-bigquery==3.38.0", "google-cloud-tasks==2.20.0", "google-cloud-logging==3.12.1"]
|
|
19
20
|
|
|
20
21
|
[project.scripts]
|
|
21
22
|
fufu = "fufu_cloud_cli.cli:main"
|
|
@@ -23,6 +24,7 @@ aws-fufu = "fufu_cloud_cli.cli:aws_main"
|
|
|
23
24
|
gcloud-fufu = "fufu_cloud_cli.cli:gcloud_main"
|
|
24
25
|
az-fufu = "fufu_cloud_cli.cli:az_main"
|
|
25
26
|
wrangler-fufu = "fufu_cloud_cli.cli:wrangler_main"
|
|
27
|
+
bq-fufu = "fufu_cloud_cli.cli:bq_main"
|
|
26
28
|
|
|
27
29
|
[tool.setuptools.packages.find]
|
|
28
30
|
where = ["src"]
|
|
@@ -3,7 +3,7 @@ import sys
|
|
|
3
3
|
from .cli import entry
|
|
4
4
|
|
|
5
5
|
if __name__ == "__main__":
|
|
6
|
-
if len(sys.argv) > 1 and sys.argv[1] in {"fufu", "aws-fufu", "gcloud-fufu", "az-fufu"}:
|
|
6
|
+
if len(sys.argv) > 1 and sys.argv[1] in {"fufu", "aws-fufu", "gcloud-fufu", "az-fufu", "wrangler-fufu", "bq-fufu"}:
|
|
7
7
|
entry(sys.argv[1], sys.argv[2:])
|
|
8
8
|
else:
|
|
9
9
|
entry("fufu", sys.argv[1:])
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
"""bq command subset. Uses the same IAM-enforced BigQuery API as the console."""
|
|
2
|
+
import json
|
|
3
|
+
import sys
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from .api_backend import ApiClient
|
|
6
|
+
from .errors import require
|
|
7
|
+
from .gcp_infra import call
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def reference(value, project):
|
|
11
|
+
if ':' in value: selected, value = value.split(':', 1)
|
|
12
|
+
else: selected = project
|
|
13
|
+
require(selected == project, 'ACCESS_DENIED', 'BigQuery project 必須符合 credential scope。')
|
|
14
|
+
parts = value.split('.')
|
|
15
|
+
require(len(parts) <= 2 and all(parts), 'INVALID_RESOURCE', '請使用 project:dataset.table。')
|
|
16
|
+
return {'projectId': project, 'datasetId': parts[0], **({'tableId': parts[1]} if len(parts) == 2 else {})}
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def schema(value):
|
|
20
|
+
path = Path(value)
|
|
21
|
+
if path.is_file():
|
|
22
|
+
require(path.stat().st_size <= 1024**2, 'PAYLOAD_LIMIT', 'Schema 檔案超過 1 MiB。')
|
|
23
|
+
result = json.loads(path.read_text())
|
|
24
|
+
return result if isinstance(result, dict) else {'fields': result}
|
|
25
|
+
return {'fields': [{'name': name, 'type': kind.upper()} for name, kind in (field.split(':', 1) for field in value.split(','))]}
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def command(argv, Parser):
|
|
29
|
+
# bq permits global flags before or after the command.
|
|
30
|
+
project = location = None; clean = []; i = 0
|
|
31
|
+
while i < len(argv):
|
|
32
|
+
flag, sep, inline = argv[i].partition('=')
|
|
33
|
+
if flag in {'--project_id', '--location', '--format'}:
|
|
34
|
+
value = inline if sep else (argv[i + 1] if i + 1 < len(argv) else '')
|
|
35
|
+
require(value and not value.startswith('--'), 'INVALID_ARGUMENT', '全域參數缺少值。')
|
|
36
|
+
if flag == '--project_id': project = value
|
|
37
|
+
elif flag == '--location': location = value
|
|
38
|
+
else: require(value in {'json', 'prettyjson'}, 'UNSUPPORTED_FORMAT', 'bq-fufu 輸出支援 json 與 prettyjson。')
|
|
39
|
+
i += 1 if sep else 2
|
|
40
|
+
elif flag in {'--quiet', '-q', '--headless'}: i += 1
|
|
41
|
+
else: clean.append(argv[i]); i += 1
|
|
42
|
+
p = Parser(prog='bq-fufu', description='BigQuery dataset/table、insertAll 與 SELECT 子集。未支援 load jobs、DML/DDL、external tables 或成本估算。')
|
|
43
|
+
actions = p.add_subparsers(dest='action', required=True)
|
|
44
|
+
for action in ['mk', 'ls', 'show', 'rm', 'head']:
|
|
45
|
+
q = actions.add_parser(action)
|
|
46
|
+
q.add_argument('resource', nargs='?' if action == 'ls' else None)
|
|
47
|
+
q.add_argument('--dataset', '-d', action='store_true'); q.add_argument('--table', '-t', action='store_true')
|
|
48
|
+
if action == 'mk': q.add_argument('schema', nargs='?'); q.add_argument('--description')
|
|
49
|
+
if action == 'rm': q.add_argument('--recursive', '-r', action='store_true'); q.add_argument('--force', '-f', action='store_true')
|
|
50
|
+
if action in {'head', 'ls'}: q.add_argument('--max_results', '-n', type=int, default=100); q.add_argument('--page_token')
|
|
51
|
+
q = actions.add_parser('query'); q.add_argument('query'); q.add_argument('--use_legacy_sql', choices=['false'], default='false'); q.add_argument('--nouse_legacy_sql', action='store_true'); q.add_argument('--parameter', action='append', default=[]); q.add_argument('--max_rows', type=int, default=100)
|
|
52
|
+
q = actions.add_parser('insert'); q.add_argument('resource'); q.add_argument('file')
|
|
53
|
+
a = p.parse_args(clean)
|
|
54
|
+
sys.stderr.write('FUFU BigQuery subset: tenant emulator; unsupported SQL and job types fail explicitly.\n')
|
|
55
|
+
with ApiClient('gcp', read_only=a.action in {'ls', 'show', 'head'}) as api:
|
|
56
|
+
selected = api.whoami()['projectId']
|
|
57
|
+
require(project is None or project == selected, 'ACCESS_DENIED', 'BigQuery project 必須符合 credential scope。')
|
|
58
|
+
if a.action == 'query':
|
|
59
|
+
params = {'projectId': selected, 'query': a.query, 'useLegacySql': False, 'maxResults': a.max_rows}
|
|
60
|
+
if location: params['location'] = location
|
|
61
|
+
if a.parameter:
|
|
62
|
+
params['parameterMode'] = 'NAMED'; params['queryParameters'] = []
|
|
63
|
+
for value in a.parameter:
|
|
64
|
+
parts = value.split(':', 2); require(len(parts) == 3, 'INVALID_ARGUMENT', 'parameter 請使用 name:type:value。')
|
|
65
|
+
name, kind, data = parts
|
|
66
|
+
params['queryParameters'].append({'name': name, 'parameterType': {'type': kind.upper() or 'STRING'}, 'parameterValue': {'value': data}})
|
|
67
|
+
return call(api, 'bigquery', 'queries.query', params)
|
|
68
|
+
ref = reference(a.resource, selected) if a.resource else {'projectId': selected}
|
|
69
|
+
table = 'tableId' in ref
|
|
70
|
+
if getattr(a, 'dataset', False): require(not table, 'INVALID_RESOURCE', 'Dataset 命令不可傳入 table。')
|
|
71
|
+
if getattr(a, 'table', False): require(table, 'INVALID_RESOURCE', 'Table 命令需要 dataset.table。')
|
|
72
|
+
group = 'tables' if table else 'datasets'
|
|
73
|
+
if a.action == 'mk':
|
|
74
|
+
require(not table or a.schema, 'INVALID_ARGUMENT', 'Table 建立需要 schema。')
|
|
75
|
+
value = {'tableReference' if table else 'datasetReference': ref}
|
|
76
|
+
if table: value['schema'] = schema(a.schema)
|
|
77
|
+
elif location: value['location'] = location
|
|
78
|
+
if a.description is not None: value['description'] = a.description
|
|
79
|
+
return call(api, 'bigquery', group + '.insert', {'projectId': selected, **({'datasetId': ref['datasetId']} if table else {}), 'table' if table else 'dataset': value})
|
|
80
|
+
if a.action == 'rm': return call(api, 'bigquery', group + '.delete', {**ref, **({'deleteContents': a.recursive} if not table else {})})
|
|
81
|
+
if a.action == 'show': return call(api, 'bigquery', group + '.get', ref)
|
|
82
|
+
if a.action in {'ls', 'head'}:
|
|
83
|
+
require(a.action != 'head' or table, 'INVALID_RESOURCE', 'head 需要 table。')
|
|
84
|
+
require(a.action != 'ls' or not table, 'INVALID_RESOURCE', 'ls 請指定 dataset。')
|
|
85
|
+
op = 'tabledata.list' if table else 'tables.list' if 'datasetId' in ref else 'datasets.list'
|
|
86
|
+
return call(api, 'bigquery', op, {**ref, 'maxResults': a.max_results, **({'pageToken': a.page_token} if a.page_token else {})})
|
|
87
|
+
require(table, 'INVALID_RESOURCE', 'insert 需要 table。')
|
|
88
|
+
file = Path(a.file); require(file.is_file() and file.stat().st_size <= 10 * 1024**2, 'PAYLOAD_LIMIT', 'NDJSON 檔案超過 10 MiB。')
|
|
89
|
+
rows = [{'json': json.loads(line)} for line in file.read_text().splitlines() if line.strip()]
|
|
90
|
+
result = call(api, 'bigquery', 'tabledata.insertAll', {**ref, 'rows': rows})
|
|
91
|
+
require(not result.get('insertErrors'), 'BIGQUERY_INSERT_FAILED', 'BigQuery 拒絕部分資料列;請檢查 schema。')
|
|
92
|
+
return result
|
|
@@ -323,10 +323,82 @@
|
|
|
323
323
|
"profile-environmentFrom-SSM-only"
|
|
324
324
|
]
|
|
325
325
|
},
|
|
326
|
+
"gcp-logging": {
|
|
327
|
+
"name": "Cloud Logging",
|
|
328
|
+
"status": "implemented",
|
|
329
|
+
"backend": "api",
|
|
330
|
+
"mode": "simplified",
|
|
331
|
+
"operations": [
|
|
332
|
+
"write-read",
|
|
333
|
+
"filter-AND-severity-timestamp-logName-resourceType",
|
|
334
|
+
"logs-list-delete",
|
|
335
|
+
"partialSuccess",
|
|
336
|
+
"explicit-official-Python-REST-SDK",
|
|
337
|
+
"setup-logging"
|
|
338
|
+
],
|
|
339
|
+
"limitations": [
|
|
340
|
+
"last-10000-entries-per-tenant",
|
|
341
|
+
"single-writer-128MiB-state-capacity",
|
|
342
|
+
"no-automatic-container-stdout",
|
|
343
|
+
"no-sinks-retention-metrics-alerts",
|
|
344
|
+
"no-new-IaC-resources"
|
|
345
|
+
]
|
|
346
|
+
},
|
|
347
|
+
"gcp-infrastructure-config": {
|
|
348
|
+
"name": "GCP infrastructure configuration",
|
|
349
|
+
"status": "mock-only",
|
|
350
|
+
"backend": "api",
|
|
351
|
+
"mode": "mock-only",
|
|
352
|
+
"services": [
|
|
353
|
+
"compute-instances",
|
|
354
|
+
"sql-instances",
|
|
355
|
+
"sql-databases",
|
|
356
|
+
"app-services",
|
|
357
|
+
"artifact-repositories",
|
|
358
|
+
"dns-zones",
|
|
359
|
+
"dns-records",
|
|
360
|
+
"networks",
|
|
361
|
+
"subnets",
|
|
362
|
+
"vpc-connectors",
|
|
363
|
+
"builds",
|
|
364
|
+
"gemini-models",
|
|
365
|
+
"vm-policies",
|
|
366
|
+
"eventarc-triggers"
|
|
367
|
+
],
|
|
368
|
+
"operations": [
|
|
369
|
+
"typed-crud",
|
|
370
|
+
"three-locale-mobile-console",
|
|
371
|
+
"fufu-infrastructure-cli"
|
|
372
|
+
],
|
|
373
|
+
"limitations": [
|
|
374
|
+
"configuration-only",
|
|
375
|
+
"no-VM-SQL-network-DNS-build-registry-inference-runtime",
|
|
376
|
+
"no-new-IaC-resources"
|
|
377
|
+
]
|
|
378
|
+
},
|
|
326
379
|
"gcp-bigquery": {
|
|
327
380
|
"name": "BigQuery",
|
|
328
|
-
"status": "
|
|
329
|
-
"
|
|
381
|
+
"status": "implemented",
|
|
382
|
+
"backend": "api",
|
|
383
|
+
"mode": "simplified",
|
|
384
|
+
"operations": [
|
|
385
|
+
"dataset-table-crud",
|
|
386
|
+
"insertAll",
|
|
387
|
+
"tabledata-list",
|
|
388
|
+
"SELECT-CTE",
|
|
389
|
+
"query-parameters",
|
|
390
|
+
"query-job-results",
|
|
391
|
+
"bq-fufu",
|
|
392
|
+
"explicit-official-Python-REST-SDK"
|
|
393
|
+
],
|
|
394
|
+
"limitations": [
|
|
395
|
+
"tenant-emulator-required",
|
|
396
|
+
"no-DML-DDL-load-export-external-tables",
|
|
397
|
+
"GoogleSQL-subset",
|
|
398
|
+
"no-billing-or-performance-parity",
|
|
399
|
+
"last-1000-local-query-handles",
|
|
400
|
+
"no-new-Terraform-OpenTofu-Pulumi-resources"
|
|
401
|
+
]
|
|
330
402
|
},
|
|
331
403
|
"gcp-firestore": {
|
|
332
404
|
"name": "Firestore",
|
|
@@ -343,8 +415,28 @@
|
|
|
343
415
|
},
|
|
344
416
|
"gcp-cloud-tasks": {
|
|
345
417
|
"name": "Cloud Tasks",
|
|
346
|
-
"status": "
|
|
347
|
-
"
|
|
418
|
+
"status": "implemented",
|
|
419
|
+
"backend": "api",
|
|
420
|
+
"mode": "simplified",
|
|
421
|
+
"operations": [
|
|
422
|
+
"queue-crud-pause-resume-purge",
|
|
423
|
+
"HTTP-task-create-list-get-delete-run",
|
|
424
|
+
"scheduled-background-delivery",
|
|
425
|
+
"retry",
|
|
426
|
+
"service-account-invoker-enforcement",
|
|
427
|
+
"explicit-official-Python-REST-SDK"
|
|
428
|
+
],
|
|
429
|
+
"limitations": [
|
|
430
|
+
"same-tenant-registered-HTTP-targets-only",
|
|
431
|
+
"single-local-coordinator",
|
|
432
|
+
"one-task-per-queue-per-second",
|
|
433
|
+
"2-active-per-tenant-8-global",
|
|
434
|
+
"no-AppEngine-targets",
|
|
435
|
+
"no-Google-ID-token",
|
|
436
|
+
"24h-task-name-tombstones",
|
|
437
|
+
"exhausted-tasks-remain-visible",
|
|
438
|
+
"no-new-IaC-resources"
|
|
439
|
+
]
|
|
348
440
|
},
|
|
349
441
|
"gcp-secret-manager": {
|
|
350
442
|
"name": "Secret Manager",
|
|
@@ -370,8 +462,13 @@
|
|
|
370
462
|
},
|
|
371
463
|
"gcp-cloud-sql": {
|
|
372
464
|
"name": "Cloud SQL",
|
|
373
|
-
"status": "
|
|
374
|
-
"
|
|
465
|
+
"status": "mock-only",
|
|
466
|
+
"backend": "api",
|
|
467
|
+
"mode": "mock-only",
|
|
468
|
+
"operations": [
|
|
469
|
+
"typed-instance-database-configuration-crud"
|
|
470
|
+
],
|
|
471
|
+
"reason": "僅保存設定;不建立 SQL runtime、connection endpoint、proxy、backup 或 HA。"
|
|
375
472
|
},
|
|
376
473
|
"aws-rds": {
|
|
377
474
|
"name": "RDS / Aurora",
|
|
@@ -391,12 +488,22 @@
|
|
|
391
488
|
"gcp-app-engine": {
|
|
392
489
|
"name": "App Engine",
|
|
393
490
|
"status": "mock-only",
|
|
394
|
-
"
|
|
491
|
+
"backend": "api",
|
|
492
|
+
"mode": "mock-only",
|
|
493
|
+
"operations": [
|
|
494
|
+
"typed-service-configuration-crud"
|
|
495
|
+
],
|
|
496
|
+
"reason": "僅保存設定;不執行 app.yaml、App Engine runtime 或 traffic。"
|
|
395
497
|
},
|
|
396
498
|
"gcp-compute-engine": {
|
|
397
499
|
"name": "Compute Engine",
|
|
398
|
-
"status": "
|
|
399
|
-
"
|
|
500
|
+
"status": "mock-only",
|
|
501
|
+
"backend": "api",
|
|
502
|
+
"mode": "mock-only",
|
|
503
|
+
"operations": [
|
|
504
|
+
"typed-instance-configuration-crud"
|
|
505
|
+
],
|
|
506
|
+
"reason": "僅保存設定;無 VM、disk、SSH、guest OS 或 hypervisor。"
|
|
400
507
|
},
|
|
401
508
|
"gcp-gke": {
|
|
402
509
|
"name": "GKE",
|
|
@@ -410,23 +517,57 @@
|
|
|
410
517
|
},
|
|
411
518
|
"gcp-cloud-scheduler": {
|
|
412
519
|
"name": "Cloud Scheduler",
|
|
413
|
-
"status": "
|
|
414
|
-
"
|
|
520
|
+
"status": "implemented",
|
|
521
|
+
"backend": "api",
|
|
522
|
+
"mode": "simplified",
|
|
523
|
+
"operations": [
|
|
524
|
+
"HTTP-job-crud",
|
|
525
|
+
"Unix-cron",
|
|
526
|
+
"IANA-timezone",
|
|
527
|
+
"pause-resume-run",
|
|
528
|
+
"automatic-dispatch",
|
|
529
|
+
"retry",
|
|
530
|
+
"service-account-invoker-enforcement"
|
|
531
|
+
],
|
|
532
|
+
"limitations": [
|
|
533
|
+
"same-tenant-registered-HTTP-targets-only",
|
|
534
|
+
"no-PubSub-targets",
|
|
535
|
+
"no-Google-ID-token-or-service-agent",
|
|
536
|
+
"single-local-coordinator",
|
|
537
|
+
"missed-schedules-coalesce",
|
|
538
|
+
"no-HA-or-exactly-once",
|
|
539
|
+
"no-new-IaC-resources"
|
|
540
|
+
]
|
|
415
541
|
},
|
|
416
542
|
"gcp-artifact-registry": {
|
|
417
543
|
"name": "Artifact Registry",
|
|
418
|
-
"status": "
|
|
419
|
-
"
|
|
544
|
+
"status": "mock-only",
|
|
545
|
+
"backend": "api",
|
|
546
|
+
"mode": "mock-only",
|
|
547
|
+
"operations": [
|
|
548
|
+
"typed-repository-configuration-crud"
|
|
549
|
+
],
|
|
550
|
+
"reason": "僅保存設定;無 OCI push/pull、package storage、scanning 或 registry authentication。"
|
|
420
551
|
},
|
|
421
552
|
"gcp-cloud-build": {
|
|
422
553
|
"name": "Cloud Build",
|
|
423
|
-
"status": "
|
|
424
|
-
"
|
|
554
|
+
"status": "mock-only",
|
|
555
|
+
"backend": "api",
|
|
556
|
+
"mode": "mock-only",
|
|
557
|
+
"operations": [
|
|
558
|
+
"typed-build-configuration-crud"
|
|
559
|
+
],
|
|
560
|
+
"reason": "僅保存設定;Cloud Run 本機 source build 是另一條可執行路徑,不是 Cloud Build。"
|
|
425
561
|
},
|
|
426
562
|
"gcp-eventarc": {
|
|
427
563
|
"name": "Eventarc",
|
|
428
564
|
"status": "mock-only",
|
|
429
|
-
"
|
|
565
|
+
"backend": "api",
|
|
566
|
+
"mode": "mock-only",
|
|
567
|
+
"operations": [
|
|
568
|
+
"typed-trigger-configuration-crud"
|
|
569
|
+
],
|
|
570
|
+
"reason": "僅保存設定;不投遞事件或執行 CloudEvents 轉換。"
|
|
430
571
|
},
|
|
431
572
|
"gcp-vertex-ai": {
|
|
432
573
|
"name": "Vertex AI",
|
|
@@ -435,8 +576,8 @@
|
|
|
435
576
|
},
|
|
436
577
|
"gcp-cloud-logging-monitoring": {
|
|
437
578
|
"name": "Cloud Logging / Monitoring",
|
|
438
|
-
"status": "
|
|
439
|
-
"reason": "
|
|
579
|
+
"status": "partial",
|
|
580
|
+
"reason": "gcp-logging 已提供可執行日誌子集;Monitoring、alerts、sinks 與完整 stdout 收集未支援。"
|
|
440
581
|
},
|
|
441
582
|
"gcp-load-balancing-nat": {
|
|
442
583
|
"name": "Cloud Load Balancing / NAT",
|
|
@@ -125,6 +125,7 @@ def fufu_parser():
|
|
|
125
125
|
sub.add_parser('tofu', help='官方 OpenTofu;支援本機或遠端 FUFU state')
|
|
126
126
|
sub.add_parser('pulumi', help='官方 Pulumi Node.js SDK;使用獨立 FUFU 開發 state')
|
|
127
127
|
sub.add_parser('state', help='管理 Terraform/OpenTofu 遠端 state、版本與鎖')
|
|
128
|
+
sub.add_parser('infrastructure', help='GCP 基礎設施配置 mock 的查詢與 CRUD;不啟動資源')
|
|
128
129
|
for action in ("plan", "deploy"):
|
|
129
130
|
q = sub.add_parser(action)
|
|
130
131
|
q.add_argument("--file", required=True)
|
|
@@ -176,7 +177,11 @@ def fufu_command(argv):
|
|
|
176
177
|
if a.remote:
|
|
177
178
|
with ApiClient(a.provider, read_only=True) as api:
|
|
178
179
|
d['remote'] = api.request('GET', '/capabilities' if a.provider == 'cloudflare' else '/compute/capabilities')
|
|
180
|
+
if a.provider == 'gcp': d['infrastructure'] = api.request('GET', '/services/capabilities')
|
|
179
181
|
available = ['cloudflare-pages', 'cloudflare-workers-static', 'cloudflare-r2'] if a.provider == 'cloudflare' and d['remote'].get('executable') else d['remote'].get('kinds', []) + d['remote'].get('features', [])
|
|
182
|
+
if a.provider == 'gcp':
|
|
183
|
+
names = {'scheduler': 'gcp-cloud-scheduler', 'tasks': 'gcp-cloud-tasks', 'bigquery': 'gcp-bigquery', 'logging': 'gcp-logging'}
|
|
184
|
+
available += [names[service['id']] for service in d['infrastructure']['services'] if service['executable'] and service['id'] in names]
|
|
180
185
|
require(all(k in available for k in a.require), 'CAPABILITY_NOT_SUPPORTED', '目前 provider 的服務端不支援必要能力。')
|
|
181
186
|
d['selectedBackend'] = backend
|
|
182
187
|
return d
|
|
@@ -414,7 +419,7 @@ def entry(provider, argv=None):
|
|
|
414
419
|
try:
|
|
415
420
|
argv = sys.argv[1:] if argv is None else argv
|
|
416
421
|
argv = list(argv)
|
|
417
|
-
require(provider in {"fufu", "aws-fufu", "gcloud-fufu", "az-fufu", "wrangler-fufu"}, "UNSUPPORTED_CLI", "未知 CLI 入口。")
|
|
422
|
+
require(provider in {"fufu", "aws-fufu", "gcloud-fufu", "az-fufu", "wrangler-fufu", "bq-fufu"}, "UNSUPPORTED_CLI", "未知 CLI 入口。")
|
|
418
423
|
if argv == ['--version']:
|
|
419
424
|
sys.stdout.write(('fufu-cloud-cli ' if provider == 'gcloud-fufu' else '') + __version__ + '\n'); return
|
|
420
425
|
if provider == 'gcloud-fufu' and argv in [['--help'], ['-h']]:
|
|
@@ -426,8 +431,11 @@ def entry(provider, argv=None):
|
|
|
426
431
|
p.add_argument('--quiet', '-q', action='store_true', help='非互動執行')
|
|
427
432
|
p.add_argument('--version', action='store_true', help='CLI 版本')
|
|
428
433
|
groups = p.add_subparsers(title='服務')
|
|
429
|
-
for group in ['run', 'functions', 'iam', 'projects', 'pubsub', 'secrets', 'storage', 'auth']: groups.add_parser(group)
|
|
434
|
+
for group in ['run', 'functions', 'iam', 'projects', 'pubsub', 'secrets', 'storage', 'auth', 'scheduler', 'tasks', 'logging', 'compute', 'sql', 'artifacts', 'dns', 'eventarc']: groups.add_parser(group)
|
|
430
435
|
p.print_help(); return
|
|
436
|
+
if provider == 'bq-fufu':
|
|
437
|
+
from .bigquery import command
|
|
438
|
+
emit(command(argv, Parser)); return
|
|
431
439
|
if provider == 'wrangler-fufu':
|
|
432
440
|
from .cloudflare import command
|
|
433
441
|
result = command(argv, Parser)
|
|
@@ -442,6 +450,9 @@ def entry(provider, argv=None):
|
|
|
442
450
|
if provider == 'fufu' and argv[:1] == ['state']:
|
|
443
451
|
from .iac_state import command
|
|
444
452
|
emit(command(argv[1:], Parser)); return
|
|
453
|
+
if provider == 'fufu' and argv[:1] == ['infrastructure']:
|
|
454
|
+
from .gcp_infra import configuration_command
|
|
455
|
+
emit(configuration_command(argv[1:], Parser)); return
|
|
445
456
|
# Native global selectors are checked against the authenticated tenant scope.
|
|
446
457
|
if provider in {'aws-fufu', 'gcloud-fufu'}:
|
|
447
458
|
rest = []; i = 0
|
|
@@ -477,11 +488,18 @@ def entry(provider, argv=None):
|
|
|
477
488
|
if output_filter or output_sort or output_limit is not None:
|
|
478
489
|
list_commands = [('run', 'services', 'list'), ('functions', 'list'), ('iam', 'service-accounts', 'list'),
|
|
479
490
|
('pubsub', 'topics', 'list'), ('pubsub', 'subscriptions', 'list'), ('secrets', 'list'),
|
|
480
|
-
('secrets', 'versions', 'list'), ('storage', 'buckets', 'list'), ('storage', 'objects', 'list')
|
|
491
|
+
('secrets', 'versions', 'list'), ('storage', 'buckets', 'list'), ('storage', 'objects', 'list'),
|
|
492
|
+
('scheduler', 'jobs', 'list'), ('tasks', 'queues', 'list'), ('tasks', 'list'),
|
|
493
|
+
('compute', 'instances', 'list'), ('sql', 'instances', 'list'), ('sql', 'databases', 'list'),
|
|
494
|
+
('artifacts', 'repositories', 'list'), ('dns', 'managed-zones', 'list'), ('eventarc', 'triggers', 'list'),
|
|
495
|
+
('compute', 'networks', 'list'), ('compute', 'networks', 'subnets', 'list'), ('compute', 'networks', 'vpc-access', 'connectors', 'list')]
|
|
481
496
|
require(any(tuple(argv[:len(command)]) == command for command in list_commands), 'UNSUPPORTED_ARGUMENT', '清單選取旗標只適用於資源 list;未執行操作。')
|
|
482
497
|
if output_format and argv[:2] == ['functions', 'call']:
|
|
483
498
|
require(output_format == 'json', 'UNSUPPORTED_FORMAT', 'Functions call 目前只支援 JSON 回應。')
|
|
484
499
|
from . import public_access
|
|
500
|
+
if provider == 'gcloud-fufu' and argv and argv[0] in {'scheduler', 'tasks', 'logging', 'compute', 'sql', 'artifacts', 'dns', 'eventarc'}:
|
|
501
|
+
from .gcp_infra import command
|
|
502
|
+
emit(command(argv, Parser)); return
|
|
485
503
|
if provider == 'aws-fufu' and argv[:1] == ['secretsmanager'] or provider == 'gcloud-fufu' and argv[:1] == ['secrets']:
|
|
486
504
|
from .secrets import command
|
|
487
505
|
emit(command('aws' if provider == 'aws-fufu' else 'gcp', argv[1:], Parser)); return
|
|
@@ -565,3 +583,7 @@ def az_main():
|
|
|
565
583
|
|
|
566
584
|
def wrangler_main():
|
|
567
585
|
entry("wrangler-fufu")
|
|
586
|
+
|
|
587
|
+
|
|
588
|
+
def bq_main():
|
|
589
|
+
entry("bq-fufu")
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
"""Native command subsets for the shared, tenant-scoped GCP infrastructure API."""
|
|
2
|
+
import base64
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import sys
|
|
6
|
+
import time
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from .api_backend import ApiClient
|
|
9
|
+
from .errors import require
|
|
10
|
+
from .gcloud_environment import dictionary
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def call(api, service, operation, params):
|
|
14
|
+
return api._request('POST', '/api/cloud/gcp/services/' + service,
|
|
15
|
+
{'operation': operation, 'params': params}, large=True)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def all_pages(api, service, operation, params, key):
|
|
19
|
+
result, seen, deadline = [], set(), time.monotonic() + 60
|
|
20
|
+
for _ in range(1000):
|
|
21
|
+
require(time.monotonic() < deadline, 'PAGINATION_FAILED', '分頁讀取超過期限。')
|
|
22
|
+
response = call(api, service, operation, params)
|
|
23
|
+
result.extend(response.get(key, []))
|
|
24
|
+
token = response.get('nextPageToken')
|
|
25
|
+
if not token: return result
|
|
26
|
+
require(token not in seen, 'PAGINATION_FAILED', '後端重複回傳分頁。')
|
|
27
|
+
seen.add(token); params = {**params, 'pageToken': token}
|
|
28
|
+
require(False, 'PAGINATION_FAILED', '分頁數超過上限。')
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def native_name(parent, collection, value):
|
|
32
|
+
prefix = parent + '/' + collection + '/'
|
|
33
|
+
if value.startswith('projects/'):
|
|
34
|
+
require(value.startswith(prefix) and '/' not in value[len(prefix):], 'ACCESS_DENIED', '資源必須屬於目前 project、location 與 parent。')
|
|
35
|
+
return value
|
|
36
|
+
require('/' not in value and value, 'INVALID_RESOURCE', '資源名稱不合法。')
|
|
37
|
+
return prefix + value
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def target_flags(parser, scheduler):
|
|
41
|
+
parser.add_argument('--uri' if scheduler else '--url', dest='uri')
|
|
42
|
+
parser.add_argument('--http-method' if scheduler else '--method', dest='method')
|
|
43
|
+
parser.add_argument('--headers')
|
|
44
|
+
body = parser.add_mutually_exclusive_group()
|
|
45
|
+
body.add_argument('--message-body' if scheduler else '--body-content', dest='body')
|
|
46
|
+
body.add_argument('--message-body-from-file' if scheduler else '--body-file', dest='body_file')
|
|
47
|
+
parser.add_argument('--oidc-service-account-email')
|
|
48
|
+
parser.add_argument('--oidc-token-audience')
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def target(a, existing=None, tasks=False):
|
|
52
|
+
result = dict(existing or {})
|
|
53
|
+
if a.uri is not None: result['url' if tasks else 'uri'] = a.uri
|
|
54
|
+
if a.method is not None: result['httpMethod'] = a.method
|
|
55
|
+
if a.headers is not None: result['headers'] = dictionary(a.headers)
|
|
56
|
+
if a.body is not None: result['body'] = base64.b64encode(a.body.encode()).decode()
|
|
57
|
+
if a.body_file:
|
|
58
|
+
p = Path(a.body_file)
|
|
59
|
+
require(p.is_file() and p.stat().st_size <= 1024**2, 'PAYLOAD_LIMIT', '工作內容檔案不得超過 1 MiB。')
|
|
60
|
+
result['body'] = base64.b64encode(p.read_bytes()).decode()
|
|
61
|
+
if a.oidc_service_account_email is not None:
|
|
62
|
+
result['oidcToken'] = {'serviceAccountEmail': a.oidc_service_account_email}
|
|
63
|
+
if a.oidc_token_audience is not None:
|
|
64
|
+
result.setdefault('oidcToken', {})['audience'] = a.oidc_token_audience
|
|
65
|
+
return result
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def jobs_command(argv, Parser):
|
|
69
|
+
scheduler = argv[0] == 'scheduler'
|
|
70
|
+
p = Parser(prog='gcloud-fufu ' + argv[0], description='HTTP 工作的可執行子集:同租戶已登錄端點、OIDC identity、cron/重試。無外部 HTTP 投遞。')
|
|
71
|
+
groups = p.add_subparsers(dest='group', required=True)
|
|
72
|
+
for group in (['jobs'] if scheduler else ['queues', 'tasks']):
|
|
73
|
+
actions = groups if group == 'tasks' else groups.add_parser(group).add_subparsers(dest='action', required=True)
|
|
74
|
+
verbs = ['create', 'describe', 'list', 'delete', 'run', 'pause', 'resume', 'update'] if scheduler else ['create', 'describe', 'list', 'delete', 'update', 'pause', 'resume', 'purge'] if group == 'queues' else ['create-http-task', 'describe', 'list', 'delete', 'run']
|
|
75
|
+
for action in verbs:
|
|
76
|
+
q = actions.add_parser(action)
|
|
77
|
+
if group == 'tasks': q.set_defaults(group='tasks', action=action)
|
|
78
|
+
if scheduler and action in {'create', 'update'}:
|
|
79
|
+
q = q.add_subparsers(dest='target_type', required=True).add_parser('http')
|
|
80
|
+
if action != 'list': q.add_argument('name', nargs='?' if action == 'create-http-task' else None)
|
|
81
|
+
else: q.add_argument('--page-size', type=int, default=100); q.add_argument('--page-token')
|
|
82
|
+
q.add_argument('--location')
|
|
83
|
+
if group == 'tasks':
|
|
84
|
+
q.add_argument('--queue', required=True); q.add_argument('--response-view', choices=['basic', 'full'], default='basic')
|
|
85
|
+
if scheduler and action in {'create', 'update'}:
|
|
86
|
+
q.add_argument('--schedule', required=action == 'create'); q.add_argument('--time-zone'); q.add_argument('--description'); q.add_argument('--attempt-deadline')
|
|
87
|
+
q.add_argument('--max-retry-attempts', type=int); q.add_argument('--min-backoff'); q.add_argument('--max-backoff'); q.add_argument('--max-doublings', type=int); q.add_argument('--max-retry-duration')
|
|
88
|
+
target_flags(q, True)
|
|
89
|
+
if group == 'queues' and action in {'create', 'update'}:
|
|
90
|
+
q.add_argument('--max-dispatches-per-second', type=int); q.add_argument('--max-concurrent-dispatches', type=int)
|
|
91
|
+
q.add_argument('--max-attempts', type=int); q.add_argument('--min-backoff'); q.add_argument('--max-backoff'); q.add_argument('--max-doublings', type=int); q.add_argument('--max-retry-duration')
|
|
92
|
+
if action == 'create-http-task':
|
|
93
|
+
target_flags(q, False); q.add_argument('--schedule-time'); q.add_argument('--dispatch-deadline')
|
|
94
|
+
a = p.parse_args(argv[1:])
|
|
95
|
+
with ApiClient('gcp', read_only=a.action in {'describe', 'list'}) as api:
|
|
96
|
+
who = api.whoami(); region = a.location or os.environ.get('FUFU_SELECTED_REGION') or who['region']
|
|
97
|
+
require(region == who['region'], 'ACCESS_DENIED', 'location 必須符合 credential scope。')
|
|
98
|
+
parent = 'projects/' + who['projectId'] + '/locations/' + region
|
|
99
|
+
service = 'scheduler' if scheduler else 'tasks'
|
|
100
|
+
task = a.group == 'tasks'
|
|
101
|
+
if task: parent = native_name(parent, 'queues', a.queue)
|
|
102
|
+
full = native_name(parent, a.group, a.name) if getattr(a, 'name', None) else None
|
|
103
|
+
operation = lambda action: action if scheduler else a.group + '.' + action
|
|
104
|
+
if a.action == 'list':
|
|
105
|
+
return all_pages(api, service, operation('list'), {'parent': parent, 'pageSize': a.page_size, **({'pageToken': a.page_token} if a.page_token else {}), **({'responseView': a.response_view.upper()} if task else {})}, a.group)
|
|
106
|
+
if a.action in {'create', 'update', 'create-http-task'}:
|
|
107
|
+
current = call(api, service, operation('get'), {'name': full}) if a.action == 'update' else {}
|
|
108
|
+
body = {'name': full} if full else {}
|
|
109
|
+
if scheduler:
|
|
110
|
+
for flag, field in [('schedule', 'schedule'), ('time_zone', 'timeZone'), ('description', 'description'), ('attempt_deadline', 'attemptDeadline')]:
|
|
111
|
+
if getattr(a, flag) is not None: body[field] = getattr(a, flag)
|
|
112
|
+
http = target(a, current.get('httpTarget'))
|
|
113
|
+
if http: body['httpTarget'] = http
|
|
114
|
+
elif task:
|
|
115
|
+
body['httpRequest'] = target(a, tasks=True)
|
|
116
|
+
if a.schedule_time: body['scheduleTime'] = a.schedule_time
|
|
117
|
+
if a.dispatch_deadline: body['dispatchDeadline'] = a.dispatch_deadline
|
|
118
|
+
else:
|
|
119
|
+
rate = dict(current.get('rateLimits', {}))
|
|
120
|
+
for flag, field in [('max_dispatches_per_second', 'maxDispatchesPerSecond'), ('max_concurrent_dispatches', 'maxConcurrentDispatches')]:
|
|
121
|
+
if getattr(a, flag) is not None: rate[field] = getattr(a, flag)
|
|
122
|
+
if rate: body['rateLimits'] = rate
|
|
123
|
+
if not task:
|
|
124
|
+
config = dict(current.get('retryConfig', {}))
|
|
125
|
+
for flag, field in [('max_retry_attempts' if scheduler else 'max_attempts', 'retryCount' if scheduler else 'maxAttempts'), ('min_backoff', 'minBackoffDuration' if scheduler else 'minBackoff'), ('max_backoff', 'maxBackoffDuration' if scheduler else 'maxBackoff'), ('max_doublings', 'maxDoublings'), ('max_retry_duration', 'maxRetryDuration')]:
|
|
126
|
+
if getattr(a, flag) is not None: config[field] = getattr(a, flag)
|
|
127
|
+
if config: body['retryConfig'] = config
|
|
128
|
+
key = 'job' if scheduler else 'task' if task else 'queue'
|
|
129
|
+
params = {key: body}
|
|
130
|
+
if a.action == 'update': params['updateMask'] = ','.join(k for k in body if k != 'name')
|
|
131
|
+
else: params['parent'] = parent
|
|
132
|
+
if task: params['responseView'] = a.response_view.upper()
|
|
133
|
+
return call(api, service, operation('update' if a.action == 'update' else 'create'), params)
|
|
134
|
+
return call(api, service, operation('get' if a.action == 'describe' else a.action), {'name': full, **({'responseView': a.response_view.upper()} if task and a.action in {'describe', 'run'} else {})})
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def logging_command(argv, Parser):
|
|
138
|
+
p = Parser(prog='gcloud-fufu logging', description='持久化本機日誌;保留最近 10,000 筆,不含正式 Log Router、retention 或全容器 stdout。')
|
|
139
|
+
actions = p.add_subparsers(dest='action', required=True)
|
|
140
|
+
q = actions.add_parser('write'); q.add_argument('log'); q.add_argument('message'); q.add_argument('--payload-type', choices=['text', 'json'], default='text'); q.add_argument('--severity', default='DEFAULT')
|
|
141
|
+
q = actions.add_parser('read'); q.add_argument('filter', nargs='?', default=''); q.add_argument('--limit', type=int, default=50); q.add_argument('--order', choices=['asc', 'desc'], default='desc')
|
|
142
|
+
logs = actions.add_parser('logs').add_subparsers(dest='logs_action', required=True)
|
|
143
|
+
logs.add_parser('list'); logs.add_parser('delete').add_argument('log')
|
|
144
|
+
a = p.parse_args(argv)
|
|
145
|
+
with ApiClient('gcp', read_only=a.action == 'read' or a.action == 'logs' and a.logs_action == 'list') as api:
|
|
146
|
+
project = 'projects/' + api.whoami()['projectId']
|
|
147
|
+
if a.action == 'logs' and a.logs_action == 'list': return all_pages(api, 'logging', 'logs-list', {'parent': project}, 'logNames')
|
|
148
|
+
if a.action == 'read':
|
|
149
|
+
require(1 <= a.limit <= 10000, 'INVALID_LIMIT', '日誌 limit 必須介於 1–10000。')
|
|
150
|
+
return all_pages(api, 'logging', 'read', {'resourceNames': [project], 'filter': a.filter, 'orderBy': 'timestamp ' + a.order, 'pageSize': min(a.limit, 1000)}, 'entries')[:a.limit]
|
|
151
|
+
name = a.log if a.log.startswith('projects/') else project + '/logs/' + a.log
|
|
152
|
+
if a.action == 'logs': return call(api, 'logging', 'logs-delete', {'logName': name})
|
|
153
|
+
entry = {'severity': a.severity, 'jsonPayload' if a.payload_type == 'json' else 'textPayload': json.loads(a.message) if a.payload_type == 'json' else a.message}
|
|
154
|
+
return call(api, 'logging', 'write', {'logName': name, 'entries': [entry]})
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
# Native flag spelling is kept; values remain configuration-only and never imply runtime success.
|
|
158
|
+
MOCK_GROUPS = {
|
|
159
|
+
('compute', 'instances'): ('compute-instances', {'zone': 'zone', 'machine-type': 'machineType', 'image': 'image', 'image-project': 'imageProject', 'boot-disk-size': 'bootDiskSizeGb'}),
|
|
160
|
+
('compute', 'networks'): ('networks', {'subnet-mode': 'subnetMode', 'mtu': 'mtu', 'description': 'description'}),
|
|
161
|
+
('compute', 'networks', 'subnets'): ('subnets', {'network': 'network', 'range': 'ipCidrRange'}),
|
|
162
|
+
('sql', 'instances'): ('sql-instances', {'database-version': 'databaseVersion', 'tier': 'tier', 'storage-size': 'storageSizeGb', 'availability-type': 'availabilityType'}),
|
|
163
|
+
('sql', 'databases'): ('sql-databases', {'instance': 'instance', 'charset': 'charset', 'collation': 'collation'}),
|
|
164
|
+
('artifacts', 'repositories'): ('artifact-repositories', {'repository-format': 'format', 'location': 'location', 'description': 'description'}),
|
|
165
|
+
('dns', 'managed-zones'): ('dns-zones', {'dns-name': 'dnsName', 'description': 'description', 'visibility': 'visibility'}),
|
|
166
|
+
('compute', 'os-config', 'os-policy-assignments'): ('vm-policies', {'zone': 'zone', 'description': 'description'}),
|
|
167
|
+
('compute', 'networks', 'vpc-access', 'connectors'): ('vpc-connectors', {'network': 'network', 'range': 'range', 'min-instances': 'minInstances', 'max-instances': 'maxInstances'}),
|
|
168
|
+
('eventarc', 'triggers'): ('eventarc-triggers', {'location': 'location', 'destination-run-service': 'destination', 'event-filters': 'eventType', 'service-account': 'serviceAccount'}),
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def mock_command(argv, Parser):
|
|
173
|
+
match = next((k for k in sorted(MOCK_GROUPS, key=len, reverse=True) if tuple(argv[:len(k)]) == k), None)
|
|
174
|
+
require(match is not None, 'CAPABILITY_NOT_SUPPORTED', '此服務只有文件列出的配置 mock;目前命令未支援。')
|
|
175
|
+
service, flags = MOCK_GROUPS[match]
|
|
176
|
+
p = Parser(prog='gcloud-fufu ' + ' '.join(match), description='MOCK ONLY:只儲存配置,不建立或啟動真正資源。')
|
|
177
|
+
actions = p.add_subparsers(dest='action', required=True)
|
|
178
|
+
for action in ['create', 'update', 'describe', 'list', 'delete']:
|
|
179
|
+
q = actions.add_parser(action)
|
|
180
|
+
if action != 'list': q.add_argument('name')
|
|
181
|
+
if action in {'create', 'update'}:
|
|
182
|
+
for flag, field in flags.items(): q.add_argument('--' + flag, dest=field, type=int if field in {'bootDiskSizeGb', 'storageSizeGb', 'mtu', 'minInstances', 'maxInstances'} else str)
|
|
183
|
+
a = p.parse_args(argv[len(match):])
|
|
184
|
+
sys.stderr.write('FUFU MOCK ONLY: configuration is persisted; no VM, database, registry, DNS or network is provisioned.\n')
|
|
185
|
+
with ApiClient('gcp', read_only=a.action in {'list', 'describe'}) as api:
|
|
186
|
+
if a.action == 'list': return all_pages(api, service, 'list', {}, 'items')
|
|
187
|
+
params = {'name': a.name}
|
|
188
|
+
if a.action in {'create', 'update'}:
|
|
189
|
+
config = {field: getattr(a, field) for field in flags.values() if getattr(a, field) is not None}
|
|
190
|
+
if service in {'sql-instances', 'subnets', 'vpc-connectors'}: config['region'] = os.environ.get('FUFU_SELECTED_REGION') or api.whoami()['region']
|
|
191
|
+
params['config'] = config
|
|
192
|
+
return call(api, service, 'get' if a.action == 'describe' else a.action, params)
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def command(argv, Parser):
|
|
196
|
+
if argv[0] in {'scheduler', 'tasks'}: return jobs_command(argv, Parser)
|
|
197
|
+
if argv[0] == 'logging': return logging_command(argv[1:], Parser)
|
|
198
|
+
return mock_command(argv, Parser)
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def configuration_command(argv, Parser):
|
|
202
|
+
p = Parser(prog='fufu infrastructure', description='GCP 配置 mock 的明確擴充介面;不會執行 VM、SQL、DNS、Cloud Build 或 AI 推論。')
|
|
203
|
+
actions = p.add_subparsers(dest='action', required=True)
|
|
204
|
+
actions.add_parser('catalog')
|
|
205
|
+
for action in ['create', 'update', 'describe', 'list', 'delete']:
|
|
206
|
+
q = actions.add_parser(action); q.add_argument('service')
|
|
207
|
+
if action != 'list': q.add_argument('name')
|
|
208
|
+
if action in {'create', 'update'}: q.add_argument('--config-file', required=True)
|
|
209
|
+
a = p.parse_args(argv)
|
|
210
|
+
with ApiClient('gcp', read_only=a.action in {'catalog', 'describe', 'list'}) as api:
|
|
211
|
+
capabilities = api.request('GET', '/services/capabilities')
|
|
212
|
+
mocks = {s['id']: s for s in capabilities['services'] if not s['executable']}
|
|
213
|
+
if a.action == 'catalog': return {'services': list(mocks.values())}
|
|
214
|
+
require(a.service in mocks, 'CAPABILITY_NOT_SUPPORTED', '這個介面只處理明確列出的配置 mock。')
|
|
215
|
+
sys.stderr.write('FUFU MOCK ONLY: configuration is persisted; no underlying resource is provisioned.\n')
|
|
216
|
+
if a.action == 'list': return all_pages(api, a.service, 'list', {}, 'items')
|
|
217
|
+
params = {'name': a.name}
|
|
218
|
+
if a.action in {'create', 'update'}:
|
|
219
|
+
source = Path(a.config_file)
|
|
220
|
+
require(source.is_file() and source.stat().st_size <= 1024**2, 'INVALID_CONFIG', '配置 JSON 檔案須存在且不超過 1 MiB。')
|
|
221
|
+
params['config'] = json.loads(source.read_text())
|
|
222
|
+
return call(api, a.service, 'get' if a.action == 'describe' else a.action, params)
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""Explicit Google SDK client factories for deployed FUFU workloads.
|
|
2
|
+
|
|
3
|
+
Native request/response serialization stays in Google's SDK. Only documented
|
|
4
|
+
REST operations are routed; there is no ADC search or real-cloud fallback.
|
|
5
|
+
"""
|
|
6
|
+
import os
|
|
7
|
+
from urllib.parse import urlsplit
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def _configuration():
|
|
11
|
+
endpoint = os.environ.get('FUFU_RUNTIME_ENDPOINT', '')
|
|
12
|
+
parsed = urlsplit(endpoint)
|
|
13
|
+
token = os.environ.get('FUFU_WORKLOAD_TOKEN')
|
|
14
|
+
project = os.environ.get('GOOGLE_CLOUD_PROJECT')
|
|
15
|
+
if parsed.scheme != 'http' or parsed.hostname != 'fufu-api' or parsed.port != 8080 or parsed.path != '/api/runtime/gcp' or parsed.query or parsed.fragment or parsed.username or parsed.password or not token or not project:
|
|
16
|
+
raise RuntimeError('A deployed FUFU GCP workload and its scoped identity are required')
|
|
17
|
+
from google.auth.credentials import Credentials
|
|
18
|
+
|
|
19
|
+
class WorkloadCredentials(Credentials):
|
|
20
|
+
def __init__(self):
|
|
21
|
+
super().__init__(); self.token = token
|
|
22
|
+
def refresh(self, request):
|
|
23
|
+
# The server renews the lease only while this exact deployment is live.
|
|
24
|
+
self.token = token
|
|
25
|
+
|
|
26
|
+
return endpoint, project, WorkloadCredentials()
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def bigquery_client():
|
|
30
|
+
from google.cloud import bigquery
|
|
31
|
+
endpoint, project, credentials = _configuration()
|
|
32
|
+
return bigquery.Client(project=project, credentials=credentials, client_options={'api_endpoint': endpoint + '/bigquery'})
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def tasks_client():
|
|
36
|
+
from google.cloud import tasks_v2
|
|
37
|
+
endpoint, _, credentials = _configuration()
|
|
38
|
+
return tasks_v2.CloudTasksClient(credentials=credentials, transport='rest', client_options={'api_endpoint': endpoint + '/tasks'})
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def logging_client():
|
|
42
|
+
from google.cloud import logging_v2
|
|
43
|
+
from google.cloud.logging_v2.resource import Resource
|
|
44
|
+
from google.cloud.logging_v2.handlers import CloudLoggingHandler
|
|
45
|
+
from google.cloud.logging_v2.handlers.transports import SyncTransport
|
|
46
|
+
endpoint, project, credentials = _configuration()
|
|
47
|
+
resource = Resource(type='global', labels={'project_id': project})
|
|
48
|
+
|
|
49
|
+
class ScopedLoggingClient(logging_v2.Client):
|
|
50
|
+
def logger(self, name, *, labels=None, resource=None):
|
|
51
|
+
return super().logger(name, labels=labels, resource=resource or Resource(type='global', labels={'project_id': project}))
|
|
52
|
+
|
|
53
|
+
def get_default_handler(self, **kwargs):
|
|
54
|
+
# setup_logging must send to the explicit endpoint even when Cloud Run
|
|
55
|
+
# environment variables would normally choose stdout collection.
|
|
56
|
+
kwargs.setdefault('resource', resource)
|
|
57
|
+
kwargs.setdefault('transport', SyncTransport)
|
|
58
|
+
return CloudLoggingHandler(self, **kwargs)
|
|
59
|
+
|
|
60
|
+
return ScopedLoggingClient(project=project, credentials=credentials, _use_grpc=False, client_options={'api_endpoint': endpoint + '/logging'})
|
|
@@ -1,9 +1,56 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: fufu-cloud-cli
|
|
3
|
+
Version: 0.9.5
|
|
4
|
+
Summary: FUFU Cloud CLI for tenant-scoped mock cloud development and deployment
|
|
5
|
+
License-Expression: LicenseRef-Proprietary
|
|
6
|
+
Requires-Python: >=3.10
|
|
7
|
+
Description-Content-Type: text/markdown
|
|
8
|
+
License-File: LICENSE
|
|
9
|
+
Requires-Dist: PyYAML==6.0.3
|
|
10
|
+
Requires-Dist: tomli==2.2.1; python_version < "3.11"
|
|
11
|
+
Provides-Extra: yaml
|
|
12
|
+
Requires-Dist: PyYAML==6.0.3; extra == "yaml"
|
|
13
|
+
Provides-Extra: test
|
|
14
|
+
Requires-Dist: boto3==1.40.66; extra == "test"
|
|
15
|
+
Provides-Extra: terraform
|
|
16
|
+
Requires-Dist: python-hcl2==7.3.1; extra == "terraform"
|
|
17
|
+
Provides-Extra: gcp
|
|
18
|
+
Requires-Dist: google-cloud-bigquery==3.38.0; extra == "gcp"
|
|
19
|
+
Requires-Dist: google-cloud-tasks==2.20.0; extra == "gcp"
|
|
20
|
+
Requires-Dist: google-cloud-logging==3.12.1; extra == "gcp"
|
|
21
|
+
Dynamic: license-file
|
|
22
|
+
|
|
1
23
|
# FUFU Cloud CLI
|
|
2
24
|
|
|
3
|
-
|
|
4
|
-
`gcloud-fufu`、`az-fufu`、`wrangler-fufu`。這是 AWS、Google Cloud、Azure 與 Cloudflare 的**明確子集**,
|
|
25
|
+
以六個指令操作 FUFU Cloud 的租戶隔離開發環境:`fufu`、`aws-fufu`、
|
|
26
|
+
`gcloud-fufu`、`az-fufu`、`wrangler-fufu`、`bq-fufu`。這是 AWS、Google Cloud、Azure 與 Cloudflare 的**明確子集**,
|
|
5
27
|
只供 FUFU mock 環境使用;不會把部署轉送到正式雲端。
|
|
6
28
|
|
|
29
|
+
0.9.5 新增 `gcloud-fufu scheduler`、`tasks`、`logging` 與 `bq-fufu`。
|
|
30
|
+
支援同租戶已登錄 HTTP 應用的排程/背景任務、BigQuery dataset/table、
|
|
31
|
+
insertAll/SELECT 與持久化日誌。`fufu_cloud_cli.gcp_sdk` 提供官方 Python
|
|
32
|
+
BigQuery、Cloud Tasks、Logging SDK 的明確 REST factory;可用 `[gcp]` extra
|
|
33
|
+
安裝已驗收相依。需要對應的新後端,並非透明 ADC 或所有 Google SDK 替換。
|
|
34
|
+
|
|
35
|
+
`fufu infrastructure` 提供 14 類 typed configuration mock。Compute Engine、
|
|
36
|
+
Cloud SQL、App Engine、DNS、VPC、Artifact Registry、Cloud Build、Gemini、
|
|
37
|
+
VM Manager、Eventarc 等設定不會啟動 VM/SQL/registry/網路/推論。
|
|
38
|
+
UI 顯示僅設定,CLI stderr 保留 MOCK ONLY,不因輸出投影而隱藏。
|
|
39
|
+
新服務尚未接入 Terraform/OpenTofu/Pulumi;單機背景投遞不是雲端 HA。
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
gcloud-fufu scheduler jobs list
|
|
43
|
+
gcloud-fufu tasks queues list
|
|
44
|
+
bq-fufu query --use_legacy_sql=false 'SELECT 1 AS ready'
|
|
45
|
+
gcloud-fufu logging read 'severity>=ERROR'
|
|
46
|
+
fufu infrastructure catalog
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
BigQuery 是 tenant emulator 的 GoogleSQL 子集,無 DML/DDL、load/export、
|
|
50
|
+
外部表與效能/費用等價。Logging 保留每租戶最新 10,000 筆,不自動收集所有
|
|
51
|
+
容器 stdout。Tasks 每 queue 每秒最多 1 次、每租戶 2 個同時投遞;耗盡 task
|
|
52
|
+
留待檢查,與官方刪除策略不同。新的 dependency flow 不代表完整應用都能遷移。
|
|
53
|
+
|
|
7
54
|
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
55
|
|
|
9
56
|
0.9.3 的常用輸出投影、清單篩選/排序/limit、Cloud Run 設定更新、Service Account 啟停及 Pub/Sub attributes/ACK deadline 持續保留。僅明列子集經過官方 gcloud、小用量真實 GCP 與隔離 Docker 驗收;不表示完整 Google IAM、SDK/ADC 或自動擴縮相容。
|
|
@@ -18,7 +65,7 @@ Python 預設識別而遇到 Cloudflare HTTP 403/1010。識別標頭不取代
|
|
|
18
65
|
## Cloudflare 前端部署與 R2
|
|
19
66
|
|
|
20
67
|
```bash
|
|
21
|
-
pip install 'fufu-cloud-cli==0.9.
|
|
68
|
+
pip install 'fufu-cloud-cli==0.9.5'
|
|
22
69
|
export FUFU_CLOUDFLARE_CREDENTIAL_FILE=/run/secrets/fufu-cloudflare.json
|
|
23
70
|
wrangler-fufu capabilities
|
|
24
71
|
wrangler-fufu pages project create frontend --production-branch main
|
|
@@ -62,7 +109,8 @@ pip install fufu-cloud-cli
|
|
|
62
109
|
fufu --version
|
|
63
110
|
```
|
|
64
111
|
|
|
65
|
-
npm
|
|
112
|
+
npm registry 尚未發布;以下是 registry 可用後的安裝語法。目前可安裝經驗收
|
|
113
|
+
的 npm tarball。npm 版本需要 Node.js 18 以上,以及已安裝的 Python 3.10 以上;各入口
|
|
66
114
|
呼叫相同的 Python CLI。0.8.0 的 npm 建置包內附純 Python YAML/TOML parser,沒有安裝腳本,
|
|
67
115
|
不會自行下載 Python 或執行 pip。
|
|
68
116
|
|
|
@@ -79,12 +127,12 @@ npx --no-install aws-fufu --help
|
|
|
79
127
|
|
|
80
128
|
## 憑證與 CI
|
|
81
129
|
|
|
82
|
-
|
|
130
|
+
目前版本另提供 `fufu tofu`、`fufu pulumi`、`fufu state`。
|
|
83
131
|
Terraform/OpenTofu 可選擇租戶遠端 HTTP state,具備鎖、加密、版本與
|
|
84
132
|
復原;需配套 Console API 和平台金鑰。Pulumi 使用固定官方 Node.js SDK,
|
|
85
133
|
已驗收 SQS、SSM String、Pub/Sub topic/pull subscription,採獨立本機
|
|
86
|
-
DIY backend,秘密欄位需外部 passphrase
|
|
87
|
-
|
|
134
|
+
DIY backend,秘密欄位需外部 passphrase。這些能力從 0.9.0 起納入 CLI,
|
|
135
|
+
仍需要配套服務端;不能把套件安裝成功當成支援驗收。
|
|
88
136
|
|
|
89
137
|
此工作樹也提供 `fufu terraform`:以官方 AWS/Google Terraform provider
|
|
90
138
|
管理 Lambda ZIP、Cloud Run v2、IAM 與服務入口的明列子集,並使用獨立的
|
|
@@ -94,9 +142,9 @@ GCS/S3、Functions Gen 1/Gen 2 等一般 CLI 能力
|
|
|
94
142
|
不表示官方 Terraform 資源已相容。正式環境使用正常 Terraform、正式認證
|
|
95
143
|
與獨立 state,不可直接搬用 FUFU state。
|
|
96
144
|
|
|
97
|
-
|
|
145
|
+
目前版本另接入 SQS queue、SSM String parameter、Pub/Sub topic/pull
|
|
98
146
|
subscription;新增能力需要配套的 Console 與 Pub/Sub 後端更新,不能從
|
|
99
|
-
|
|
147
|
+
CLI 安裝成功就推定服務端已具備。`gcloud-fufu pubsub` 提供建立、
|
|
100
148
|
查詢、更新、刪除、publish/pull/ack 子集;不支援 push、ordering、
|
|
101
149
|
exactly-once、Function trigger 或保留期限保證。
|
|
102
150
|
|
|
@@ -5,6 +5,7 @@ pyproject.toml
|
|
|
5
5
|
src/fufu_cloud_cli/__init__.py
|
|
6
6
|
src/fufu_cloud_cli/__main__.py
|
|
7
7
|
src/fufu_cloud_cli/api_backend.py
|
|
8
|
+
src/fufu_cloud_cli/bigquery.py
|
|
8
9
|
src/fufu_cloud_cli/capabilities.json
|
|
9
10
|
src/fufu_cloud_cli/capacity.py
|
|
10
11
|
src/fufu_cloud_cli/cli.py
|
|
@@ -15,6 +16,8 @@ src/fufu_cloud_cli/docker_backend.py
|
|
|
15
16
|
src/fufu_cloud_cli/errors.py
|
|
16
17
|
src/fufu_cloud_cli/gcloud_environment.py
|
|
17
18
|
src/fufu_cloud_cli/gcloud_output.py
|
|
19
|
+
src/fufu_cloud_cli/gcp_infra.py
|
|
20
|
+
src/fufu_cloud_cli/gcp_sdk.py
|
|
18
21
|
src/fufu_cloud_cli/gcs.py
|
|
19
22
|
src/fufu_cloud_cli/http_runtime.py
|
|
20
23
|
src/fufu_cloud_cli/iac_state.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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{fufu_cloud_cli-0.9.4 → fufu_cloud_cli-0.9.5}/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.4 → fufu_cloud_cli-0.9.5}/src/fufu_cloud_cli/terraform_bridge/__init__.py
RENAMED
|
File without changes
|
|
File without changes
|
{fufu_cloud_cli-0.9.4 → fufu_cloud_cli-0.9.5}/src/fufu_cloud_cli/terraform_bridge/aws_data.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{fufu_cloud_cli-0.9.4 → fufu_cloud_cli-0.9.5}/src/fufu_cloud_cli/terraform_bridge/google_pubsub.py
RENAMED
|
File without changes
|
{fufu_cloud_cli-0.9.4 → fufu_cloud_cli-0.9.5}/src/fufu_cloud_cli/terraform_bridge/remote_state.py
RENAMED
|
File without changes
|
|
File without changes
|
{fufu_cloud_cli-0.9.4 → fufu_cloud_cli-0.9.5}/src/fufu_cloud_cli.egg-info/dependency_links.txt
RENAMED
|
File without changes
|
|
File without changes
|