fufu-cloud-cli 0.8.0__tar.gz → 0.9.1__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.8.0/src/fufu_cloud_cli.egg-info → fufu_cloud_cli-0.9.1}/PKG-INFO +46 -2
- {fufu_cloud_cli-0.8.0 → fufu_cloud_cli-0.9.1}/README.md +45 -1
- {fufu_cloud_cli-0.8.0 → fufu_cloud_cli-0.9.1}/pyproject.toml +2 -2
- {fufu_cloud_cli-0.8.0 → fufu_cloud_cli-0.9.1}/src/fufu_cloud_cli/__init__.py +1 -1
- {fufu_cloud_cli-0.8.0 → fufu_cloud_cli-0.9.1}/src/fufu_cloud_cli/api_backend.py +13 -4
- {fufu_cloud_cli-0.8.0 → fufu_cloud_cli-0.9.1}/src/fufu_cloud_cli/capabilities.json +47 -13
- {fufu_cloud_cli-0.8.0 → fufu_cloud_cli-0.9.1}/src/fufu_cloud_cli/cli.py +32 -6
- {fufu_cloud_cli-0.8.0 → fufu_cloud_cli-0.9.1}/src/fufu_cloud_cli/control.py +8 -5
- {fufu_cloud_cli-0.8.0 → fufu_cloud_cli-0.9.1}/src/fufu_cloud_cli/docker_backend.py +22 -2
- fufu_cloud_cli-0.9.1/src/fufu_cloud_cli/iac_state.py +61 -0
- {fufu_cloud_cli-0.8.0 → fufu_cloud_cli-0.9.1}/src/fufu_cloud_cli/manifest.py +12 -5
- fufu_cloud_cli-0.9.1/src/fufu_cloud_cli/pubsub.py +66 -0
- fufu_cloud_cli-0.9.1/src/fufu_cloud_cli/pulumi-endpoints.json +526 -0
- fufu_cloud_cli-0.9.1/src/fufu_cloud_cli/pulumi_bridge.py +205 -0
- {fufu_cloud_cli-0.8.0 → fufu_cloud_cli-0.9.1}/src/fufu_cloud_cli/remote_compute.py +4 -1
- {fufu_cloud_cli-0.8.0 → fufu_cloud_cli-0.9.1}/src/fufu_cloud_cli/runtime_sdk.py +8 -2
- fufu_cloud_cli-0.9.1/src/fufu_cloud_cli/secrets.py +90 -0
- fufu_cloud_cli-0.9.1/src/fufu_cloud_cli/terraform_bridge/aws_data.py +63 -0
- {fufu_cloud_cli-0.8.0 → fufu_cloud_cli-0.9.1}/src/fufu_cloud_cli/terraform_bridge/google.py +3 -0
- fufu_cloud_cli-0.9.1/src/fufu_cloud_cli/terraform_bridge/google_pubsub.py +57 -0
- fufu_cloud_cli-0.9.1/src/fufu_cloud_cli/terraform_bridge/remote_state.py +74 -0
- {fufu_cloud_cli-0.8.0 → fufu_cloud_cli-0.9.1}/src/fufu_cloud_cli/terraform_bridge/runner.py +77 -20
- {fufu_cloud_cli-0.8.0 → fufu_cloud_cli-0.9.1/src/fufu_cloud_cli.egg-info}/PKG-INFO +46 -2
- {fufu_cloud_cli-0.8.0 → fufu_cloud_cli-0.9.1}/src/fufu_cloud_cli.egg-info/SOURCES.txt +8 -0
- {fufu_cloud_cli-0.8.0 → fufu_cloud_cli-0.9.1}/LICENSE +0 -0
- {fufu_cloud_cli-0.8.0 → fufu_cloud_cli-0.9.1}/MANIFEST.in +0 -0
- {fufu_cloud_cli-0.8.0 → fufu_cloud_cli-0.9.1}/setup.cfg +0 -0
- {fufu_cloud_cli-0.8.0 → fufu_cloud_cli-0.9.1}/src/fufu_cloud_cli/__main__.py +0 -0
- {fufu_cloud_cli-0.8.0 → fufu_cloud_cli-0.9.1}/src/fufu_cloud_cli/capacity.py +0 -0
- {fufu_cloud_cli-0.8.0 → fufu_cloud_cli-0.9.1}/src/fufu_cloud_cli/cloud_data.py +0 -0
- {fufu_cloud_cli-0.8.0 → fufu_cloud_cli-0.9.1}/src/fufu_cloud_cli/cloudflare.py +0 -0
- {fufu_cloud_cli-0.8.0 → fufu_cloud_cli-0.9.1}/src/fufu_cloud_cli/errors.py +0 -0
- {fufu_cloud_cli-0.8.0 → fufu_cloud_cli-0.9.1}/src/fufu_cloud_cli/http_runtime.py +0 -0
- {fufu_cloud_cli-0.8.0 → fufu_cloud_cli-0.9.1}/src/fufu_cloud_cli/lambda_adapter.py +0 -0
- {fufu_cloud_cli-0.8.0 → fufu_cloud_cli-0.9.1}/src/fufu_cloud_cli/limits.py +0 -0
- {fufu_cloud_cli-0.8.0 → fufu_cloud_cli-0.9.1}/src/fufu_cloud_cli/object_transfer.py +0 -0
- {fufu_cloud_cli-0.8.0 → fufu_cloud_cli-0.9.1}/src/fufu_cloud_cli/public_access.py +0 -0
- {fufu_cloud_cli-0.8.0 → fufu_cloud_cli-0.9.1}/src/fufu_cloud_cli/runtime/functions/Dockerfile +0 -0
- {fufu_cloud_cli-0.8.0 → fufu_cloud_cli-0.9.1}/src/fufu_cloud_cli/runtime/ingress/Dockerfile +0 -0
- {fufu_cloud_cli-0.8.0 → fufu_cloud_cli-0.9.1}/src/fufu_cloud_cli/runtime/ingress/proxy.py +0 -0
- {fufu_cloud_cli-0.8.0 → fufu_cloud_cli-0.9.1}/src/fufu_cloud_cli/serverless.py +0 -0
- {fufu_cloud_cli-0.8.0 → fufu_cloud_cli-0.9.1}/src/fufu_cloud_cli/terraform_bridge/__init__.py +0 -0
- {fufu_cloud_cli-0.8.0 → fufu_cloud_cli-0.9.1}/src/fufu_cloud_cli/terraform_bridge/aws.py +0 -0
- {fufu_cloud_cli-0.8.0 → fufu_cloud_cli-0.9.1}/src/fufu_cloud_cli/terraform_bridge/common.py +0 -0
- {fufu_cloud_cli-0.8.0 → fufu_cloud_cli-0.9.1}/src/fufu_cloud_cli.egg-info/dependency_links.txt +0 -0
- {fufu_cloud_cli-0.8.0 → fufu_cloud_cli-0.9.1}/src/fufu_cloud_cli.egg-info/entry_points.txt +0 -0
- {fufu_cloud_cli-0.8.0 → fufu_cloud_cli-0.9.1}/src/fufu_cloud_cli.egg-info/requires.txt +0 -0
- {fufu_cloud_cli-0.8.0 → fufu_cloud_cli-0.9.1}/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.
|
|
3
|
+
Version: 0.9.1
|
|
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,12 +22,17 @@ 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.1 統一所有 CLI API 請求的 `User-Agent: fufu-cloud-cli`,涵蓋物件傳輸、
|
|
26
|
+
Cloudflare 靜態檔案部署、IaC 遠端 state 與 Cloud Shell session,避免沿用
|
|
27
|
+
Python 預設識別而遇到 Cloudflare HTTP 403/1010。識別標頭不取代 credential
|
|
28
|
+
或 IAM;HTTPS 驗證、禁止重新導向與不使用環境代理的行為保留。
|
|
29
|
+
|
|
25
30
|
0.8.0 包含資源配額、分段傳輸、Terraform 核心子集與 Cloudflare 靜態部署/R2。需要同步更新服務端;CLI 安裝成功不表示任意 FUFU 服務端都已有這些能力。
|
|
26
31
|
|
|
27
32
|
## Cloudflare 前端部署與 R2
|
|
28
33
|
|
|
29
34
|
```bash
|
|
30
|
-
pip install 'fufu-cloud-cli==0.
|
|
35
|
+
pip install 'fufu-cloud-cli==0.9.1'
|
|
31
36
|
export FUFU_CLOUDFLARE_CREDENTIAL_FILE=/run/secrets/fufu-cloudflare.json
|
|
32
37
|
wrangler-fufu capabilities
|
|
33
38
|
wrangler-fufu pages project create frontend --production-branch main
|
|
@@ -88,6 +93,13 @@ npx --no-install aws-fufu --help
|
|
|
88
93
|
|
|
89
94
|
## 憑證與 CI
|
|
90
95
|
|
|
96
|
+
目前工作樹另提供 `fufu tofu`、`fufu pulumi`、`fufu state`。
|
|
97
|
+
Terraform/OpenTofu 可選擇租戶遠端 HTTP state,具備鎖、加密、版本與
|
|
98
|
+
復原;需配套 Console API 和平台金鑰。Pulumi 使用固定官方 Node.js SDK,
|
|
99
|
+
已驗收 SQS、SSM String、Pub/Sub topic/pull subscription,採獨立本機
|
|
100
|
+
DIY backend,秘密欄位需外部 passphrase。這些新能力尚未包含在已發布
|
|
101
|
+
的 0.8.0,不能把套件安裝成功當成支援驗收。
|
|
102
|
+
|
|
91
103
|
此工作樹也提供 `fufu terraform`:以官方 AWS/Google Terraform provider
|
|
92
104
|
管理 Lambda ZIP、Cloud Run v2、IAM 與服務入口的明列子集,並使用獨立的
|
|
93
105
|
FUFU state。Terraform 需另行安裝,Python 使用 `fufu-cloud-cli[terraform]`
|
|
@@ -96,6 +108,12 @@ GCS/S3、Functions Gen 1/Gen 2 等一般 CLI 能力
|
|
|
96
108
|
不表示官方 Terraform 資源已相容。正式環境使用正常 Terraform、正式認證
|
|
97
109
|
與獨立 state,不可直接搬用 FUFU state。
|
|
98
110
|
|
|
111
|
+
後續開發版另接入 SQS queue、SSM String parameter、Pub/Sub topic/pull
|
|
112
|
+
subscription;新增能力需要配套的 Console 與 Pub/Sub 後端更新,不能從
|
|
113
|
+
已發布的 0.8.0 安裝成功就推定已具備。`gcloud-fufu pubsub` 提供建立、
|
|
114
|
+
查詢、更新、刪除、publish/pull/ack 子集;不支援 push、ordering、
|
|
115
|
+
exactly-once、Function trigger 或保留期限保證。
|
|
116
|
+
|
|
99
117
|
向 FUFU Cloud 管理者取得該租戶、該 provider 的專用 credential JSON。
|
|
100
118
|
CLI 不附預設帳號、共用金鑰或可用的租戶憑證,也不讀取 `.env`、瀏覽器
|
|
101
119
|
Cookie 或正式雲端登入工作階段。
|
|
@@ -129,6 +147,32 @@ Lambda Function URL 的 AWS_IAM 支援長效 FUFU access key 的 SigV4 header si
|
|
|
129
147
|
|
|
130
148
|
## 部署與支援範圍
|
|
131
149
|
|
|
150
|
+
0.9.0 的配套後端新增 AWS/GCP Secrets Manager、GCP 部署秘密參照、
|
|
151
|
+
Cloud Run SSE/WebSocket、HTTP Functions SSE,以及目前運行版本的身分續期。
|
|
152
|
+
服務端須配置私有加密金鑰;未配置時會明確拒絕秘密操作。
|
|
153
|
+
|
|
154
|
+
```bash
|
|
155
|
+
aws-fufu secretsmanager create-secret --name app-key --secret-string file://./dev-secret.txt
|
|
156
|
+
aws-fufu secretsmanager get-secret-value --secret-id app-key --out-file ./private-result.txt
|
|
157
|
+
gcloud-fufu secrets create app-key --data-file ./dev-secret.txt
|
|
158
|
+
gcloud-fufu run deploy example --source ./prepared-asgi.zip \
|
|
159
|
+
--service-account "$WORKLOAD_SERVICE_ACCOUNT" --set-secrets APP_KEY=app-key:1
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
秘密值只由受控檔案或 `--secret-env VARIABLE` 輸入;讀取需指定私有新檔案,
|
|
163
|
+
不會輸出到終端。Cloud Run/Functions 的 service account 需 Secret Accessor
|
|
164
|
+
權限。支援 `--update-secrets`、`--clear-secrets` 和 Knative `secretKeyRef`。
|
|
165
|
+
這是本機加密版本管理子集:AWS 為 UTF-8 SecretString,單值最大 64 KiB;
|
|
166
|
+
無 KMS、rotation、跨區域複寫、排程刪除與復原。立即刪除或銷毀後,加密
|
|
167
|
+
備份與既有容器可能仍持有舊值。CLI metadata 採 FUFU 共用形狀,並非完整
|
|
168
|
+
官方 JSON;GCP runtime 使用明確 helper,無全量 Google SDK/ADC 替換。
|
|
169
|
+
新的秘密資源與 Terraform/OpenTofu/Pulumi 參照尚未接通。
|
|
170
|
+
|
|
171
|
+
Cloud Run 串流回應不設原本的 32 MiB 緩衝上限,仍受服務期限及容量限制。
|
|
172
|
+
HTTP Functions Gen 1/Gen 2 支援 SSE、無 WebSocket;Gen 2 保留較寬鬆的
|
|
173
|
+
32 MiB response,而官方 streaming response 是 10 MB。Lambda 仍為 buffered。
|
|
174
|
+
公網 DNS、TLS、WAF 及 CDN 期限須另外驗證。
|
|
175
|
+
|
|
132
176
|
```bash
|
|
133
177
|
fufu capabilities
|
|
134
178
|
fufu capabilities --remote --provider gcp --require cloud-run-http
|
|
@@ -4,12 +4,17 @@
|
|
|
4
4
|
`gcloud-fufu`、`az-fufu`、`wrangler-fufu`。這是 AWS、Google Cloud、Azure 與 Cloudflare 的**明確子集**,
|
|
5
5
|
只供 FUFU mock 環境使用;不會把部署轉送到正式雲端。
|
|
6
6
|
|
|
7
|
+
0.9.1 統一所有 CLI API 請求的 `User-Agent: fufu-cloud-cli`,涵蓋物件傳輸、
|
|
8
|
+
Cloudflare 靜態檔案部署、IaC 遠端 state 與 Cloud Shell session,避免沿用
|
|
9
|
+
Python 預設識別而遇到 Cloudflare HTTP 403/1010。識別標頭不取代 credential
|
|
10
|
+
或 IAM;HTTPS 驗證、禁止重新導向與不使用環境代理的行為保留。
|
|
11
|
+
|
|
7
12
|
0.8.0 包含資源配額、分段傳輸、Terraform 核心子集與 Cloudflare 靜態部署/R2。需要同步更新服務端;CLI 安裝成功不表示任意 FUFU 服務端都已有這些能力。
|
|
8
13
|
|
|
9
14
|
## Cloudflare 前端部署與 R2
|
|
10
15
|
|
|
11
16
|
```bash
|
|
12
|
-
pip install 'fufu-cloud-cli==0.
|
|
17
|
+
pip install 'fufu-cloud-cli==0.9.1'
|
|
13
18
|
export FUFU_CLOUDFLARE_CREDENTIAL_FILE=/run/secrets/fufu-cloudflare.json
|
|
14
19
|
wrangler-fufu capabilities
|
|
15
20
|
wrangler-fufu pages project create frontend --production-branch main
|
|
@@ -70,6 +75,13 @@ npx --no-install aws-fufu --help
|
|
|
70
75
|
|
|
71
76
|
## 憑證與 CI
|
|
72
77
|
|
|
78
|
+
目前工作樹另提供 `fufu tofu`、`fufu pulumi`、`fufu state`。
|
|
79
|
+
Terraform/OpenTofu 可選擇租戶遠端 HTTP state,具備鎖、加密、版本與
|
|
80
|
+
復原;需配套 Console API 和平台金鑰。Pulumi 使用固定官方 Node.js SDK,
|
|
81
|
+
已驗收 SQS、SSM String、Pub/Sub topic/pull subscription,採獨立本機
|
|
82
|
+
DIY backend,秘密欄位需外部 passphrase。這些新能力尚未包含在已發布
|
|
83
|
+
的 0.8.0,不能把套件安裝成功當成支援驗收。
|
|
84
|
+
|
|
73
85
|
此工作樹也提供 `fufu terraform`:以官方 AWS/Google Terraform provider
|
|
74
86
|
管理 Lambda ZIP、Cloud Run v2、IAM 與服務入口的明列子集,並使用獨立的
|
|
75
87
|
FUFU state。Terraform 需另行安裝,Python 使用 `fufu-cloud-cli[terraform]`
|
|
@@ -78,6 +90,12 @@ GCS/S3、Functions Gen 1/Gen 2 等一般 CLI 能力
|
|
|
78
90
|
不表示官方 Terraform 資源已相容。正式環境使用正常 Terraform、正式認證
|
|
79
91
|
與獨立 state,不可直接搬用 FUFU state。
|
|
80
92
|
|
|
93
|
+
後續開發版另接入 SQS queue、SSM String parameter、Pub/Sub topic/pull
|
|
94
|
+
subscription;新增能力需要配套的 Console 與 Pub/Sub 後端更新,不能從
|
|
95
|
+
已發布的 0.8.0 安裝成功就推定已具備。`gcloud-fufu pubsub` 提供建立、
|
|
96
|
+
查詢、更新、刪除、publish/pull/ack 子集;不支援 push、ordering、
|
|
97
|
+
exactly-once、Function trigger 或保留期限保證。
|
|
98
|
+
|
|
81
99
|
向 FUFU Cloud 管理者取得該租戶、該 provider 的專用 credential JSON。
|
|
82
100
|
CLI 不附預設帳號、共用金鑰或可用的租戶憑證,也不讀取 `.env`、瀏覽器
|
|
83
101
|
Cookie 或正式雲端登入工作階段。
|
|
@@ -111,6 +129,32 @@ Lambda Function URL 的 AWS_IAM 支援長效 FUFU access key 的 SigV4 header si
|
|
|
111
129
|
|
|
112
130
|
## 部署與支援範圍
|
|
113
131
|
|
|
132
|
+
0.9.0 的配套後端新增 AWS/GCP Secrets Manager、GCP 部署秘密參照、
|
|
133
|
+
Cloud Run SSE/WebSocket、HTTP Functions SSE,以及目前運行版本的身分續期。
|
|
134
|
+
服務端須配置私有加密金鑰;未配置時會明確拒絕秘密操作。
|
|
135
|
+
|
|
136
|
+
```bash
|
|
137
|
+
aws-fufu secretsmanager create-secret --name app-key --secret-string file://./dev-secret.txt
|
|
138
|
+
aws-fufu secretsmanager get-secret-value --secret-id app-key --out-file ./private-result.txt
|
|
139
|
+
gcloud-fufu secrets create app-key --data-file ./dev-secret.txt
|
|
140
|
+
gcloud-fufu run deploy example --source ./prepared-asgi.zip \
|
|
141
|
+
--service-account "$WORKLOAD_SERVICE_ACCOUNT" --set-secrets APP_KEY=app-key:1
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
秘密值只由受控檔案或 `--secret-env VARIABLE` 輸入;讀取需指定私有新檔案,
|
|
145
|
+
不會輸出到終端。Cloud Run/Functions 的 service account 需 Secret Accessor
|
|
146
|
+
權限。支援 `--update-secrets`、`--clear-secrets` 和 Knative `secretKeyRef`。
|
|
147
|
+
這是本機加密版本管理子集:AWS 為 UTF-8 SecretString,單值最大 64 KiB;
|
|
148
|
+
無 KMS、rotation、跨區域複寫、排程刪除與復原。立即刪除或銷毀後,加密
|
|
149
|
+
備份與既有容器可能仍持有舊值。CLI metadata 採 FUFU 共用形狀,並非完整
|
|
150
|
+
官方 JSON;GCP runtime 使用明確 helper,無全量 Google SDK/ADC 替換。
|
|
151
|
+
新的秘密資源與 Terraform/OpenTofu/Pulumi 參照尚未接通。
|
|
152
|
+
|
|
153
|
+
Cloud Run 串流回應不設原本的 32 MiB 緩衝上限,仍受服務期限及容量限制。
|
|
154
|
+
HTTP Functions Gen 1/Gen 2 支援 SSE、無 WebSocket;Gen 2 保留較寬鬆的
|
|
155
|
+
32 MiB response,而官方 streaming response 是 10 MB。Lambda 仍為 buffered。
|
|
156
|
+
公網 DNS、TLS、WAF 及 CDN 期限須另外驗證。
|
|
157
|
+
|
|
114
158
|
```bash
|
|
115
159
|
fufu capabilities
|
|
116
160
|
fufu capabilities --remote --provider gcp --require cloud-run-http
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "fufu-cloud-cli"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.9.1"
|
|
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"
|
|
@@ -28,4 +28,4 @@ wrangler-fufu = "fufu_cloud_cli.cli:wrangler_main"
|
|
|
28
28
|
where = ["src"]
|
|
29
29
|
|
|
30
30
|
[tool.setuptools.package-data]
|
|
31
|
-
fufu_cloud_cli = ["capabilities.json", "runtime/*/Dockerfile", "runtime/ingress/proxy.py"]
|
|
31
|
+
fufu_cloud_cli = ["capabilities.json", "pulumi-endpoints.json", "runtime/*/Dockerfile", "runtime/ingress/proxy.py"]
|
|
@@ -19,6 +19,7 @@ PROVIDERS = {"aws", "gcp", "azure", "cloudflare"}
|
|
|
19
19
|
from .limits import JSON_BODY_BYTES, JSON_RESPONSE_BYTES
|
|
20
20
|
|
|
21
21
|
MAX_RESPONSE = 8 * 1024 * 1024
|
|
22
|
+
HTTP_USER_AGENT = "fufu-cloud-cli"
|
|
22
23
|
|
|
23
24
|
|
|
24
25
|
@contextmanager
|
|
@@ -49,6 +50,15 @@ class NoRedirect(HTTPRedirectHandler):
|
|
|
49
50
|
return None
|
|
50
51
|
|
|
51
52
|
|
|
53
|
+
def api_opener():
|
|
54
|
+
"""Keep the CLI identity on every request, including binary and IaC traffic."""
|
|
55
|
+
opener = build_opener(ProxyHandler({}), NoRedirect(), HTTPSHandler(context=ssl.create_default_context()))
|
|
56
|
+
# Python-urllib's default identity can trigger Cloudflare error 1010. Set
|
|
57
|
+
# this at the transport boundary so new request paths inherit it as well.
|
|
58
|
+
opener.addheaders = [("User-Agent", HTTP_USER_AGENT)]
|
|
59
|
+
return opener
|
|
60
|
+
|
|
61
|
+
|
|
52
62
|
def endpoint_url(value):
|
|
53
63
|
require(isinstance(value, str) and value == value.strip(), "INVALID_ENDPOINT", "請提供有效的 FUFU API endpoint。")
|
|
54
64
|
parsed = urlsplit(value)
|
|
@@ -113,7 +123,7 @@ def load_credential(provider=None, filename=None):
|
|
|
113
123
|
class ApiClient:
|
|
114
124
|
def __init__(self, provider=None, filename=None, read_only=False):
|
|
115
125
|
self.owns_token = True
|
|
116
|
-
self.opener =
|
|
126
|
+
self.opener = api_opener()
|
|
117
127
|
if os.environ.get('FUFU_SESSION_FILE') and not filename:
|
|
118
128
|
p = Path(os.environ['FUFU_SESSION_FILE'])
|
|
119
129
|
require(p.is_file() and p.stat().st_size <= 16384, 'INVALID_SESSION', 'Session 檔案無效。')
|
|
@@ -133,7 +143,6 @@ class ApiClient:
|
|
|
133
143
|
credential, self.endpoint = load_credential(provider, filename)
|
|
134
144
|
self.provider = credential["provider"]
|
|
135
145
|
self.token = None
|
|
136
|
-
self.opener = build_opener(ProxyHandler({}), NoRedirect(), HTTPSHandler(context=ssl.create_default_context()))
|
|
137
146
|
scopes = [f"{self.provider}:read"] + ([] if read_only else [f"{self.provider}:write"])
|
|
138
147
|
issued = self._request("POST", "/api/auth/token", {**credential, "scopes": scopes,
|
|
139
148
|
**({'assume_role': os.environ['FUFU_ASSUME_ROLE']} if os.environ.get('FUFU_ASSUME_ROLE') else {})})
|
|
@@ -157,7 +166,7 @@ class ApiClient:
|
|
|
157
166
|
def _request(self, method, path, body=None, *, large=False):
|
|
158
167
|
require(path.startswith("/api/") and "\r" not in path and "\n" not in path,
|
|
159
168
|
"INVALID_REQUEST", "API 路徑無效。")
|
|
160
|
-
headers = {"Accept": "application/json"
|
|
169
|
+
headers = {"Accept": "application/json"}
|
|
161
170
|
if self.token:
|
|
162
171
|
headers["Authorization"] = f"Bearer {self.token}"
|
|
163
172
|
data = None if body is None else json.dumps(body).encode("utf-8")
|
|
@@ -183,7 +192,7 @@ class ApiClient:
|
|
|
183
192
|
raise FufuError("API_UNREACHABLE", "無法在時限內安全連線至 FUFU API。") from None
|
|
184
193
|
|
|
185
194
|
def request(self, method, path, body=None):
|
|
186
|
-
return self._request(method, f"/api/cloud/{self.provider}" + path, body, large=path.startswith(('/compute/', '/s3/', '/storage/', '/control/native', '/sites')))
|
|
195
|
+
return self._request(method, f"/api/cloud/{self.provider}" + path, body, large=path.startswith(('/compute/', '/s3/', '/storage/', '/control/native', '/control/pubsub', '/sites')))
|
|
187
196
|
|
|
188
197
|
def whoami(self):
|
|
189
198
|
data = self._request("GET", "/api/auth/whoami")
|
|
@@ -27,9 +27,23 @@
|
|
|
27
27
|
"backend": "api",
|
|
28
28
|
"compatibility": "official-provider-subset",
|
|
29
29
|
"providers": { "hashicorp/aws": "6.63.0", "hashicorp/google": "8.1.0" },
|
|
30
|
-
"resources": ["aws_iam_role", "aws_iam_role_policy", "aws_lambda_function", "aws_lambda_function_url", "aws_lambda_permission", "google_service_account", "google_cloud_run_v2_service", "google_cloud_run_v2_service_iam_member"],
|
|
30
|
+
"resources": ["aws_iam_role", "aws_iam_role_policy", "aws_lambda_function", "aws_lambda_function_url", "aws_lambda_permission", "google_service_account", "google_cloud_run_v2_service", "google_cloud_run_v2_service_iam_member", "aws_sqs_queue", "aws_ssm_parameter", "google_pubsub_topic", "google_pubsub_subscription"],
|
|
31
31
|
"operations": ["init", "plan", "saved-plan-apply", "refresh", "update", "import", "destroy"],
|
|
32
|
-
"
|
|
32
|
+
"engines": { "terraform": "1.16.1", "opentofu": "1.12.6" },
|
|
33
|
+
"state": ["isolated-local", "tenant-remote-http", "context-binding"],
|
|
34
|
+
"limitations": ["Linux-amd64-verified", "terraform-extra-required", "root-default-providers-only", "OpenTofu-data-resources-verified-compute-not-retested", "no-full-cloud-IAM-or-runtime-equivalence", "no-Terraform-storage-or-Functions-Gen1-Gen2", "PubSub-requires-patched-emulator", "unsupported-APIs-and-fields-fail-explicitly"]
|
|
35
|
+
},
|
|
36
|
+
"iac-remote-state": {
|
|
37
|
+
"status": "implemented", "backend": "api", "mode": "simplified",
|
|
38
|
+
"operations": ["http-backend", "atomic-lock", "encrypted-state", "history", "restore", "private-export", "delete-empty", "AWS-GCP-console"],
|
|
39
|
+
"limitations": ["key-file-required", "single-host-local-SQLite", "64MiB-per-state", "32-versions", "1000-state-list-limit", "no-lock-auto-expiry", "unrestricted-provider-credential-required", "no-terminal-or-role-sessions", "no-multi-host-HA", "no-Pulumi-remote-backend", "no-online-key-rotation"]
|
|
40
|
+
},
|
|
41
|
+
"pulumi-data": {
|
|
42
|
+
"status": "implemented", "backend": "api", "compatibility": "official-provider-subset",
|
|
43
|
+
"providers": { "pulumi": "3.261.0", "aws": "7.45.0", "gcp": "9.36.1" },
|
|
44
|
+
"resources": ["aws:sqs/queue:Queue", "aws:ssm/parameter:Parameter", "gcp:pubsub/topic:Topic", "gcp:pubsub/subscription:Subscription"],
|
|
45
|
+
"operations": ["preview", "up", "refresh", "update", "import", "destroy"],
|
|
46
|
+
"limitations": ["Node.js-runtime", "default-providers", "separate-local-DIY-state", "external-passphrase-required", "secret-fields-encrypted-not-whole-state", "no-remote-backend", "no-compute-resource-acceptance", "trusted-programs-only"]
|
|
33
47
|
},
|
|
34
48
|
"cloud-run-http": {
|
|
35
49
|
"status": "implemented",
|
|
@@ -41,6 +55,9 @@
|
|
|
41
55
|
"describe",
|
|
42
56
|
"list",
|
|
43
57
|
"invoke-http",
|
|
58
|
+
"SSE-streaming",
|
|
59
|
+
"WebSocket",
|
|
60
|
+
"secret-env-references",
|
|
44
61
|
"delete"
|
|
45
62
|
],
|
|
46
63
|
"limitations": [
|
|
@@ -99,7 +116,7 @@
|
|
|
99
116
|
"no-async-Invoke",
|
|
100
117
|
"remote-ZIP-50MiB-expanded-250MiB",
|
|
101
118
|
"synchronous-payload-6MiB",
|
|
102
|
-
"workload-
|
|
119
|
+
"workload-lease-24h-renewable-while-active",
|
|
103
120
|
"per-revision-network"
|
|
104
121
|
],
|
|
105
122
|
"backends": [
|
|
@@ -111,8 +128,8 @@
|
|
|
111
128
|
"status": "implemented",
|
|
112
129
|
"backend": "api",
|
|
113
130
|
"mode": "simplified",
|
|
114
|
-
"operations": ["stable-service-https-url", "enable-disable", "service-invoker-policy", "cloud-run-ingress-annotation", "audience-bound-id-token", "lambda-function-url-config", "lambda-url-resource-policy", "aws-sigv4-header-auth"],
|
|
115
|
-
"limitations": ["operator-DNS-TLS-WAF-required", "Cloud-Run-and-HTTP-Functions-and-Lambda-only", "buffered-Lambda-6MiB-Gen1-10MiB-Run-Gen2-
|
|
131
|
+
"operations": ["stable-service-https-url", "enable-disable", "service-invoker-policy", "cloud-run-ingress-annotation", "audience-bound-id-token", "lambda-function-url-config", "lambda-url-resource-policy", "aws-sigv4-header-auth", "Cloud-Run-SSE-WebSocket", "Functions-Gen1-Gen2-SSE"],
|
|
132
|
+
"limitations": ["operator-DNS-TLS-WAF-required", "Cloud-Run-and-HTTP-Functions-and-Lambda-only", "buffered-Lambda-6MiB-Gen1-10MiB-Gen2-32MiB", "Cloud-Run-streaming-no-response-size-quota", "Gen2-streaming-more-generous-than-official-10MB", "configured-service-timeout-up-to-900s-540s-3600s", "external-edge-timeouts-independent", "no-Lambda-streaming-or-Functions-WebSocket", "no-STS-or-presigned-URL", "FUFU-ID-token-issuer-not-Google", "internal-means-platform-not-VPC", "6000-requests-per-minute-per-service-and-host-capacity", "no-custom-domain-or-CORS-management", "no-full-cloud-IAM-equivalence"]
|
|
116
133
|
},
|
|
117
134
|
"serverless-framework": {
|
|
118
135
|
"status": "unsupported",
|
|
@@ -257,7 +274,13 @@
|
|
|
257
274
|
"delete-queue",
|
|
258
275
|
"send-message",
|
|
259
276
|
"receive-message",
|
|
260
|
-
"delete-message"
|
|
277
|
+
"delete-message",
|
|
278
|
+
"get-queue-attributes",
|
|
279
|
+
"set-queue-attributes",
|
|
280
|
+
"list-queue-tags",
|
|
281
|
+
"tag-queue",
|
|
282
|
+
"untag-queue",
|
|
283
|
+
"terraform-lifecycle"
|
|
261
284
|
],
|
|
262
285
|
"limitations": [
|
|
263
286
|
"standard-queues",
|
|
@@ -266,6 +289,7 @@
|
|
|
266
289
|
"receive-max-10",
|
|
267
290
|
"fufu-QueueUrl",
|
|
268
291
|
"no-FIFO",
|
|
292
|
+
"no-KMS-or-SQS-encryption",
|
|
269
293
|
"manual-Lambda-drain-only",
|
|
270
294
|
"no-background-poller"
|
|
271
295
|
]
|
|
@@ -277,7 +301,12 @@
|
|
|
277
301
|
"put-parameter",
|
|
278
302
|
"get-parameter",
|
|
279
303
|
"get-parameters",
|
|
280
|
-
"delete-parameter"
|
|
304
|
+
"delete-parameter",
|
|
305
|
+
"describe-parameters-exact-name",
|
|
306
|
+
"list-tags-for-resource",
|
|
307
|
+
"add-tags-to-resource",
|
|
308
|
+
"remove-tags-from-resource",
|
|
309
|
+
"terraform-lifecycle"
|
|
281
310
|
],
|
|
282
311
|
"limitations": [
|
|
283
312
|
"String-only",
|
|
@@ -298,8 +327,11 @@
|
|
|
298
327
|
},
|
|
299
328
|
"gcp-pubsub": {
|
|
300
329
|
"name": "Pub/Sub",
|
|
301
|
-
"status": "
|
|
302
|
-
"
|
|
330
|
+
"status": "implemented",
|
|
331
|
+
"backend": "api",
|
|
332
|
+
"mode": "simplified",
|
|
333
|
+
"operations": ["topic-crud", "pull-subscription-crud", "labels", "ack-deadline", "publish", "pull", "ack", "terraform-lifecycle"],
|
|
334
|
+
"limitations": ["patched-PubSub-emulator-required", "no-push", "no-ordering", "no-exactly-once", "no-Function-trigger", "no-retention-timing-guarantee", "no-Google-Python-SDK-injection"]
|
|
303
335
|
},
|
|
304
336
|
"gcp-cloud-tasks": {
|
|
305
337
|
"name": "Cloud Tasks",
|
|
@@ -308,8 +340,9 @@
|
|
|
308
340
|
},
|
|
309
341
|
"gcp-secret-manager": {
|
|
310
342
|
"name": "Secret Manager",
|
|
311
|
-
"status": "
|
|
312
|
-
"
|
|
343
|
+
"status": "implemented", "backend": "api", "mode": "simplified",
|
|
344
|
+
"operations": ["create-list-describe-delete", "version-add-list-access-enable-disable-destroy", "encrypted-at-rest", "IAM-project-bindings", "deployment-env-references", "explicit-runtime-helper", "AWS-GCP-console-three-locales"],
|
|
345
|
+
"limitations": ["private-key-file-required", "64KiB-payload", "same-project-env-only", "no-secret-volumes", "no-Google-ADC-SDK-injection", "no-per-secret-IAM", "no-KMS-rotation-replication", "immediate-logical-deletion-backups-may-retain-ciphertext", "common-FUFU-metadata-response", "new-IaC-secret-resources-not-integrated"]
|
|
313
346
|
},
|
|
314
347
|
"gcp-firebase-auth": {
|
|
315
348
|
"name": "Firebase Authentication",
|
|
@@ -318,8 +351,9 @@
|
|
|
318
351
|
},
|
|
319
352
|
"aws-secrets-manager": {
|
|
320
353
|
"name": "Secrets Manager",
|
|
321
|
-
"status": "
|
|
322
|
-
"
|
|
354
|
+
"status": "implemented", "backend": "api", "mode": "simplified",
|
|
355
|
+
"operations": ["create-list-describe-delete", "put-get-list-versions", "encrypted-at-rest", "boto3-runtime-reads", "IAM-action-resource-enforcement", "AWS-GCP-console-three-locales"],
|
|
356
|
+
"limitations": ["private-key-file-required", "64KiB-UTF8-SecretString", "no-SecretBinary", "AWSCURRENT-stage-only", "no-KMS-rotation-replication", "force-delete-no-recovery-window", "backups-may-retain-ciphertext", "common-FUFU-CLI-metadata-response", "no-cross-provider-workload-binding", "new-IaC-secret-resources-not-integrated"]
|
|
323
357
|
},
|
|
324
358
|
"aws-eventbridge-scheduler": {
|
|
325
359
|
"name": "EventBridge Scheduler",
|
|
@@ -122,6 +122,9 @@ def fufu_parser():
|
|
|
122
122
|
c.add_argument('--remote', action='store_true')
|
|
123
123
|
c.add_argument('--provider', choices=['aws', 'gcp', 'cloudflare'], default='gcp')
|
|
124
124
|
sub.add_parser('terraform', help='官方 AWS / Google Terraform provider 的 FUFU 開發環境;使用獨立 state')
|
|
125
|
+
sub.add_parser('tofu', help='官方 OpenTofu;支援本機或遠端 FUFU state')
|
|
126
|
+
sub.add_parser('pulumi', help='官方 Pulumi Node.js SDK;使用獨立 FUFU 開發 state')
|
|
127
|
+
sub.add_parser('state', help='管理 Terraform/OpenTofu 遠端 state、版本與鎖')
|
|
125
128
|
for action in ("plan", "deploy"):
|
|
126
129
|
q = sub.add_parser(action)
|
|
127
130
|
q.add_argument("--file", required=True)
|
|
@@ -198,10 +201,11 @@ def fufu_command(argv):
|
|
|
198
201
|
"images": available, "auth": "local-docker-permission", "remote_cloud": False}
|
|
199
202
|
if a.action == "deploy":
|
|
200
203
|
spec = manifest.load(a.file)
|
|
204
|
+
require(not spec.get('secret_references') or api_selected(), 'API_BACKEND_REQUIRED', 'Secret references 需要 FUFU API 模式。')
|
|
201
205
|
configure_manifest(spec)
|
|
202
206
|
spec["env"].update(env_from_names(a.env))
|
|
203
207
|
return configure_manifest(spec, compute_backend('cloud-run-http').deploy(kind="cloud-run-http", service=spec["name"], image=spec["image"],
|
|
204
|
-
port=spec["port"], env=spec["env"], startup_timeout=a.startup_timeout, resources={**spec.get('resources', {}), **resource_options(a, 'cloud-run-http')}, **({'role': spec['role']} if spec.get('role') else {})))
|
|
208
|
+
port=spec["port"], env=spec["env"], startup_timeout=a.startup_timeout, resources={**spec.get('resources', {}), **resource_options(a, 'cloud-run-http')}, **({'secret_references': spec['secret_references']} if spec.get('secret_references') else {}), **({'role': spec['role']} if spec.get('role') else {})))
|
|
205
209
|
if a.action == "down":
|
|
206
210
|
require(not api_selected(), 'UNSUPPORTED_ARGUMENT', 'API 模式請逐一刪除指定服務;不提供整個租戶清空。')
|
|
207
211
|
return Backend().down()
|
|
@@ -232,6 +236,8 @@ def gcloud_command(argv):
|
|
|
232
236
|
d.add_argument('--entry-point', default='main:app', help='FUFU ASGI 來源子集,預設 main:app')
|
|
233
237
|
d.add_argument("--port", type=int, default=8080)
|
|
234
238
|
d.add_argument('--service-account')
|
|
239
|
+
secrets = d.add_mutually_exclusive_group()
|
|
240
|
+
secrets.add_argument('--set-secrets'); secrets.add_argument('--update-secrets'); secrets.add_argument('--clear-secrets', action='store_true')
|
|
235
241
|
deployment_options(d)
|
|
236
242
|
deployment_flags(d); run_iam_flags(d)
|
|
237
243
|
services = run_sub.add_parser("services").add_subparsers(dest="service_action", required=True)
|
|
@@ -248,6 +254,8 @@ def gcloud_command(argv):
|
|
|
248
254
|
d.add_argument("--runtime", choices=["python312"], required=True)
|
|
249
255
|
d.add_argument("--entry-point", required=True)
|
|
250
256
|
d.add_argument('--service-account')
|
|
257
|
+
secrets = d.add_mutually_exclusive_group()
|
|
258
|
+
secrets.add_argument('--set-secrets'); secrets.add_argument('--update-secrets'); secrets.add_argument('--clear-secrets', action='store_true')
|
|
251
259
|
generation_options(d)
|
|
252
260
|
d.add_argument("--trigger-http", required=True, action="store_true")
|
|
253
261
|
deployment_options(d)
|
|
@@ -264,28 +272,34 @@ def gcloud_command(argv):
|
|
|
264
272
|
action = a.service_action if a.action == "services" else a.action
|
|
265
273
|
if action == "replace":
|
|
266
274
|
spec = manifest.load(a.file)
|
|
275
|
+
require(not spec.get('secret_references') or api_selected(), 'API_BACKEND_REQUIRED', 'Secret references 需要 FUFU API 模式。')
|
|
267
276
|
configure_manifest(spec)
|
|
268
277
|
spec["env"].update(env_from_names(a.env))
|
|
269
278
|
return configure_manifest(spec, compute_backend(kind).deploy(kind=kind, service=spec["name"], image=spec["image"], port=spec["port"],
|
|
270
|
-
env=spec["env"], startup_timeout=a.startup_timeout, resources={**spec.get('resources', {}), **resource_options(a, kind)}, **({'role': spec['role']} if spec.get('role') else {})))
|
|
279
|
+
env=spec["env"], startup_timeout=a.startup_timeout, resources={**spec.get('resources', {}), **resource_options(a, kind)}, **({'secret_references': spec['secret_references']} if spec.get('secret_references') else {}), **({'role': spec['role']} if spec.get('role') else {})))
|
|
271
280
|
if action == "deploy":
|
|
272
281
|
manifest.name(a.name)
|
|
273
282
|
configuration(a, kind)
|
|
274
283
|
finish = lambda deployed: configure_gcp(a, kind, a.name, deployed)
|
|
275
284
|
env = env_from_names(a.env)
|
|
285
|
+
from .secrets import references
|
|
286
|
+
secret_options = {}
|
|
287
|
+
if a.set_secrets is not None or a.clear_secrets: secret_options['secret_references'] = references(a.set_secrets)
|
|
288
|
+
if a.update_secrets is not None: secret_options['secret_updates'] = references(a.update_secrets)
|
|
289
|
+
require(not secret_options or api_selected(), 'API_BACKEND_REQUIRED', 'Secret references 需要 FUFU API 模式。')
|
|
276
290
|
if a.service == 'run' and a.source:
|
|
277
291
|
require(api_selected(), 'API_BACKEND_REQUIRED', 'Cloud Run --source 自動核准需要 FUFU API 模式。')
|
|
278
292
|
return finish(RemoteBackend(kind).deploy(kind=kind, service=a.name, env=env, port=a.port,
|
|
279
293
|
archive=cloud_run_archive(a.source), runtime='python312', handler=a.entry_point,
|
|
280
|
-
startup_timeout=a.startup_timeout, role=a.service_account, resources=resource_options(a, kind)))
|
|
294
|
+
startup_timeout=a.startup_timeout, role=a.service_account, resources=resource_options(a, kind), **secret_options))
|
|
281
295
|
if a.service == 'run':
|
|
282
296
|
require(a.entry_point == 'main:app', 'UNSUPPORTED_ARGUMENT', '--entry-point 只用於 --source。')
|
|
283
297
|
if a.service == 'functions' and api_selected():
|
|
284
298
|
return finish(RemoteBackend(kind).deploy(kind=kind, service=a.name, env=env,
|
|
285
|
-
archive=source_archive(a.source, kind), runtime=a.runtime, handler=a.entry_point, startup_timeout=a.startup_timeout, role=a.service_account, resources=resource_options(a, kind)))
|
|
299
|
+
archive=source_archive(a.source, kind), runtime=a.runtime, handler=a.entry_point, startup_timeout=a.startup_timeout, role=a.service_account, resources=resource_options(a, kind), **secret_options))
|
|
286
300
|
image = a.image if a.service == "run" else build_function(a.source, a.entry_point)
|
|
287
301
|
return finish(compute_backend(kind).deploy(kind=kind, service=a.name, image=image, port=getattr(a, "port", 8080),
|
|
288
|
-
env=env, startup_timeout=a.startup_timeout, resources=resource_options(a, kind), **({'role': a.service_account} if a.service_account else {})))
|
|
302
|
+
env=env, startup_timeout=a.startup_timeout, resources=resource_options(a, kind), **secret_options, **({'role': a.service_account} if a.service_account else {})))
|
|
289
303
|
b = compute_backend(kind)
|
|
290
304
|
if action == "describe":
|
|
291
305
|
return b.describe(kind, a.name)
|
|
@@ -386,9 +400,15 @@ def entry(provider, argv=None):
|
|
|
386
400
|
result = command(argv, Parser)
|
|
387
401
|
if type(result) is int: raise SystemExit(result)
|
|
388
402
|
emit(result); return
|
|
389
|
-
if provider == 'fufu' and argv[:1]
|
|
403
|
+
if provider == 'fufu' and argv[:1] in [['terraform'], ['tofu']]:
|
|
390
404
|
from .terraform_bridge.runner import command
|
|
405
|
+
raise SystemExit(command(argv[1:], Parser, engine=argv[0]))
|
|
406
|
+
if provider == 'fufu' and argv[:1] == ['pulumi']:
|
|
407
|
+
from .pulumi_bridge import command
|
|
391
408
|
raise SystemExit(command(argv[1:], Parser))
|
|
409
|
+
if provider == 'fufu' and argv[:1] == ['state']:
|
|
410
|
+
from .iac_state import command
|
|
411
|
+
emit(command(argv[1:], Parser)); return
|
|
392
412
|
# Native global selectors are checked against the authenticated tenant scope.
|
|
393
413
|
if provider in {'aws-fufu', 'gcloud-fufu'}:
|
|
394
414
|
rest = []; i = 0
|
|
@@ -408,6 +428,9 @@ def entry(provider, argv=None):
|
|
|
408
428
|
else: rest.append(argv[i]); i += 1
|
|
409
429
|
argv = rest
|
|
410
430
|
from . import public_access
|
|
431
|
+
if provider == 'aws-fufu' and argv[:1] == ['secretsmanager'] or provider == 'gcloud-fufu' and argv[:1] == ['secrets']:
|
|
432
|
+
from .secrets import command
|
|
433
|
+
emit(command('aws' if provider == 'aws-fufu' else 'gcp', argv[1:], Parser)); return
|
|
411
434
|
public_command = None
|
|
412
435
|
if provider == 'aws-fufu' and len(argv) > 1 and argv[0] == 'lambda' and argv[1] in public_access.AWS_ACTIONS:
|
|
413
436
|
public_command = public_access.aws_command
|
|
@@ -431,6 +454,9 @@ def entry(provider, argv=None):
|
|
|
431
454
|
if provider == 'gcloud-fufu' and argv and argv[0] in {'iam', 'projects'}:
|
|
432
455
|
from .control import gcp_command
|
|
433
456
|
emit(gcp_command(argv, Parser)); return
|
|
457
|
+
if provider == 'gcloud-fufu' and argv and argv[0] == 'pubsub':
|
|
458
|
+
from .pubsub import command
|
|
459
|
+
emit(command(argv[1:], Parser)); return
|
|
434
460
|
if provider == 'aws-fufu' and argv and argv[0] == 'apigatewayv2':
|
|
435
461
|
from .control import gateway_command
|
|
436
462
|
emit(gateway_command(argv[1:], Parser)); return
|
|
@@ -32,15 +32,18 @@ AWS_OPERATIONS = {
|
|
|
32
32
|
'GetObject': 'Bucket Key ChecksumMode', 'HeadObject': 'Bucket Key ChecksumMode', 'DeleteObject': 'Bucket Key',
|
|
33
33
|
},
|
|
34
34
|
'sqs': {
|
|
35
|
-
'CreateQueue': 'QueueName Attributes', 'GetQueueUrl': 'QueueName', 'ListQueues': 'QueueNamePrefix MaxResults NextToken', 'DeleteQueue': 'QueueUrl',
|
|
35
|
+
'CreateQueue': 'QueueName Attributes tags', 'GetQueueUrl': 'QueueName', 'ListQueues': 'QueueNamePrefix MaxResults NextToken', 'DeleteQueue': 'QueueUrl',
|
|
36
|
+
'ListQueueTags': 'QueueUrl', 'TagQueue': 'QueueUrl Tags', 'UntagQueue': 'QueueUrl TagKeys',
|
|
36
37
|
'GetQueueAttributes': 'QueueUrl AttributeNames', 'SetQueueAttributes': 'QueueUrl Attributes',
|
|
37
38
|
'SendMessage': 'QueueUrl MessageBody DelaySeconds MessageAttributes', 'ReceiveMessage': 'QueueUrl MaxNumberOfMessages VisibilityTimeout WaitTimeSeconds MessageAttributeNames AttributeNames',
|
|
38
39
|
'DeleteMessage': 'QueueUrl ReceiptHandle', 'ChangeMessageVisibility': 'QueueUrl ReceiptHandle VisibilityTimeout',
|
|
39
40
|
},
|
|
40
|
-
'ssm': { 'PutParameter': 'Name Value Type Overwrite Description', 'GetParameter': 'Name WithDecryption', 'GetParameters': 'Names WithDecryption', 'DeleteParameter': 'Name'
|
|
41
|
+
'ssm': { 'PutParameter': 'Name Value Type Overwrite Description Tags', 'GetParameter': 'Name WithDecryption', 'GetParameters': 'Names WithDecryption', 'DeleteParameter': 'Name',
|
|
42
|
+
'DescribeParameters': 'ParameterFilters', 'ListTagsForResource': 'ResourceType ResourceId',
|
|
43
|
+
'AddTagsToResource': 'ResourceType ResourceId Tags', 'RemoveTagsFromResource': 'ResourceType ResourceId TagKeys' },
|
|
41
44
|
}
|
|
42
|
-
JSON_FIELDS = set('AssumeRolePolicyDocument PolicyDocument AttributeDefinitions KeySchema ProvisionedThroughput GlobalSecondaryIndexes LocalSecondaryIndexes StreamSpecification Item Key ExpressionAttributeNames ExpressionAttributeValues ExclusiveStartKey RequestItems CreateBucketConfiguration Metadata Attributes MessageAttributes'.split())
|
|
43
|
-
LIST_FIELDS = set('ActionNames ResourceArns Names AttributeNames MessageAttributeNames'.split())
|
|
45
|
+
JSON_FIELDS = set('AssumeRolePolicyDocument PolicyDocument AttributeDefinitions KeySchema ProvisionedThroughput GlobalSecondaryIndexes LocalSecondaryIndexes StreamSpecification Item Key ExpressionAttributeNames ExpressionAttributeValues ExclusiveStartKey RequestItems CreateBucketConfiguration Metadata Attributes MessageAttributes Tags tags ParameterFilters'.split())
|
|
46
|
+
LIST_FIELDS = set('ActionNames ResourceArns Names AttributeNames MessageAttributeNames TagKeys'.split())
|
|
44
47
|
BOOL_FIELDS = set('ConsistentRead ScanIndexForward WithDecryption Overwrite Enabled'.split())
|
|
45
48
|
INT_FIELDS = set('Limit MaxKeys MaxResults MaxNumberOfMessages VisibilityTimeout WaitTimeSeconds DelaySeconds BatchSize'.split())
|
|
46
49
|
|
|
@@ -105,7 +108,7 @@ def command(argv, Parser):
|
|
|
105
108
|
params.setdefault('Overwrite', False)
|
|
106
109
|
params.setdefault('Type', 'String')
|
|
107
110
|
if a.service == 'sqs' and 'QueueUrl' in params:
|
|
108
|
-
require(isinstance(params['QueueUrl'], str) and re.fullmatch(r'fufu://sqs/[A-Za-z0-9_-]{1,80}', params['QueueUrl']), 'INVALID_RESOURCE', 'QueueUrl 必須是 FUFU 租戶資源識別。')
|
|
111
|
+
require(isinstance(params['QueueUrl'], str) and re.fullmatch(r'(?:fufu://sqs/|https://sqs\.[a-z0-9-]+\.fufu\.invalid/[0-9]{12}/)[A-Za-z0-9_-]{1,80}', params['QueueUrl']), 'INVALID_RESOURCE', 'QueueUrl 必須是 FUFU 租戶資源識別。')
|
|
109
112
|
with ApiClient('aws', read_only=bool(re.match(r'^(Get|List|Describe|Query|Scan|BatchGet|Simulate|Head)', a.native_operation))) as api:
|
|
110
113
|
if a.service == 's3api' and a.native_operation == 'PutObject' and isinstance(params.get('Body'), Path):
|
|
111
114
|
from .object_transfer import upload_file
|
|
@@ -239,17 +239,20 @@ class Backend:
|
|
|
239
239
|
def public(self, r):
|
|
240
240
|
return {"name": r["name"], "kind": r["kind"], "workspace": self.workspace,
|
|
241
241
|
"revision": r["revision"], "serviceId": self.service_id(r), "url": self.url(r), "readiness": "tcp",
|
|
242
|
-
**{k: r[k] for k in ("runtime", "handler", "role", "sdkTransport", "iamEnforced", "build", "resources") if k in r},
|
|
242
|
+
**{k: r[k] for k in ("runtime", "handler", "role", "sdkTransport", "iamEnforced", "build", "resources", "secretReferences") if k in r},
|
|
243
243
|
"status": "running", "backend": "local-docker"}
|
|
244
244
|
|
|
245
245
|
def deploy(self, *, kind, service, image, port=8080, env=None, command=None,
|
|
246
246
|
startup_timeout=30, replace=True, require_existing=False, runtime=None, handler=None, preserve_environment=False,
|
|
247
|
-
runtime_env=None, role=None, gateway_container=None, build=None, resources=None, source=None, image_reference=None, provider_options=None
|
|
247
|
+
runtime_env=None, role=None, gateway_container=None, build=None, resources=None, source=None, image_reference=None, provider_options=None,
|
|
248
|
+
secret_env=None, secret_references=None):
|
|
248
249
|
name(service)
|
|
249
250
|
require(kind in KINDS, "UNSUPPORTED_SERVICE", "未支援此服務。")
|
|
250
251
|
require(type(port) is int and 1024 <= port <= 65535, "INVALID_PORT", "無效的容器 port。")
|
|
251
252
|
require(1 <= startup_timeout <= 240, "INVALID_TIMEOUT", "啟動期限須介於 1–240 秒。")
|
|
252
253
|
env = environment(dict(env or {}))
|
|
254
|
+
secrets = environment(dict(secret_env or {}))
|
|
255
|
+
require(set(secrets) == set(secret_references or {}) and (not secrets or role), 'INVALID_SECRET_REFERENCE', 'Secret injection 需要對應參照與 workload role。')
|
|
253
256
|
require(not role or (runtime_env and gateway_container), 'IAM_BACKEND_REQUIRED', 'IAM workload role 需要已配置的遠端 gateway;本機 Docker 模式不假裝執行 IAM。')
|
|
254
257
|
image_id = inspect_image(image)
|
|
255
258
|
ingress_image_id = inspect_image(INGRESS_IMAGE)
|
|
@@ -284,6 +287,8 @@ class Backend:
|
|
|
284
287
|
revision = f"{service}-{uuid.uuid4().hex[:8]}"
|
|
285
288
|
cname = f"fufu-cli-{self.scope}-{uuid.uuid4().hex[:12]}"
|
|
286
289
|
supplied = dict(env)
|
|
290
|
+
require(not set(env).intersection(secrets), 'INVALID_SECRET_REFERENCE', '秘密與一般環境變數名稱衝突。')
|
|
291
|
+
supplied.update(secrets)
|
|
287
292
|
supplied.update(runtime_env or {})
|
|
288
293
|
supplied["AWS_EC2_METADATA_DISABLED"] = "true"
|
|
289
294
|
if kind == "aws-lambda-sync":
|
|
@@ -317,6 +322,7 @@ class Backend:
|
|
|
317
322
|
**({'source': source} if source else {}),
|
|
318
323
|
**({'imageReference': image_reference} if image_reference else {}),
|
|
319
324
|
'providerOptions': provider_options or {},
|
|
325
|
+
**({'secretReferences': secret_references} if secret_references else {}),
|
|
320
326
|
"environment_names": sorted(env),
|
|
321
327
|
**({"runtime": runtime, "handler": handler} if runtime is not None else {})}
|
|
322
328
|
deadline = time.monotonic() + startup_timeout
|
|
@@ -420,6 +426,20 @@ class Backend:
|
|
|
420
426
|
'source': record.get('source', {}), 'providerOptions': record.get('providerOptions', {}),
|
|
421
427
|
'_imageId': current['image'], '_build': record.get('build')}
|
|
422
428
|
|
|
429
|
+
def workload_active(self, kind, service, token_hash):
|
|
430
|
+
"""Confirm the lease belongs to the currently running revision, without returning its environment."""
|
|
431
|
+
import hmac
|
|
432
|
+
require(isinstance(token_hash, str) and re.fullmatch(r'[a-f0-9]{64}', token_hash), 'INVALID_REQUEST', 'Workload hash 無效。')
|
|
433
|
+
name(service)
|
|
434
|
+
with self.locked():
|
|
435
|
+
record = self.read_state()['services'].get(f'{kind}:{service}')
|
|
436
|
+
if not record or not record.get('role'): return False
|
|
437
|
+
self.url(record)
|
|
438
|
+
values = object_json(docker('container', 'inspect', record['container_id'], '--format', '{{json .Config.Env}}'))
|
|
439
|
+
env = dict(item.split('=', 1) for item in values if '=' in item)
|
|
440
|
+
token = env.get('FUFU_WORKLOAD_TOKEN', '')
|
|
441
|
+
return bool(token) and hmac.compare_digest(hashlib.sha256(token.encode()).hexdigest(), token_hash)
|
|
442
|
+
|
|
423
443
|
def listing(self, kind=None):
|
|
424
444
|
with self.locked():
|
|
425
445
|
return [self.public(r) for r in self.read_state()["services"].values() if kind is None or r["kind"] == kind]
|