fufu-cloud-cli 0.8.0__tar.gz → 0.9.0__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.0}/PKG-INFO +40 -1
- {fufu_cloud_cli-0.8.0 → fufu_cloud_cli-0.9.0}/README.md +39 -0
- {fufu_cloud_cli-0.8.0 → fufu_cloud_cli-0.9.0}/pyproject.toml +2 -2
- {fufu_cloud_cli-0.8.0 → fufu_cloud_cli-0.9.0}/src/fufu_cloud_cli/__init__.py +1 -1
- {fufu_cloud_cli-0.8.0 → fufu_cloud_cli-0.9.0}/src/fufu_cloud_cli/api_backend.py +1 -1
- {fufu_cloud_cli-0.8.0 → fufu_cloud_cli-0.9.0}/src/fufu_cloud_cli/capabilities.json +47 -13
- {fufu_cloud_cli-0.8.0 → fufu_cloud_cli-0.9.0}/src/fufu_cloud_cli/cli.py +32 -6
- {fufu_cloud_cli-0.8.0 → fufu_cloud_cli-0.9.0}/src/fufu_cloud_cli/control.py +8 -5
- {fufu_cloud_cli-0.8.0 → fufu_cloud_cli-0.9.0}/src/fufu_cloud_cli/docker_backend.py +22 -2
- fufu_cloud_cli-0.9.0/src/fufu_cloud_cli/iac_state.py +61 -0
- {fufu_cloud_cli-0.8.0 → fufu_cloud_cli-0.9.0}/src/fufu_cloud_cli/manifest.py +12 -5
- fufu_cloud_cli-0.9.0/src/fufu_cloud_cli/pubsub.py +66 -0
- fufu_cloud_cli-0.9.0/src/fufu_cloud_cli/pulumi-endpoints.json +526 -0
- fufu_cloud_cli-0.9.0/src/fufu_cloud_cli/pulumi_bridge.py +205 -0
- {fufu_cloud_cli-0.8.0 → fufu_cloud_cli-0.9.0}/src/fufu_cloud_cli/remote_compute.py +4 -1
- {fufu_cloud_cli-0.8.0 → fufu_cloud_cli-0.9.0}/src/fufu_cloud_cli/runtime_sdk.py +8 -2
- fufu_cloud_cli-0.9.0/src/fufu_cloud_cli/secrets.py +90 -0
- fufu_cloud_cli-0.9.0/src/fufu_cloud_cli/terraform_bridge/aws_data.py +63 -0
- {fufu_cloud_cli-0.8.0 → fufu_cloud_cli-0.9.0}/src/fufu_cloud_cli/terraform_bridge/google.py +3 -0
- fufu_cloud_cli-0.9.0/src/fufu_cloud_cli/terraform_bridge/google_pubsub.py +57 -0
- fufu_cloud_cli-0.9.0/src/fufu_cloud_cli/terraform_bridge/remote_state.py +74 -0
- {fufu_cloud_cli-0.8.0 → fufu_cloud_cli-0.9.0}/src/fufu_cloud_cli/terraform_bridge/runner.py +77 -20
- {fufu_cloud_cli-0.8.0 → fufu_cloud_cli-0.9.0/src/fufu_cloud_cli.egg-info}/PKG-INFO +40 -1
- {fufu_cloud_cli-0.8.0 → fufu_cloud_cli-0.9.0}/src/fufu_cloud_cli.egg-info/SOURCES.txt +8 -0
- {fufu_cloud_cli-0.8.0 → fufu_cloud_cli-0.9.0}/LICENSE +0 -0
- {fufu_cloud_cli-0.8.0 → fufu_cloud_cli-0.9.0}/MANIFEST.in +0 -0
- {fufu_cloud_cli-0.8.0 → fufu_cloud_cli-0.9.0}/setup.cfg +0 -0
- {fufu_cloud_cli-0.8.0 → fufu_cloud_cli-0.9.0}/src/fufu_cloud_cli/__main__.py +0 -0
- {fufu_cloud_cli-0.8.0 → fufu_cloud_cli-0.9.0}/src/fufu_cloud_cli/capacity.py +0 -0
- {fufu_cloud_cli-0.8.0 → fufu_cloud_cli-0.9.0}/src/fufu_cloud_cli/cloud_data.py +0 -0
- {fufu_cloud_cli-0.8.0 → fufu_cloud_cli-0.9.0}/src/fufu_cloud_cli/cloudflare.py +0 -0
- {fufu_cloud_cli-0.8.0 → fufu_cloud_cli-0.9.0}/src/fufu_cloud_cli/errors.py +0 -0
- {fufu_cloud_cli-0.8.0 → fufu_cloud_cli-0.9.0}/src/fufu_cloud_cli/http_runtime.py +0 -0
- {fufu_cloud_cli-0.8.0 → fufu_cloud_cli-0.9.0}/src/fufu_cloud_cli/lambda_adapter.py +0 -0
- {fufu_cloud_cli-0.8.0 → fufu_cloud_cli-0.9.0}/src/fufu_cloud_cli/limits.py +0 -0
- {fufu_cloud_cli-0.8.0 → fufu_cloud_cli-0.9.0}/src/fufu_cloud_cli/object_transfer.py +0 -0
- {fufu_cloud_cli-0.8.0 → fufu_cloud_cli-0.9.0}/src/fufu_cloud_cli/public_access.py +0 -0
- {fufu_cloud_cli-0.8.0 → fufu_cloud_cli-0.9.0}/src/fufu_cloud_cli/runtime/functions/Dockerfile +0 -0
- {fufu_cloud_cli-0.8.0 → fufu_cloud_cli-0.9.0}/src/fufu_cloud_cli/runtime/ingress/Dockerfile +0 -0
- {fufu_cloud_cli-0.8.0 → fufu_cloud_cli-0.9.0}/src/fufu_cloud_cli/runtime/ingress/proxy.py +0 -0
- {fufu_cloud_cli-0.8.0 → fufu_cloud_cli-0.9.0}/src/fufu_cloud_cli/serverless.py +0 -0
- {fufu_cloud_cli-0.8.0 → fufu_cloud_cli-0.9.0}/src/fufu_cloud_cli/terraform_bridge/__init__.py +0 -0
- {fufu_cloud_cli-0.8.0 → fufu_cloud_cli-0.9.0}/src/fufu_cloud_cli/terraform_bridge/aws.py +0 -0
- {fufu_cloud_cli-0.8.0 → fufu_cloud_cli-0.9.0}/src/fufu_cloud_cli/terraform_bridge/common.py +0 -0
- {fufu_cloud_cli-0.8.0 → fufu_cloud_cli-0.9.0}/src/fufu_cloud_cli.egg-info/dependency_links.txt +0 -0
- {fufu_cloud_cli-0.8.0 → fufu_cloud_cli-0.9.0}/src/fufu_cloud_cli.egg-info/entry_points.txt +0 -0
- {fufu_cloud_cli-0.8.0 → fufu_cloud_cli-0.9.0}/src/fufu_cloud_cli.egg-info/requires.txt +0 -0
- {fufu_cloud_cli-0.8.0 → fufu_cloud_cli-0.9.0}/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.0
|
|
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
|
|
@@ -88,6 +88,13 @@ npx --no-install aws-fufu --help
|
|
|
88
88
|
|
|
89
89
|
## 憑證與 CI
|
|
90
90
|
|
|
91
|
+
目前工作樹另提供 `fufu tofu`、`fufu pulumi`、`fufu state`。
|
|
92
|
+
Terraform/OpenTofu 可選擇租戶遠端 HTTP state,具備鎖、加密、版本與
|
|
93
|
+
復原;需配套 Console API 和平台金鑰。Pulumi 使用固定官方 Node.js SDK,
|
|
94
|
+
已驗收 SQS、SSM String、Pub/Sub topic/pull subscription,採獨立本機
|
|
95
|
+
DIY backend,秘密欄位需外部 passphrase。這些新能力尚未包含在已發布
|
|
96
|
+
的 0.8.0,不能把套件安裝成功當成支援驗收。
|
|
97
|
+
|
|
91
98
|
此工作樹也提供 `fufu terraform`:以官方 AWS/Google Terraform provider
|
|
92
99
|
管理 Lambda ZIP、Cloud Run v2、IAM 與服務入口的明列子集,並使用獨立的
|
|
93
100
|
FUFU state。Terraform 需另行安裝,Python 使用 `fufu-cloud-cli[terraform]`
|
|
@@ -96,6 +103,12 @@ GCS/S3、Functions Gen 1/Gen 2 等一般 CLI 能力
|
|
|
96
103
|
不表示官方 Terraform 資源已相容。正式環境使用正常 Terraform、正式認證
|
|
97
104
|
與獨立 state,不可直接搬用 FUFU state。
|
|
98
105
|
|
|
106
|
+
後續開發版另接入 SQS queue、SSM String parameter、Pub/Sub topic/pull
|
|
107
|
+
subscription;新增能力需要配套的 Console 與 Pub/Sub 後端更新,不能從
|
|
108
|
+
已發布的 0.8.0 安裝成功就推定已具備。`gcloud-fufu pubsub` 提供建立、
|
|
109
|
+
查詢、更新、刪除、publish/pull/ack 子集;不支援 push、ordering、
|
|
110
|
+
exactly-once、Function trigger 或保留期限保證。
|
|
111
|
+
|
|
99
112
|
向 FUFU Cloud 管理者取得該租戶、該 provider 的專用 credential JSON。
|
|
100
113
|
CLI 不附預設帳號、共用金鑰或可用的租戶憑證,也不讀取 `.env`、瀏覽器
|
|
101
114
|
Cookie 或正式雲端登入工作階段。
|
|
@@ -129,6 +142,32 @@ Lambda Function URL 的 AWS_IAM 支援長效 FUFU access key 的 SigV4 header si
|
|
|
129
142
|
|
|
130
143
|
## 部署與支援範圍
|
|
131
144
|
|
|
145
|
+
0.9.0 的配套後端新增 AWS/GCP Secrets Manager、GCP 部署秘密參照、
|
|
146
|
+
Cloud Run SSE/WebSocket、HTTP Functions SSE,以及目前運行版本的身分續期。
|
|
147
|
+
服務端須配置私有加密金鑰;未配置時會明確拒絕秘密操作。
|
|
148
|
+
|
|
149
|
+
```bash
|
|
150
|
+
aws-fufu secretsmanager create-secret --name app-key --secret-string file://./dev-secret.txt
|
|
151
|
+
aws-fufu secretsmanager get-secret-value --secret-id app-key --out-file ./private-result.txt
|
|
152
|
+
gcloud-fufu secrets create app-key --data-file ./dev-secret.txt
|
|
153
|
+
gcloud-fufu run deploy example --source ./prepared-asgi.zip \
|
|
154
|
+
--service-account "$WORKLOAD_SERVICE_ACCOUNT" --set-secrets APP_KEY=app-key:1
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
秘密值只由受控檔案或 `--secret-env VARIABLE` 輸入;讀取需指定私有新檔案,
|
|
158
|
+
不會輸出到終端。Cloud Run/Functions 的 service account 需 Secret Accessor
|
|
159
|
+
權限。支援 `--update-secrets`、`--clear-secrets` 和 Knative `secretKeyRef`。
|
|
160
|
+
這是本機加密版本管理子集:AWS 為 UTF-8 SecretString,單值最大 64 KiB;
|
|
161
|
+
無 KMS、rotation、跨區域複寫、排程刪除與復原。立即刪除或銷毀後,加密
|
|
162
|
+
備份與既有容器可能仍持有舊值。CLI metadata 採 FUFU 共用形狀,並非完整
|
|
163
|
+
官方 JSON;GCP runtime 使用明確 helper,無全量 Google SDK/ADC 替換。
|
|
164
|
+
新的秘密資源與 Terraform/OpenTofu/Pulumi 參照尚未接通。
|
|
165
|
+
|
|
166
|
+
Cloud Run 串流回應不設原本的 32 MiB 緩衝上限,仍受服務期限及容量限制。
|
|
167
|
+
HTTP Functions Gen 1/Gen 2 支援 SSE、無 WebSocket;Gen 2 保留較寬鬆的
|
|
168
|
+
32 MiB response,而官方 streaming response 是 10 MB。Lambda 仍為 buffered。
|
|
169
|
+
公網 DNS、TLS、WAF 及 CDN 期限須另外驗證。
|
|
170
|
+
|
|
132
171
|
```bash
|
|
133
172
|
fufu capabilities
|
|
134
173
|
fufu capabilities --remote --provider gcp --require cloud-run-http
|
|
@@ -70,6 +70,13 @@ npx --no-install aws-fufu --help
|
|
|
70
70
|
|
|
71
71
|
## 憑證與 CI
|
|
72
72
|
|
|
73
|
+
目前工作樹另提供 `fufu tofu`、`fufu pulumi`、`fufu state`。
|
|
74
|
+
Terraform/OpenTofu 可選擇租戶遠端 HTTP state,具備鎖、加密、版本與
|
|
75
|
+
復原;需配套 Console API 和平台金鑰。Pulumi 使用固定官方 Node.js SDK,
|
|
76
|
+
已驗收 SQS、SSM String、Pub/Sub topic/pull subscription,採獨立本機
|
|
77
|
+
DIY backend,秘密欄位需外部 passphrase。這些新能力尚未包含在已發布
|
|
78
|
+
的 0.8.0,不能把套件安裝成功當成支援驗收。
|
|
79
|
+
|
|
73
80
|
此工作樹也提供 `fufu terraform`:以官方 AWS/Google Terraform provider
|
|
74
81
|
管理 Lambda ZIP、Cloud Run v2、IAM 與服務入口的明列子集,並使用獨立的
|
|
75
82
|
FUFU state。Terraform 需另行安裝,Python 使用 `fufu-cloud-cli[terraform]`
|
|
@@ -78,6 +85,12 @@ GCS/S3、Functions Gen 1/Gen 2 等一般 CLI 能力
|
|
|
78
85
|
不表示官方 Terraform 資源已相容。正式環境使用正常 Terraform、正式認證
|
|
79
86
|
與獨立 state,不可直接搬用 FUFU state。
|
|
80
87
|
|
|
88
|
+
後續開發版另接入 SQS queue、SSM String parameter、Pub/Sub topic/pull
|
|
89
|
+
subscription;新增能力需要配套的 Console 與 Pub/Sub 後端更新,不能從
|
|
90
|
+
已發布的 0.8.0 安裝成功就推定已具備。`gcloud-fufu pubsub` 提供建立、
|
|
91
|
+
查詢、更新、刪除、publish/pull/ack 子集;不支援 push、ordering、
|
|
92
|
+
exactly-once、Function trigger 或保留期限保證。
|
|
93
|
+
|
|
81
94
|
向 FUFU Cloud 管理者取得該租戶、該 provider 的專用 credential JSON。
|
|
82
95
|
CLI 不附預設帳號、共用金鑰或可用的租戶憑證,也不讀取 `.env`、瀏覽器
|
|
83
96
|
Cookie 或正式雲端登入工作階段。
|
|
@@ -111,6 +124,32 @@ Lambda Function URL 的 AWS_IAM 支援長效 FUFU access key 的 SigV4 header si
|
|
|
111
124
|
|
|
112
125
|
## 部署與支援範圍
|
|
113
126
|
|
|
127
|
+
0.9.0 的配套後端新增 AWS/GCP Secrets Manager、GCP 部署秘密參照、
|
|
128
|
+
Cloud Run SSE/WebSocket、HTTP Functions SSE,以及目前運行版本的身分續期。
|
|
129
|
+
服務端須配置私有加密金鑰;未配置時會明確拒絕秘密操作。
|
|
130
|
+
|
|
131
|
+
```bash
|
|
132
|
+
aws-fufu secretsmanager create-secret --name app-key --secret-string file://./dev-secret.txt
|
|
133
|
+
aws-fufu secretsmanager get-secret-value --secret-id app-key --out-file ./private-result.txt
|
|
134
|
+
gcloud-fufu secrets create app-key --data-file ./dev-secret.txt
|
|
135
|
+
gcloud-fufu run deploy example --source ./prepared-asgi.zip \
|
|
136
|
+
--service-account "$WORKLOAD_SERVICE_ACCOUNT" --set-secrets APP_KEY=app-key:1
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
秘密值只由受控檔案或 `--secret-env VARIABLE` 輸入;讀取需指定私有新檔案,
|
|
140
|
+
不會輸出到終端。Cloud Run/Functions 的 service account 需 Secret Accessor
|
|
141
|
+
權限。支援 `--update-secrets`、`--clear-secrets` 和 Knative `secretKeyRef`。
|
|
142
|
+
這是本機加密版本管理子集:AWS 為 UTF-8 SecretString,單值最大 64 KiB;
|
|
143
|
+
無 KMS、rotation、跨區域複寫、排程刪除與復原。立即刪除或銷毀後,加密
|
|
144
|
+
備份與既有容器可能仍持有舊值。CLI metadata 採 FUFU 共用形狀,並非完整
|
|
145
|
+
官方 JSON;GCP runtime 使用明確 helper,無全量 Google SDK/ADC 替換。
|
|
146
|
+
新的秘密資源與 Terraform/OpenTofu/Pulumi 參照尚未接通。
|
|
147
|
+
|
|
148
|
+
Cloud Run 串流回應不設原本的 32 MiB 緩衝上限,仍受服務期限及容量限制。
|
|
149
|
+
HTTP Functions Gen 1/Gen 2 支援 SSE、無 WebSocket;Gen 2 保留較寬鬆的
|
|
150
|
+
32 MiB response,而官方 streaming response 是 10 MB。Lambda 仍為 buffered。
|
|
151
|
+
公網 DNS、TLS、WAF 及 CDN 期限須另外驗證。
|
|
152
|
+
|
|
114
153
|
```bash
|
|
115
154
|
fufu capabilities
|
|
116
155
|
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.0"
|
|
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"]
|
|
@@ -183,7 +183,7 @@ class ApiClient:
|
|
|
183
183
|
raise FufuError("API_UNREACHABLE", "無法在時限內安全連線至 FUFU API。") from None
|
|
184
184
|
|
|
185
185
|
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')))
|
|
186
|
+
return self._request(method, f"/api/cloud/{self.provider}" + path, body, large=path.startswith(('/compute/', '/s3/', '/storage/', '/control/native', '/control/pubsub', '/sites')))
|
|
187
187
|
|
|
188
188
|
def whoami(self):
|
|
189
189
|
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]
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""Tenant-scoped remote Terraform/OpenTofu state administration."""
|
|
2
|
+
import json
|
|
3
|
+
import os
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
import uuid
|
|
6
|
+
|
|
7
|
+
from .api_backend import ApiClient
|
|
8
|
+
from .errors import FufuError, require
|
|
9
|
+
from .terraform_bridge.remote_state import request, state_name
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def call(api, method, suffix='', body=None, *, missing=False):
|
|
13
|
+
status, data = request(api, method, suffix, body)
|
|
14
|
+
if missing and status == 404: return None
|
|
15
|
+
require(200 <= status < 300, 'STATE_REQUEST_REJECTED',
|
|
16
|
+
'State 請求被拒絕(HTTP ' + str(status) + ');請確認 scope、鎖與目前版本。')
|
|
17
|
+
return json.loads(data) if data else {}
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def command(argv, Parser):
|
|
21
|
+
parser = Parser(prog='fufu state', description='AWS/GCP 的 Terraform、OpenTofu 遠端 state;預設只輸出 metadata。')
|
|
22
|
+
parser.add_argument('--provider', choices=['aws', 'gcp'], default='aws')
|
|
23
|
+
sub = parser.add_subparsers(dest='action', required=True)
|
|
24
|
+
sub.add_parser('list')
|
|
25
|
+
for action in ['history', 'export', 'restore', 'unlock', 'delete-empty']:
|
|
26
|
+
entry = sub.add_parser(action)
|
|
27
|
+
entry.add_argument('name', type=state_name)
|
|
28
|
+
if action == 'export': entry.add_argument('--output', type=Path, required=True)
|
|
29
|
+
if action == 'restore': entry.add_argument('--version', type=int, required=True)
|
|
30
|
+
if action == 'unlock': entry.add_argument('--lock-id', required=True)
|
|
31
|
+
if action in {'restore', 'unlock', 'delete-empty'}: entry.add_argument('--yes', action='store_true', required=True)
|
|
32
|
+
args = parser.parse_args(argv)
|
|
33
|
+
with ApiClient(args.provider, read_only=args.action in {'list', 'history', 'export'}) as api:
|
|
34
|
+
if args.action == 'list': return call(api, 'GET')
|
|
35
|
+
suffix = '/' + args.name
|
|
36
|
+
if args.action == 'history': return call(api, 'GET', suffix + '/history')
|
|
37
|
+
if args.action == 'export':
|
|
38
|
+
state = call(api, 'GET', suffix)
|
|
39
|
+
target = args.output.absolute()
|
|
40
|
+
require(target.parent.is_dir() and not target.is_symlink(), 'INVALID_STATE_OUTPUT', '請使用已存在的安全目錄與新的輸出檔案。')
|
|
41
|
+
try: fd = os.open(target, os.O_CREAT | os.O_EXCL | os.O_WRONLY | os.O_NOFOLLOW, 0o600)
|
|
42
|
+
except FileExistsError: raise FufuError('STATE_OUTPUT_EXISTS', '輸出檔已存在;不會覆寫既有備份。') from None
|
|
43
|
+
try:
|
|
44
|
+
with os.fdopen(fd, 'w') as stream:
|
|
45
|
+
json.dump(state, stream); stream.write('\n'); stream.flush(); os.fsync(stream.fileno())
|
|
46
|
+
except BaseException: target.unlink(missing_ok=True); raise
|
|
47
|
+
return {'exported': True, 'serial': state['serial'], 'sensitiveFile': True}
|
|
48
|
+
if args.action == 'unlock':
|
|
49
|
+
call(api, 'UNLOCK', suffix + '/lock', {'ID': args.lock_id})
|
|
50
|
+
return {'unlocked': True}
|
|
51
|
+
lock_id = str(uuid.uuid4())
|
|
52
|
+
call(api, 'LOCK', suffix + '/lock', {'ID': lock_id, 'Operation': 'StateMaintenance'})
|
|
53
|
+
try:
|
|
54
|
+
if args.action == 'restore':
|
|
55
|
+
require(args.version >= 1, 'INVALID_STATE_VERSION', '版本必須是正整數。')
|
|
56
|
+
call(api, 'POST', suffix + '/restore', {'id': lock_id, 'version': args.version})
|
|
57
|
+
return {'restored': True, 'nextStep': 'Run refresh and plan; restoring state does not roll back cloud resources.'}
|
|
58
|
+
call(api, 'DELETE', suffix + '?ID=' + lock_id)
|
|
59
|
+
return {'deleted': True}
|
|
60
|
+
finally:
|
|
61
|
+
call(api, 'UNLOCK', suffix + '/lock', {'ID': lock_id})
|
|
@@ -122,16 +122,23 @@ def parse(d):
|
|
|
122
122
|
p = fields(ports[0], {"containerPort"}, "ports")
|
|
123
123
|
port = p.get("containerPort")
|
|
124
124
|
require(type(port) is int and 1024 <= port <= 65535, "INVALID_PORT", "本機容器 port 範圍為 1024–65535。")
|
|
125
|
-
env = {}
|
|
125
|
+
env, secrets = {}, {}
|
|
126
126
|
items = c.get("env", [])
|
|
127
127
|
require(isinstance(items, list), "INVALID_ENV", "env 必須是陣列。")
|
|
128
128
|
for item in items:
|
|
129
|
-
fields(item, {"name", "value"}, "env")
|
|
129
|
+
fields(item, {"name", "value", "valueFrom"}, "env")
|
|
130
130
|
k = item.get("name")
|
|
131
|
-
require(isinstance(k, str) and k not in env, "INVALID_ENV", "環境變數名稱重複或無效。")
|
|
132
|
-
|
|
131
|
+
require(isinstance(k, str) and k not in env and k not in secrets, "INVALID_ENV", "環境變數名稱重複或無效。")
|
|
132
|
+
if 'valueFrom' in item:
|
|
133
|
+
require('value' not in item, 'INVALID_ENV', 'Secret reference 不能同時配置 value。')
|
|
134
|
+
ref = fields(fields(item['valueFrom'], {'secretKeyRef'}, 'valueFrom').get('secretKeyRef'), {'name', 'key'}, 'secretKeyRef')
|
|
135
|
+
require(isinstance(ref.get('name'), str) and re.fullmatch(r'[A-Za-z0-9_-]{1,255}', ref['name'])
|
|
136
|
+
and re.fullmatch(r'latest|[1-9][0-9]*', str(ref.get('key', ''))), 'INVALID_SECRET_REFERENCE', 'Secret reference 必須是同專案秘密與有效版本。')
|
|
137
|
+
secrets[k] = {'name': ref['name'], 'version': str(ref['key'])}
|
|
138
|
+
environment({k: ''})
|
|
139
|
+
else: env[k] = item.get("value")
|
|
133
140
|
environment(env)
|
|
134
|
-
return {"name": service, "image": image, "port": port, "env": env, **({'resources': allocation} if allocation else {}), **({'role': role} if role else {}), **({'publicAccess': access} if access else {})}
|
|
141
|
+
return {"name": service, "image": image, "port": port, "env": env, **({'secret_references': secrets} if secrets else {}), **({'resources': allocation} if allocation else {}), **({'role': role} if role else {}), **({'publicAccess': access} if access else {})}
|
|
135
142
|
|
|
136
143
|
|
|
137
144
|
def public_plan(spec):
|