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.
Files changed (48) hide show
  1. {fufu_cloud_cli-0.8.0/src/fufu_cloud_cli.egg-info → fufu_cloud_cli-0.9.0}/PKG-INFO +40 -1
  2. {fufu_cloud_cli-0.8.0 → fufu_cloud_cli-0.9.0}/README.md +39 -0
  3. {fufu_cloud_cli-0.8.0 → fufu_cloud_cli-0.9.0}/pyproject.toml +2 -2
  4. {fufu_cloud_cli-0.8.0 → fufu_cloud_cli-0.9.0}/src/fufu_cloud_cli/__init__.py +1 -1
  5. {fufu_cloud_cli-0.8.0 → fufu_cloud_cli-0.9.0}/src/fufu_cloud_cli/api_backend.py +1 -1
  6. {fufu_cloud_cli-0.8.0 → fufu_cloud_cli-0.9.0}/src/fufu_cloud_cli/capabilities.json +47 -13
  7. {fufu_cloud_cli-0.8.0 → fufu_cloud_cli-0.9.0}/src/fufu_cloud_cli/cli.py +32 -6
  8. {fufu_cloud_cli-0.8.0 → fufu_cloud_cli-0.9.0}/src/fufu_cloud_cli/control.py +8 -5
  9. {fufu_cloud_cli-0.8.0 → fufu_cloud_cli-0.9.0}/src/fufu_cloud_cli/docker_backend.py +22 -2
  10. fufu_cloud_cli-0.9.0/src/fufu_cloud_cli/iac_state.py +61 -0
  11. {fufu_cloud_cli-0.8.0 → fufu_cloud_cli-0.9.0}/src/fufu_cloud_cli/manifest.py +12 -5
  12. fufu_cloud_cli-0.9.0/src/fufu_cloud_cli/pubsub.py +66 -0
  13. fufu_cloud_cli-0.9.0/src/fufu_cloud_cli/pulumi-endpoints.json +526 -0
  14. fufu_cloud_cli-0.9.0/src/fufu_cloud_cli/pulumi_bridge.py +205 -0
  15. {fufu_cloud_cli-0.8.0 → fufu_cloud_cli-0.9.0}/src/fufu_cloud_cli/remote_compute.py +4 -1
  16. {fufu_cloud_cli-0.8.0 → fufu_cloud_cli-0.9.0}/src/fufu_cloud_cli/runtime_sdk.py +8 -2
  17. fufu_cloud_cli-0.9.0/src/fufu_cloud_cli/secrets.py +90 -0
  18. fufu_cloud_cli-0.9.0/src/fufu_cloud_cli/terraform_bridge/aws_data.py +63 -0
  19. {fufu_cloud_cli-0.8.0 → fufu_cloud_cli-0.9.0}/src/fufu_cloud_cli/terraform_bridge/google.py +3 -0
  20. fufu_cloud_cli-0.9.0/src/fufu_cloud_cli/terraform_bridge/google_pubsub.py +57 -0
  21. fufu_cloud_cli-0.9.0/src/fufu_cloud_cli/terraform_bridge/remote_state.py +74 -0
  22. {fufu_cloud_cli-0.8.0 → fufu_cloud_cli-0.9.0}/src/fufu_cloud_cli/terraform_bridge/runner.py +77 -20
  23. {fufu_cloud_cli-0.8.0 → fufu_cloud_cli-0.9.0/src/fufu_cloud_cli.egg-info}/PKG-INFO +40 -1
  24. {fufu_cloud_cli-0.8.0 → fufu_cloud_cli-0.9.0}/src/fufu_cloud_cli.egg-info/SOURCES.txt +8 -0
  25. {fufu_cloud_cli-0.8.0 → fufu_cloud_cli-0.9.0}/LICENSE +0 -0
  26. {fufu_cloud_cli-0.8.0 → fufu_cloud_cli-0.9.0}/MANIFEST.in +0 -0
  27. {fufu_cloud_cli-0.8.0 → fufu_cloud_cli-0.9.0}/setup.cfg +0 -0
  28. {fufu_cloud_cli-0.8.0 → fufu_cloud_cli-0.9.0}/src/fufu_cloud_cli/__main__.py +0 -0
  29. {fufu_cloud_cli-0.8.0 → fufu_cloud_cli-0.9.0}/src/fufu_cloud_cli/capacity.py +0 -0
  30. {fufu_cloud_cli-0.8.0 → fufu_cloud_cli-0.9.0}/src/fufu_cloud_cli/cloud_data.py +0 -0
  31. {fufu_cloud_cli-0.8.0 → fufu_cloud_cli-0.9.0}/src/fufu_cloud_cli/cloudflare.py +0 -0
  32. {fufu_cloud_cli-0.8.0 → fufu_cloud_cli-0.9.0}/src/fufu_cloud_cli/errors.py +0 -0
  33. {fufu_cloud_cli-0.8.0 → fufu_cloud_cli-0.9.0}/src/fufu_cloud_cli/http_runtime.py +0 -0
  34. {fufu_cloud_cli-0.8.0 → fufu_cloud_cli-0.9.0}/src/fufu_cloud_cli/lambda_adapter.py +0 -0
  35. {fufu_cloud_cli-0.8.0 → fufu_cloud_cli-0.9.0}/src/fufu_cloud_cli/limits.py +0 -0
  36. {fufu_cloud_cli-0.8.0 → fufu_cloud_cli-0.9.0}/src/fufu_cloud_cli/object_transfer.py +0 -0
  37. {fufu_cloud_cli-0.8.0 → fufu_cloud_cli-0.9.0}/src/fufu_cloud_cli/public_access.py +0 -0
  38. {fufu_cloud_cli-0.8.0 → fufu_cloud_cli-0.9.0}/src/fufu_cloud_cli/runtime/functions/Dockerfile +0 -0
  39. {fufu_cloud_cli-0.8.0 → fufu_cloud_cli-0.9.0}/src/fufu_cloud_cli/runtime/ingress/Dockerfile +0 -0
  40. {fufu_cloud_cli-0.8.0 → fufu_cloud_cli-0.9.0}/src/fufu_cloud_cli/runtime/ingress/proxy.py +0 -0
  41. {fufu_cloud_cli-0.8.0 → fufu_cloud_cli-0.9.0}/src/fufu_cloud_cli/serverless.py +0 -0
  42. {fufu_cloud_cli-0.8.0 → fufu_cloud_cli-0.9.0}/src/fufu_cloud_cli/terraform_bridge/__init__.py +0 -0
  43. {fufu_cloud_cli-0.8.0 → fufu_cloud_cli-0.9.0}/src/fufu_cloud_cli/terraform_bridge/aws.py +0 -0
  44. {fufu_cloud_cli-0.8.0 → fufu_cloud_cli-0.9.0}/src/fufu_cloud_cli/terraform_bridge/common.py +0 -0
  45. {fufu_cloud_cli-0.8.0 → fufu_cloud_cli-0.9.0}/src/fufu_cloud_cli.egg-info/dependency_links.txt +0 -0
  46. {fufu_cloud_cli-0.8.0 → fufu_cloud_cli-0.9.0}/src/fufu_cloud_cli.egg-info/entry_points.txt +0 -0
  47. {fufu_cloud_cli-0.8.0 → fufu_cloud_cli-0.9.0}/src/fufu_cloud_cli.egg-info/requires.txt +0 -0
  48. {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.8.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.8.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"]
@@ -1,3 +1,3 @@
1
1
  """FUFU CLI 與本機部署工具;相容範圍見 capabilities.json。"""
2
2
 
3
- __version__ = "0.8.0"
3
+ __version__ = "0.9.0"
@@ -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
- "limitations": ["Linux-amd64-verified", "terraform-extra-required", "root-default-providers-only", "independent-local-DEV-state", "no-remote-state-service", "no-full-cloud-IAM-or-runtime-equivalence", "no-Terraform-storage-or-Functions-Gen1-Gen2", "unsupported-APIs-and-fields-fail-explicitly"]
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-token-24h",
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-32MiB", "configured-service-timeout-up-to-900s-540s-3600s", "external-edge-timeouts-independent", "no-WebSocket-or-SSE", "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"]
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": "partial",
302
- "reason": "已有 emulator 與 Console 檢視;尚未接入 CLI 部署或函式事件。"
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": "partial",
312
- "reason": "已有 emulator 與 Console 檢視;CLI 未接入。"
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": "partial",
322
- "reason": "已有每租戶 LocalStack 與 Console 目錄;CLI 未接入。"
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] == ['terraform']:
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
- env[k] = item.get("value")
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):